<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>swimminginthecode</title>
    <link>https://dazz6study.tistory.com/</link>
    <description>private dazz6;</description>
    <language>ko</language>
    <pubDate>Mon, 20 Jul 2026 08:53:52 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>dazz6</managingEditor>
    <image>
      <title>swimminginthecode</title>
      <url>https://tistory1.daumcdn.net/tistory/7000334/attach/1e34d8500d5a4347b646889377ba7fd4</url>
      <link>https://dazz6study.tistory.com</link>
    </image>
    <item>
      <title>모각 프로젝트 - #3. 배포 및 회고</title>
      <link>https://dazz6study.tistory.com/237</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;Next.js 를 사용하여 작업하였으므로 자연스럽게 vercel 을 통해 배포했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;vercel 내에서 도메인을 연결했으며 github repository 와 연결하여 자동 배포 CI/CD 를 구축했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이번 프로젝트를 통해서... 정말 많은 것들을 배울 수 있었다... (ㅋㅋ ㅠ)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;일단 백엔드와 프론트엔드를 완전 분리하여 프론트엔드만 작업하려고 하니 적응하는 데 시간이 걸렸다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;평소에는 백엔드까지 함께 작업했으므로 문제 원인을 찾기 쉬웠는데 스웨거를 통해서 엔드포인트를 파악하고...&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;spring security 같은 권한 문제는 어떻게 찾아야 할지 정말 막막하기도 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;내가 프론트엔드만을 지망하는 것이 아니고 풀스택을 지망하고 있기 때문에&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이 다음에는 전처럼 백엔드, 프론트엔드를 모두 직접 구현하는 프로젝트를 진행해야겠다는 결론이 내려졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이전 프로젝트보다 훨씬 발전했다고 느껴지는 부분은 TypeScript 에 대한 이해.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그리고 Component 분리에 좀 익숙해졌다. (아무래도 Next.js 에서 server / client 분리를 component 단위로 할 일이 많아서 그런 듯)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;전보다 피그마 툴도 조금 더 손에 익었고, Next.js 로 프로젝트 하나를 해낼 수 있었다는 것도 나름 발전한 부분인 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;당연히! 아직도 배울 게 너무너무 많다 (참 재미잇는 부분)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;프로젝트 끝났다고 바로 다른 거 해 보겠다고 뛰어드는 것보다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;리팩토링 하면서 조금 더 Next.js 를 이해하는 것이 먼저인 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;아무튼 좋은 경험이었고 또 더 나은 개발을 하기 위해 노력해야지 (_ _)&amp;gt;&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/237</guid>
      <comments>https://dazz6study.tistory.com/237#entry237comment</comments>
      <pubDate>Thu, 7 Aug 2025 18:00:52 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-6. 관리자 페이지</title>
      <link>https://dazz6study.tistory.com/236</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;관리자 페이지에서는 모각존, 모각챌, 뱃지, 고민 상담 게시판을 관리할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yTqoY/btsPERlboTE/7TlscKWl0Hq6CSNxOfq1W1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yTqoY/btsPERlboTE/7TlscKWl0Hq6CSNxOfq1W1/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yTqoY/btsPERlboTE/7TlscKWl0Hq6CSNxOfq1W1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyTqoY%2FbtsPERlboTE%2F7TlscKWl0Hq6CSNxOfq1W1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/34red/btsPEOaX1Jf/2CpRkAxtuEGDniKKSbEpd0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/34red/btsPEOaX1Jf/2CpRkAxtuEGDniKKSbEpd0/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/34red/btsPEOaX1Jf/2CpRkAxtuEGDniKKSbEpd0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F34red%2FbtsPEOaX1Jf%2F2CpRkAxtuEGDniKKSbEpd0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Qn498/btsPESdkhpW/INo2WQjy8PYyUtd8FiEtHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Qn498/btsPESdkhpW/INo2WQjy8PYyUtd8FiEtHK/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Qn498/btsPESdkhpW/INo2WQjy8PYyUtd8FiEtHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQn498%2FbtsPESdkhpW%2FINo2WQjy8PYyUtd8FiEtHK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/DDj2G/btsPE4YWLyh/3uUbM4gCsX6TMNR0vKBdy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/DDj2G/btsPE4YWLyh/3uUbM4gCsX6TMNR0vKBdy1/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/DDj2G/btsPE4YWLyh/3uUbM4gCsX6TMNR0vKBdy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDDj2G%2FbtsPE4YWLyh%2F3uUbM4gCsX6TMNR0vKBdy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;관리자 페이지는 크게 복잡한 것 없이 반환된 리스트 출력, 그리고 일부 추가 기능만 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;챌린지의 경우 '공식 챌린지' 기능을 위해 관리자가 챌린지를 생성할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그리고 뱃지 관리에서는 뱃지를 추가할 수 있는데, 여기서 뱃지의 조건을 설정하면 유저가 조건을 충족할 경우 자동으로 부여된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;예를 들어 성공 횟수 3으로 생성할 경우, 유저가 챌린지를 세 번 정상적으로 완수할 경우 해당 유저에게 해당 뱃지가 부여되는 식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;관리자 페이지는 이벤트가 많고 유저 페이지처럼 성능이 크게 중요하지 않을 것이라고 판단되어 전부 client component 로 작성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그리고 모든 페이지에 표가 필요하므로, 공통 component 로 분리하여 import 하여 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754017103615&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { AdminTableProps } from &quot;@/types/admin.type&quot;;
import Link from &quot;next/link&quot;;

export default function AdminTable&amp;lt;T extends object&amp;gt;({
  columns,
  data,
}: AdminTableProps&amp;lt;T&amp;gt;) {
  return (
    &amp;lt;table className=&quot;w-full border text-sm&quot;&amp;gt;
      &amp;lt;thead&amp;gt;
        &amp;lt;tr className=&quot;bg-borders text-text dark:bg-border-dark dark:text-text-dark&quot;&amp;gt;
          {columns.map((col) =&amp;gt; (
            &amp;lt;th key={String(col.key)} className=&quot;px-4 py-2 border&quot;&amp;gt;
              {col.label}
            &amp;lt;/th&amp;gt;
          ))}
        &amp;lt;/tr&amp;gt;
      &amp;lt;/thead&amp;gt;
      &amp;lt;tbody&amp;gt;
        {data.map((row, i) =&amp;gt; (
          &amp;lt;tr key={i} className=&quot;border-t&quot;&amp;gt;
            {columns.map((col) =&amp;gt; {
              const isCustomKey =
                typeof col.key === &quot;string&quot; &amp;amp;&amp;amp; !(col.key in row);
              const cellValue = isCustomKey
                ? undefined
                : row[col.key as keyof T];

              return (
                &amp;lt;td key={String(col.key)} className=&quot;px-4 py-2 text-center&quot;&amp;gt;
                  {col.render ? (
                    col.render(cellValue, row)
                  ) : col.linkTo &amp;amp;&amp;amp; !isCustomKey ? (
                    &amp;lt;Link
                      href={col.linkTo(row)}
                      className=&quot;text-primary dark:text-primary-dark hover:underline&quot;
                    &amp;gt;
                      {String(cellValue)}
                    &amp;lt;/Link&amp;gt;
                  ) : (
                    String(cellValue ?? &quot;&quot;)
                  )}
                &amp;lt;/td&amp;gt;
              );
            })}
          &amp;lt;/tr&amp;gt;
        ))}
      &amp;lt;/tbody&amp;gt;
    &amp;lt;/table&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그리고 사용할 때는 아래처럼 사용하였다. (모각존 관리 페이지)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754017144900&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import AdminTable from &quot;@/app/components/admin/admin-table&quot;;
import ConfirmModal from &quot;@/app/components/confirm-modal&quot;;
import { ZoneSearch } from &quot;@/lib/shared/zone.api&quot;;
import { ZoneDelete } from &quot;@/lib/client/zone.client.api&quot;;
import Loading from &quot;@/app/loading&quot;;
import { ZoneMainProps } from &quot;@/types/zone.type&quot;;
import { Column } from &quot;@/types/admin.type&quot;;
import Pagination from &quot;@/app/components/shared/paginaiton&quot;;

export default function AdminZonePage() {
  const [zones, setZones] = useState&amp;lt;ZoneMainProps[]&amp;gt;([]);
  const [loading, setLoading] = useState(true);

  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(1);

  const [showModal, setShowModal] = useState(false);
  const [targetId, setTargetId] = useState&amp;lt;number | null&amp;gt;(null);
  const [targetName, setTargetName] = useState&amp;lt;string | null&amp;gt;(null);

  const fetchZones = async (pageNumber = 0) =&amp;gt; {
    setLoading(true);
    try {
      const res = await ZoneSearch({
        sort: &quot;recent&quot;,
        page: pageNumber,
        size: 10,
      });

      setZones(res.data);
      setPage(pageNumber);
      setTotalPages(res.totalPages);
    } catch (e) {
      console.error(&quot;에러:&quot;, e);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() =&amp;gt; {
    fetchZones();
  }, []);

  const openModal = (id: number, name: string) =&amp;gt; {
    setTargetId(id);
    setTargetName(name);
    setShowModal(true);
  };

  const confirmDelete = async () =&amp;gt; {
    if (targetId !== null) {
      await ZoneDelete(targetId);
      await fetchZones(page);
    }
    setShowModal(false);
    setTargetId(null);
    setTargetName(null);
  };

  const columns: Column&amp;lt;ZoneMainProps&amp;gt;[] = [
    { key: &quot;mogakZoneId&quot;, label: &quot;ID&quot; },
    {
      key: &quot;title&quot;,
      label: &quot;이름&quot;,
      linkTo: (row: ZoneMainProps) =&amp;gt; `/zone/${row.mogakZoneId}`,
    },
    { key: &quot;tag&quot;, label: &quot;태그&quot; },
    {
      key: &quot;hasPwd&quot;,
      label: &quot;공개 여부&quot;,
      render: (value) =&amp;gt; (value ? &quot; &quot; : &quot;&quot;),
    },
    {
      key: &quot;actions&quot;,
      label: &quot;관리&quot;,
      render: (_, row: ZoneMainProps) =&amp;gt; (
        &amp;lt;button
          className=&quot;text-sm px-2 py-1 bg-error dark:bg-error-dark text-white rounded&quot;
          onClick={() =&amp;gt; openModal(row.mogakZoneId, row.title)}
        &amp;gt;
          삭제
        &amp;lt;/button&amp;gt;
      ),
    },
  ];

  if (loading) {
    return &amp;lt;Loading /&amp;gt;;
  }

  return (
    &amp;lt;div className=&quot;flex flex-col gap-4&quot;&amp;gt;
      &amp;lt;h1 className=&quot;text-lg font-bold text-primary dark:text-primary-dark&quot;&amp;gt;
        모각존 관리
      &amp;lt;/h1&amp;gt;
      &amp;lt;AdminTable columns={columns} data={zones} /&amp;gt;

      {totalPages &amp;gt; 1 &amp;amp;&amp;amp; (
        &amp;lt;Pagination
          page={page}
          totalPages={totalPages}
          onPageChange={fetchZones}
        /&amp;gt;
      )}

      {showModal &amp;amp;&amp;amp; (
        &amp;lt;ConfirmModal
          message={`정말 '${targetName}' 존을 삭제하시겠습니까?`}
          onConfirm={confirmDelete}
          onCancel={() =&amp;gt; setShowModal(false)}
        /&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/236</guid>
      <comments>https://dazz6study.tistory.com/236#entry236comment</comments>
      <pubDate>Wed, 6 Aug 2025 18:00:11 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-5. 로그인/로그아웃</title>
      <link>https://dazz6study.tistory.com/235</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;모각 프로젝트에서는 카카오 소셜 로그인과 네이버 소셜 로그인을 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYzOe2/btsPC6qlDuB/VVowx8Q2Kcib26KrXJ6SZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYzOe2/btsPC6qlDuB/VVowx8Q2Kcib26KrXJ6SZK/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYzOe2/btsPC6qlDuB/VVowx8Q2Kcib26KrXJ6SZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYzOe2%2FbtsPC6qlDuB%2FVVowx8Q2Kcib26KrXJ6SZK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b5nka8/btsPDpca0IK/uk9m6ofAcviVWg6vPorXlk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b5nka8/btsPDpca0IK/uk9m6ofAcviVWg6vPorXlk/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b5nka8/btsPDpca0IK/uk9m6ofAcviVWg6vPorXlk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb5nka8%2FbtsPDpca0IK%2Fuk9m6ofAcviVWg6vPorXlk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;관리자 및 테스트 로그인을 제외하고는 소셜 로그인으로만 회원가입 및 로그인이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이전 프로젝트에서 구현했던 로그인과 가장 다른 점은 next 의 서버사이드에서 처리했다는 것!&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754016527958&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import SubTitle from &quot;@/app/components/shared/sub-title&quot;;
import kakaoLogin from &quot;./kakao_login.png&quot;;
import naverLogin from &quot;./naver_login.png&quot;;
import Link from &quot;next/link&quot;;
import { useEffect } from &quot;react&quot;;
import { useRouter, useSearchParams } from &quot;next/navigation&quot;;
import { getClientUser } from &quot;@/utils/client/user.client.util&quot;;

export default function LoginPage() {
  const router = useRouter();
  const searchParams = useSearchParams();
  const nextPath = searchParams.get(&quot;next&quot;) || &quot;/&quot;;

  useEffect(() =&amp;gt; {
    if (getClientUser()) {
      router.push(&quot;/&quot;);
    }
  }, []);

  const handleKakaoLogin = () =&amp;gt; {
    window.location.href = `/api/internal/auth/kakao/login?next=${encodeURIComponent(
      nextPath
    )}`;
  };

  const handleNaverLogin = () =&amp;gt; {
    window.location.href = `/api/internal/auth/naver/login?next=${encodeURIComponent(
      nextPath
    )}`;
  };

  return (
    &amp;lt;div&amp;gt;
      &amp;lt;SubTitle contents=&quot;로그인&quot; /&amp;gt;
      &amp;lt;span className=&quot;flex justify-center items-center mt-4 text-center&quot;&amp;gt;
        소셜 로그인을 통해 간편하게 로그인 / 회원가입 해 보세요!
      &amp;lt;/span&amp;gt;
      &amp;lt;div className=&quot;flex flex-col items-center m-10 justify-center gap-y-5&quot;&amp;gt;
        &amp;lt;img
          className=&quot;w-60 cursor-pointer&quot;
          src={kakaoLogin.src}
          alt=&quot;카카오 로그인&quot;
          onClick={handleKakaoLogin}
        /&amp;gt;
        &amp;lt;img
          className=&quot;w-60 cursor-pointer&quot;
          src={naverLogin.src}
          alt=&quot;네이버 로그인&quot;
          onClick={handleNaverLogin}
        /&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;hr className=&quot;w-80 mx-auto dark:border-border-dark border-borders&quot; /&amp;gt;
      &amp;lt;div className=&quot;flex text-sm text-borders dark:text-border-dark justify-center items-center mt-4 text-center&quot;&amp;gt;
        관리자 페이지는
        &amp;lt;Link
          className=&quot;mx-2 underline italic text-secondary dark:text-secondary-dark&quot;
          href=&quot;/login/admin&quot;
        &amp;gt;
          여기
        &amp;lt;/Link&amp;gt;
        를 클릭해 주세요.
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;기존 react+typescript 환경에서 로그인 후 로그인 이전 페이지로 돌아가는 기능 구현을 위해 쿠키를 사용했었는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;SSR 이 섞인 Next 환경에서는 쿠키가 아닌 url 쿼리스트링을 사용하여 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754016614724&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { NextResponse } from &quot;next/server&quot;;

export async function GET(req: Request) {
  const { searchParams } = new URL(req.url);
  const next = searchParams.get(&quot;next&quot;) || &quot;/&quot;;

  const clientId = process.env.KAKAO_REST_API_KEY!;
  const redirectUri = process.env.KAKAO_REDIRECT_URI!;

  const kakaoUrl = `https://kauth.kakao.com/oauth/authorize?client_id=${clientId}&amp;amp;redirect_uri=${redirectUri}&amp;amp;response_type=code&amp;amp;state=${encodeURIComponent(
    next
  )}`;

  return NextResponse.redirect(kakaoUrl);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1754016630282&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { NextRequest, NextResponse } from &quot;next/server&quot;;

export async function GET(req: NextRequest) {
  try {
    const url = new URL(req.url);
    const code = url.searchParams.get(&quot;code&quot;);
    const state = url.searchParams.get(&quot;state&quot;);
    const next = state || &quot;/&quot;;

    if (!code) {
      return NextResponse.json({ error: &quot;No code provided&quot; }, { status: 400 });
    }

    const tokenRes = await fetch(&quot;https://kauth.kakao.com/oauth/token&quot;, {
      method: &quot;POST&quot;,
      headers: {
        &quot;Content-Type&quot;: &quot;application/x-www-form-urlencoded&quot;,
      },
      body: new URLSearchParams({
        grant_type: &quot;authorization_code&quot;,
        client_id: process.env.KAKAO_REST_API_KEY!,
        redirect_uri: process.env.KAKAO_REDIRECT_URI!,
        code,
        client_secret: process.env.KAKAO_CLIENT_SECRET!,
      }),
    });

    const tokenData = await tokenRes.json();
    if (!tokenData.access_token) {
      return NextResponse.json(
        { error: &quot;Token fetch failed&quot;, tokenData },
        { status: 500 }
      );
    }

    const accessToken = tokenData.access_token;

    const userRes = await fetch(&quot;https://kapi.kakao.com/v2/user/me&quot;, {
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });

    const userData = await userRes.json();

    const userInfo = {
      provider: &quot;kakao&quot;,
      providerId: userData.id,
    };

    const res = await fetch(
      `${process.env.BACKEND_API_URL}/auth/social-login`,
      {
        method: &quot;POST&quot;,
        headers: {
          &quot;Content-Type&quot;: &quot;application/json&quot;,
        },
        body: JSON.stringify(userInfo),
      }
    );

    const { token, memberId } = await res.json();

    const redirectUrl = `${
      process.env.FRONTEND_API_URL
    }/login/callback?memberId=${memberId}&amp;amp;token=${token}&amp;amp;next=${encodeURIComponent(
      next
    )}`;

    const response = NextResponse.redirect(redirectUrl);
    response.cookies.set(&quot;jwt&quot;, token, { path: &quot;/&quot;, httpOnly: false });
    response.cookies.set(&quot;provider&quot;, &quot;kakao&quot;);

    return response;
  } catch (err) {
    console.error(&quot;카카오 로그인 처리 오류:&quot;, err);
    return NextResponse.json(
      { error: &quot;Internal server error&quot; },
      { status: 500 }
    );
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이렇게 /api (서버 사이드) 경로에서 외부 통신을 마친 다음, 지정한 callback 페이지로 redirect 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754016680691&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect } from &quot;react&quot;;
import { useSearchParams, useRouter } from &quot;next/navigation&quot;;

import Loading from &quot;@/app/loading&quot;;
import { useAuthStore } from &quot;@/store/authStore&quot;;

export default function AuthCallbackPage() {
  const params = useSearchParams();
  const router = useRouter();
  const { login } = useAuthStore();

  useEffect(() =&amp;gt; {
    const memberId = params.get(&quot;memberId&quot;);
    const token = params.get(&quot;token&quot;);
    const next = params.get(&quot;next&quot;) || &quot;/&quot;;

    if (memberId) {
      localStorage.setItem(&quot;memberId&quot;, memberId);
    }

    if (token) {
      localStorage.setItem(&quot;jwt&quot;, token);
      login(token);
    }

    window.dispatchEvent(new Event(&quot;member:changed&quot;));
    router.replace(next);
  }, [params, router, login]);

  return &amp;lt;Loading /&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그러면 해당 callback 페이지에서 localStorage 에 memberId 와 jwt 를 저장하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;쿼리스트링으로 계속 가지고 다닌 url 로 replace 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;위 예시는 카카오 로그인이며, 네이버 로그인도 크게 다르지 않으므로 줄이겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/235</guid>
      <comments>https://dazz6study.tistory.com/235#entry235comment</comments>
      <pubDate>Tue, 5 Aug 2025 18:00:52 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-4. 비밀 고민 상담</title>
      <link>https://dazz6study.tistory.com/234</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;말 못할 고민을 익명으로 털어놓을 수 있는 게시판으로, 글을 작성할 때 시간을 지정하여 해당 시간이 경과하면 자동으로 삭제된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xNCj0/btsPFEMg2oe/wDGcFzbVvKsVq5SiOcORr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xNCj0/btsPFEMg2oe/wDGcFzbVvKsVq5SiOcORr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xNCj0/btsPFEMg2oe/wDGcFzbVvKsVq5SiOcORr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxNCj0%2FbtsPFEMg2oe%2FwDGcFzbVvKsVq5SiOcORr0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;친구들에게 말 못할 고민을 털어놓자는 콘셉트로 추가된 페이지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;비밀일기장 같은 느낌을 위한 일러스트 이미지를 메인에 삽입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;&lt;b&gt;일러스트디자인은 디자이너 지인이 도와주었습니다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.youtube.com/@티거-r9c&quot;&gt;https://www.youtube.com/@티거-r9c&lt;/a&gt;&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1gE2Y/btsPDZDYfKb/LieJnF9CDyJ5smDVFnyyp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1gE2Y/btsPDZDYfKb/LieJnF9CDyJ5smDVFnyyp0/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1gE2Y/btsPDZDYfKb/LieJnF9CDyJ5smDVFnyyp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1gE2Y%2FbtsPDZDYfKb%2FLieJnF9CDyJ5smDVFnyyp0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/deGWzn/btsPEqg8Jsl/ehvJiaVbWAeRTxF3wdxkh1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/deGWzn/btsPEqg8Jsl/ehvJiaVbWAeRTxF3wdxkh1/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/deGWzn/btsPEqg8Jsl/ehvJiaVbWAeRTxF3wdxkh1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdeGWzn%2FbtsPEqg8Jsl%2FehvJiaVbWAeRTxF3wdxkh1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;고민을 작성하는 시점에 시간을 지정할 수 있으며, 기본 시간은 24시간이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;작성을 완료하면 해당 작성글로 바로 redirect 되고, 삭제까지 남은 시간이 실시간으로 줄어들게 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;첫 번째 댓글은 서버가 반환한 '모각봇' 의 AI 위로 답변이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754016224494&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import { AiFillHeart, AiOutlineHeart } from &quot;react-icons/ai&quot;;
import {
  timeArrayToSeconds,
  secondsToTimeArray,
  formatTimeArray,
} from &quot;@/utils/shared/time.util&quot;;
import { AdviceDetailResponse } from &quot;@/types/advice.type&quot;;
import AdviceCommentsCard from &quot;./advice-comments-card&quot;;
import { AdviceEmpathyPost } from &quot;@/lib/client/advice.client.api&quot;;

import AlertModal from &quot;@/app/components/custom-modal&quot;;
import { useRouter } from &quot;next/navigation&quot;;
import { useAuthStore } from &quot;@/store/authStore&quot;;

export default function AdviceContents({
  id,
  data,
}: {
  id: string;
  data: AdviceDetailResponse;
}) {
  const [showModal, setShowModal] = useState(false);
  const [empathyCount, setEmpathyCount] = useState(data.empathyCount);
  const [hasEmpathized, setHasEmpathized] = useState(data.hasEmpathized);

  const [restSeconds, setRestSeconds] = useState(
    timeArrayToSeconds(data.restTime)
  );

  const jwt = useAuthStore((state) =&amp;gt; state.jwt);

  useEffect(() =&amp;gt; {
    const timer = setInterval(() =&amp;gt; {
      setRestSeconds((prev) =&amp;gt; (prev &amp;gt; 0 ? prev - 1 : 0));
    }, 1000);
    return () =&amp;gt; clearInterval(timer);
  }, []);

  const handleEmpathy = async () =&amp;gt; {
    if (!jwt) {
      setShowModal(true);
      return;
    }

    try {
      const res = await AdviceEmpathyPost(id);
      const { empathyCount, hasEmpathized } = res;
      setEmpathyCount(empathyCount);
      setHasEmpathized(hasEmpathized);
    } catch (err) {
      console.log(&quot;공감 실패 : &quot; + err);
    }
  };

  const router = useRouter();

  const toLogin = () =&amp;gt; {
    router.push(`/login`);
  };

  return (
    &amp;lt;div className=&quot;flex flex-col lg:flex-row gap-4 max-w-screen-xl mx-auto px-4 sm:px-6 py-10&quot;&amp;gt;
      &amp;lt;div className=&quot;lg:w-[65%] w-full flex flex-col gap-6&quot;&amp;gt;
        &amp;lt;div className=&quot;flex items-center justify-between border-borders dark:border-border-dark border-b pb-2&quot;&amp;gt;
          &amp;lt;div&amp;gt;
            &amp;lt;h2 className=&quot;text-lg font-bold text-primary dark:text-primary-dark&quot;&amp;gt;
              {data.title}
            &amp;lt;/h2&amp;gt;
            &amp;lt;p className=&quot;text-sm text-border-dark dark:text-borders&quot;&amp;gt;
              {formatTimeArray(secondsToTimeArray(restSeconds))} 남음
            &amp;lt;/p&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;p className=&quot;text-sm text-text dark:text-text-dark whitespace-pre-line&quot;&amp;gt;
          {data.body}
        &amp;lt;/p&amp;gt;

        &amp;lt;button
          className=&quot;flex items-center gap-1 text-error dark:text-error-dark text-sm&quot;
          onClick={handleEmpathy}
        &amp;gt;
          {hasEmpathized ? (
            &amp;lt;AiFillHeart size={18} /&amp;gt;
          ) : (
            &amp;lt;AiOutlineHeart size={18} /&amp;gt;
          )}
          공감 {empathyCount}개
        &amp;lt;/button&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;w-full lg:w-[35%]&quot;&amp;gt;
        &amp;lt;AdviceCommentsCard worryId={id} /&amp;gt;
      &amp;lt;/div&amp;gt;
      {showModal &amp;amp;&amp;amp; (
        &amp;lt;AlertModal
          message=&quot;로그인 후 이용해 주세요.&quot;
          moveMsg=&quot;로그인하기&quot;
          onMove={toLogin}
          confirmMsg=&quot;닫기&quot;
          onConfirm={() =&amp;gt; setShowModal(false)}
        /&amp;gt;
      )}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;advice 도 동일하게 page.tsx 는 server component 로 유지하며 wrapper(contents) 를 작성하여 import 하는 방식을 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;사실 여러 번 구현해 보았던 게시판과 크게 다른 부분은 '삭제까지의 시간 출력' 부분밖에 없었고&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;남은 시간 출력 후 클라이언트단에서 1초씩 감소하는 방식을 사용하여 크게 어렵지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/234</guid>
      <comments>https://dazz6study.tistory.com/234#entry234comment</comments>
      <pubDate>Mon, 4 Aug 2025 18:00:37 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-3. 모각챌</title>
      <link>https://dazz6study.tistory.com/233</link>
      <description>&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;모각챌은 여러 명이 하나의 미션을 인증하는 공간이다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;챌린지 기간 동안 탈락하지 않고 완수하게 되면, 성공 뱃지를 부여받는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Sbgv0/btsPEwaxn6Q/WnknVk1rGoEY3G0zpPdpz1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Sbgv0/btsPEwaxn6Q/WnknVk1rGoEY3G0zpPdpz1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Sbgv0/btsPEwaxn6Q/WnknVk1rGoEY3G0zpPdpz1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSbgv0%2FbtsPEwaxn6Q%2FWnknVk1rGoEY3G0zpPdpz1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;모각챌 메인은 challenge-search-card.tsx 로 별개 작성하였고, 챌린지 상태 (진행 전, 진행 중, 종료) 및 공식 챌린지 여부로 필터링 가능하며&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;최신순과 참가자순으로 정렬할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1754015026417&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { useEffect, useState } from &quot;react&quot;;
import SearchCardView from &quot;@/app/components/shared/search-card-view&quot;;
import {
  ChallengeMainProps,
  ChallengeSearchCardProps,
  ChallengeStatusType,
} from &quot;@/types/challenge.type&quot;;
import ChallengeMainCard from &quot;./challenge-main-card&quot;;
import { ChallengeSearch } from &quot;@/lib/shared/challenge.api&quot;;
import ChallengeMainCardSkeleton from &quot;../../../components/skeleton/challenge/challenge-main-card-skeleton&quot;;
import Pagination from &quot;@/app/components/shared/paginaiton&quot;;
import { mapSort } from &quot;@/utils/shared/sort.util&quot;;

export default function ChallengeSearchCard({
  title,
  description,
  sort,
  section,
}: ChallengeSearchCardProps) {
  const [search, setSearch] = useState(&quot;&quot;);
  const [official, setOfficial] = useState&amp;lt;boolean&amp;gt;(false);
  const [finalSort, setFinalSort] = useState(sort);
  const [data, setData] = useState&amp;lt;ChallengeMainProps[]&amp;gt;([]);
  const [loading, setLoading] = useState(true);
  const [status, setStatus] = useState&amp;lt;string | null&amp;gt;(null);

  const [page, setPage] = useState(0);
  const [totalPages, setTotalPages] = useState(1);

  const fetchData = async (pageNumber = 0) =&amp;gt; {
    setLoading(true);
    try {
      const res = await ChallengeSearch({
        search,
        official,
        sort: mapSort(finalSort),
        status: status as ChallengeStatusType,
        page: pageNumber,
        size: 12,
      });

      setData(res.data || []);
      setTotalPages(res.totalPages || 1);
      setPage(pageNumber);
    } catch (err) {
      console.error(&quot;챌린지 검색 실패&quot;, err);
    } finally {
      setLoading(false);
    }
  };

  useEffect(() =&amp;gt; {
    fetchData(0);
  }, []);

  useEffect(() =&amp;gt; {
    fetchData(0);
  }, [official, finalSort, status]);

  const toggleOfficial = () =&amp;gt; {
    setOfficial((prev) =&amp;gt; !prev);
  };

  return (
    &amp;lt;&amp;gt;
      &amp;lt;div className=&quot;w-full flex justify-center&quot;&amp;gt;
        &amp;lt;SearchCardView
          title={title}
          description={description}
          section={section}
          official={official}
          onOfficialToggle={toggleOfficial}
          sort={finalSort}
          onSortChange={setFinalSort}
          search={search}
          onSearchChange={setSearch}
          onSearch={() =&amp;gt; fetchData(0)}
          status={status}
          onStatusChange={setStatus}
        /&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 mt-8&quot;&amp;gt;
        {loading ? (
          Array.from({ length: 6 }).map((_, i) =&amp;gt; (
            &amp;lt;ChallengeMainCardSkeleton key={`challenge-skeleton-${i}`} /&amp;gt;
          ))
        ) : data.length &amp;gt; 0 ? (
          data.map((item) =&amp;gt; (
            &amp;lt;ChallengeMainCard
              key={item.challengeId}
              challengeId={item.challengeId}
              title={item.title}
              description={item.description}
              participants={item.participants}
              official={item.official}
              status={item.status}
            /&amp;gt;
          ))
        ) : (
          &amp;lt;div className=&quot;col-span-full text-center text-gray-500 py-10&quot;&amp;gt;
            검색 결과가 없습니다.
          &amp;lt;/div&amp;gt;
        )}
      &amp;lt;/div&amp;gt;

      &amp;lt;Pagination
        page={page}
        totalPages={totalPages}
        onPageChange={(next) =&amp;gt; fetchData(next)}
      /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;모각존과 모각챌 모두 검색 기능이 필요하기 때문에 search-card-view.tsx 를 작성하여 import 해서 사용했다&lt;/p&gt;
&lt;pre id=&quot;code_1754015112834&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import Link from &quot;next/link&quot;;
import Button from &quot;../ui/button&quot;;
import Input from &quot;../ui/input&quot;;
import { AiOutlineSearch } from &quot;react-icons/ai&quot;;
import { TagsProps } from &quot;@/types/shared.type&quot;;
import SubTitle from &quot;./sub-title&quot;;

type Props = {
  title: string;
  description: string;
  section: string;
  sort: string;
  onSortChange: (val: string) =&amp;gt; void;
  search: string;
  onSearchChange: (val: string) =&amp;gt; void;
  onSearch: () =&amp;gt; void;

  tags?: TagsProps[];
  selectedTag?: string | null;
  onTagClick?: (tag: string) =&amp;gt; void;

  official?: boolean;
  onOfficialToggle?: () =&amp;gt; void;

  status?: string | null;
  onStatusChange?: (val: string | null) =&amp;gt; void;
};

export default function SearchCardView({
  title,
  description,
  section,
  sort,
  onSortChange,
  search,
  onSearchChange,
  onSearch,
  tags,
  selectedTag,
  onTagClick,
  official,
  onOfficialToggle,
  status,
  onStatusChange,
}: Props) {
  return (
    &amp;lt;div className=&quot;flex flex-col w-fit items-center gap-2&quot;&amp;gt;
      &amp;lt;SubTitle contents={title} /&amp;gt;
      &amp;lt;span className=&quot;text-sm text-border-dark dark:text-borders&quot;&amp;gt;
        {description}
      &amp;lt;/span&amp;gt;

      &amp;lt;div className=&quot;mt-2 flex items-center gap-2&quot;&amp;gt;
        &amp;lt;Input
          value={search}
          onInput={(e) =&amp;gt; onSearchChange(e.currentTarget.value)}
        /&amp;gt;
        {onStatusChange &amp;amp;&amp;amp; (
          &amp;lt;select
            value={status ?? &quot;&quot;}
            onChange={(e) =&amp;gt;
              onStatusChange(e.target.value === &quot;&quot; ? null : e.target.value)
            }
            className=&quot;bg-white dark:bg-border-dark dark:text-text-dark rounded-md px-5 py-2&quot;
          &amp;gt;
            &amp;lt;option value=&quot;&quot;&amp;gt;전체&amp;lt;/option&amp;gt;
            &amp;lt;option value=&quot;BEFORE&quot;&amp;gt;진행 전&amp;lt;/option&amp;gt;
            &amp;lt;option value=&quot;ONGOING&quot;&amp;gt;진행 중&amp;lt;/option&amp;gt;
            &amp;lt;option value=&quot;COMPLETED&quot;&amp;gt;종료&amp;lt;/option&amp;gt;
          &amp;lt;/select&amp;gt;
        )}
        &amp;lt;div className=&quot;flex items-center h-10&quot;&amp;gt;
          &amp;lt;AiOutlineSearch
            onClick={onSearch}
            className=&quot;text-primary w-7 h-7 cursor-pointer&quot;
          /&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;flex flex-col md:flex-row md:items-center gap-4 md:gap-10 mt-4 w-full&quot;&amp;gt;
        &amp;lt;div className=&quot;flex flex-wrap gap-2&quot;&amp;gt;
          {tags &amp;amp;&amp;amp;
            tags.map((tag, index) =&amp;gt; (
              &amp;lt;div
                key={`${tag.name}-${index}`}
                onClick={() =&amp;gt; onTagClick?.(tag.name)}
                className={`border cursor-pointer rounded-md px-5 py-1 ${
                  selectedTag === tag.name
                    ? &quot;bg-secondary text-white&quot;
                    : &quot;border-borders dark:border-border-dark&quot;
                }`}
              &amp;gt;
                #{tag.name}
              &amp;lt;/div&amp;gt;
            ))}

          {onOfficialToggle &amp;amp;&amp;amp; (
            &amp;lt;div
              onClick={onOfficialToggle}
              className={`border cursor-pointer rounded-md px-5 py-1 ${
                official
                  ? &quot;bg-secondary text-white&quot;
                  : &quot;border-borders dark:border-border-dark&quot;
              }`}
            &amp;gt;
              #공식 챌린지
            &amp;lt;/div&amp;gt;
          )}
        &amp;lt;/div&amp;gt;

        &amp;lt;select
          value={sort}
          onChange={(e) =&amp;gt; onSortChange(e.target.value)}
          className=&quot;bg-white dark:bg-border-dark dark:text-text-dark rounded-md px-5 py-2&quot;
        &amp;gt;
          &amp;lt;option value=&quot;최신순&quot;&amp;gt;최신순&amp;lt;/option&amp;gt;
          &amp;lt;option value=&quot;참여순&quot;&amp;gt;참가자 순&amp;lt;/option&amp;gt;
        &amp;lt;/select&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;Link href={section === &quot;모각존&quot; ? `/zone/create` : `/challenge/create`}&amp;gt;
        &amp;lt;Button&amp;gt;{section} 만들기&amp;lt;/Button&amp;gt;
      &amp;lt;/Link&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;또한 페이지네이션은 Pagination Component 를 통해 여러 페이지에서 활용하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1754015060805&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

interface PaginationProps {
  page: number;
  totalPages: number;
  onPageChange: (nextPage: number) =&amp;gt; void;
}

function createPageRange(current: number, total: number, delta = 2): number[] {
  const range = [];

  const start = Math.max(0, current - delta);
  const end = Math.min(total - 1, current + delta);

  for (let i = start; i &amp;lt;= end; i++) {
    range.push(i);
  }

  return range;
}

function classNames(...classes: (string | boolean | undefined | null)[]) {
  return classes.filter(Boolean).join(&quot; &quot;);
}

export default function Pagination({
  page,
  totalPages,
  onPageChange,
}: PaginationProps) {
  const pages = createPageRange(page, totalPages);

  return (
    &amp;lt;div className=&quot;flex items-center justify-center mt-10 gap-2 text-sm select-none&quot;&amp;gt;
      &amp;lt;button
        className=&quot;px-3 py-1 border rounded hover:bg-gray-100 dark:hover:bg-border-dark disabled:opacity-40&quot;
        onClick={() =&amp;gt; onPageChange(page - 1)}
        disabled={page &amp;lt;= 0}
      &amp;gt;
        이전
      &amp;lt;/button&amp;gt;

      {pages.map((p) =&amp;gt; (
        &amp;lt;button
          key={p}
          onClick={() =&amp;gt; onPageChange(p)}
          className={classNames(
            &quot;px-3 py-1 border rounded hover:bg-primary/10&quot;,
            page === p
              ? &quot;bg-primary text-white font-bold border-primary&quot;
              : &quot;text-gray-600 dark:text-gray-300&quot;
          )}
        &amp;gt;
          {p + 1}
        &amp;lt;/button&amp;gt;
      ))}

      &amp;lt;button
        className=&quot;px-3 py-1 border rounded hover:bg-gray-100 dark:hover:bg-border-dark disabled:opacity-40&quot;
        onClick={() =&amp;gt; onPageChange(page + 1)}
        disabled={page &amp;gt;= totalPages - 1}
      &amp;gt;
        다음
      &amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Dznx3/btsPC89uAyN/oyHvxladLexfl34FWzFkv1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Dznx3/btsPC89uAyN/oyHvxladLexfl34FWzFkv1/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Dznx3/btsPC89uAyN/oyHvxladLexfl34FWzFkv1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FDznx3%2FbtsPC89uAyN%2FoyHvxladLexfl34FWzFkv1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ban3E3/btsPFDT6Uct/gnLy9ZO9vbAF7XOL3P5Cw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ban3E3/btsPFDT6Uct/gnLy9ZO9vbAF7XOL3P5Cw0/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ban3E3/btsPFDT6Uct/gnLy9ZO9vbAF7XOL3P5Cw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fban3E3%2FbtsPFDT6Uct%2FgnLy9ZO9vbAF7XOL3P5Cw0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;모각챌 하나에 접근하게 되면, 상세 페이지가 출력된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;참가자 section을 클릭하면 참가자 목록 페이지로 redirect 되며, 탈락자의 경우 프로필 사진이 흑백으로 렌더링된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;전체 기간 대비 진행률과 생존자 비율이 간략하게 포함되어 있으며, 사진과 문구를 함께 인증할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0R2Of/btsPE2fLUqT/k802IkjvsYfTTf7FHxJhh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0R2Of/btsPE2fLUqT/k802IkjvsYfTTf7FHxJhh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0R2Of/btsPE2fLUqT/k802IkjvsYfTTf7FHxJhh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0R2Of%2FbtsPE2fLUqT%2Fk802IkjvsYfTTf7FHxJhh0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;인증을 완료한 모습이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;인증은 하루에 한 번 가능하므로 인증이 완료되었을 경우 '오늘 인증이 완료되었어요!' 문구와 함께 인증 관련 section 이 비렌더링된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;인증 게시글은 최신순으로 인증 section 에 렌더링되며 pagination 도 적용되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;challenge 페이지도 page.tsx 는 Server component 로 유지하되, wrapper 를 만들어 client component 로 만들어 주었다.&lt;/p&gt;
&lt;pre id=&quot;code_1754015394406&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import { convertDate } from &quot;@/utils/shared/date.util&quot;;
import ChallengeHeader from &quot;./challenge-header&quot;;
import ChallengeSurvivors from &quot;./challenge-survivors&quot;;
import ChallengeProofGrid from &quot;./challenge-proof-grid&quot;;
import ChallengeSummary from &quot;./challenge-summary&quot;;
import { ChallengeDetileResponse } from &quot;@/types/challenge.type&quot;;
import { ChallengeDetail } from &quot;@/lib/client/challenge.client.api&quot;;
import { useEffect, useState } from &quot;react&quot;;
import { useAuthStore } from &quot;@/store/authStore&quot;;

export default function ChallengeWrapper({
  id,
  initial,
}: {
  id: string;
  initial: ChallengeDetileResponse;
}) {
  const { jwt } = useAuthStore();
  const [data, setData] = useState&amp;lt;ChallengeDetileResponse&amp;gt;(initial);
  const [refreshKey, setRefreshKey] = useState&amp;lt;number&amp;gt;(Date.now());

  useEffect(() =&amp;gt; {
    if (!jwt) {
      return;
    }
    refetch();
  }, []);

  const refetch = async () =&amp;gt; {
    const reload = await ChallengeDetail(id);
    setData(reload);
    setRefreshKey(Date.now());
  };

  return (
    &amp;lt;div className=&quot;flex flex-col lg:flex-row gap-10&quot;&amp;gt;
      &amp;lt;div className=&quot;flex-1 flex flex-col gap-6&quot;&amp;gt;
        &amp;lt;ChallengeHeader
          challengeId={id}
          status={data.status}
          title={data.title}
          description=&quot;챌린지에 참여하고 인증샷을 올려보세요!&quot;
          challengeOwnerId={data.challengeOwnerId}
          startDate={convertDate(data.startDate)}
          endDate={convertDate(data.endDate)}
          official={data.official}
          totalParticipants={data.totalParticipants}
        /&amp;gt;

        &amp;lt;ChallengeSurvivors
          avatars={data.memberImageList}
          extraCount={Math.max(
            data.totalParticipants - data.memberImageList.length,
            0
          )}
        /&amp;gt;
        {data.joined &amp;amp;&amp;amp; data.status !== &quot;BEFORE&quot; &amp;amp;&amp;amp; (
          &amp;lt;ChallengeProofGrid challengeId={id} refreshTrigger={refreshKey} /&amp;gt;
        )}
      &amp;lt;/div&amp;gt;

      &amp;lt;div className=&quot;w-full lg:w-[280px] shrink-0&quot;&amp;gt;
        &amp;lt;ChallengeSummary challengeId={id} {...data} onRefetch={refetch} /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;ChallengeSummary Component 에 포함된 통계 component 는 아래처럼 작성하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1754015489160&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type ProgressBarProps = {
  label: string;
  value: number;
};

export function ChallengeSummaryChart({ label, value }: ProgressBarProps) {
  return (
    &amp;lt;div className=&quot;w-full&quot;&amp;gt;
      &amp;lt;div className=&quot;flex justify-between text-xs mb-1 text-border-dark dark:text-borders&quot;&amp;gt;
        &amp;lt;span&amp;gt;{label}&amp;lt;/span&amp;gt;
        &amp;lt;span&amp;gt;{value}%&amp;lt;/span&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div className=&quot;w-full h-3 bg-gray-200 dark:bg-zinc-700 rounded-full overflow-hidden&quot;&amp;gt;
        &amp;lt;div
          className=&quot;h-full bg-primary dark:bg-primary-dark transition-all duration-300&quot;
          style={{ width: `${value}%` }}
        /&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;간단한 차트라서 tailwind css 선에서 끝낼 수 있었는데, 다음에는 더 다양한 통계를 다룰 수 있는 라이브러리를 적용해 보고 싶다.&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/233</guid>
      <comments>https://dazz6study.tistory.com/233#entry233comment</comments>
      <pubDate>Sun, 3 Aug 2025 18:00:54 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-2. 모각존</title>
      <link>https://dazz6study.tistory.com/232</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;모각존은 여러 명이 함께 공부할 수 있는 공간으로, 상태 변경을 통해 해당 유저의 공부 여부를 알 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;입/퇴장과 상태 변경, 채팅까지 모두 실시간으로 반영되어야 하므로 웹소켓을 사용했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n5Qb7/btsPEsev0ov/6e9YBVsetHQZspULAW3Rvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n5Qb7/btsPEsev0ov/6e9YBVsetHQZspULAW3Rvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n5Qb7/btsPEsev0ov/6e9YBVsetHQZspULAW3Rvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn5Qb7%2FbtsPEsev0ov%2F6e9YBVsetHQZspULAW3Rvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1753934945049&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export async function generateMetadata({
  params,
}: {
  params: Promise&amp;lt;{ id: string }&amp;gt;;
}) {
  const { id } = await params;
  const jwt = await getJwtFromServerCookie();

  const data = await ZoneDetail(id, jwt);

  return {
    title: `모각 | ${data.name}`,
    description: `${data.name} 모각존에서 함께 공부해요.`,
  };
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;MetaData title 을 모각존 이름으로 설정하기 위해 generateMetadata 를 사용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;또한 웹소켓 연결 전 입장 혹은 상태 변경을 시도할 수 없도록, 연결 중일 때는 skeleton UI 가 출력되도록 설정했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/chdMjE/btsPE9eo0fk/r8mV49ze0l2WhktN9Lphk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/chdMjE/btsPE9eo0fk/r8mV49ze0l2WhktN9Lphk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/chdMjE/btsPE9eo0fk/r8mV49ze0l2WhktN9Lphk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FchdMjE%2FbtsPE9eo0fk%2Fr8mV49ze0l2WhktN9Lphk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;모각존 이미지는 방장이 설정 및 변경 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;제일 애먹었던 부분은 아무래도 웹 소켓 연결 및 브로드캐스팅인데... 먼저 소켓과 관련된 함수는 이렇게 작성해 두었다.&lt;/p&gt;
&lt;pre id=&quot;code_1753935294816&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import SockJS from &quot;sockjs-client&quot;;
import { Client, IMessage } from &quot;@stomp/stompjs&quot;;
import { getJwtFromCookie } from &quot;@/utils/client/auth.client.util&quot;;

let stompClient: Client | null = null;
let isConnecting = false;

type TopicHandler&amp;lt;T = unknown&amp;gt; = {
  mogakZoneId: string;
  onMessage: (msg: T) =&amp;gt; void;
};

const subscribedTopics = new Map&amp;lt;string, TopicHandler&amp;gt;();

export async function connectAndSubscribeSocket&amp;lt;T&amp;gt;({
  topic,
  mogakZoneId,
  onMessage,
}: {
  topic: string;
  mogakZoneId: string;
  onMessage: (msg: T) =&amp;gt; void;
}) {  
  const token = getJwtFromCookie();
  if (!token) {
    console.log(&quot;[socket] JWT 없음. 구독 중단&quot;);
    return;
  }

  if (isConnecting) { 
    await waitUntilConnected();
  } else if (!stompClient || !stompClient.connected) { 
    await ensureConnected(mogakZoneId);
  } 

  subscribe(topic, mogakZoneId, onMessage);
}

export function subscribe&amp;lt;T&amp;gt;(
  topic: string,
  mogakZoneId: string,
  onMessage: (msg: T) =&amp;gt; void
) {
  if (!stompClient || !stompClient.connected) {
    return;
  }

  if (subscribedTopics.has(topic)) { 
    return;
  }

  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  console.log(&quot;[socket] 토픽 구독 시작:&quot;, topic);
  stompClient.subscribe(
    topic,
    (message: IMessage) =&amp;gt; {
      try {
        const payload = JSON.parse(message.body);

        const isSystemBroadcast =
          typeof payload === &quot;object&quot; &amp;amp;&amp;amp;
          &quot;sessionId&quot; in payload &amp;amp;&amp;amp;
          &quot;memberId&quot; in payload &amp;amp;&amp;amp;
          &quot;mogakZoneId&quot; in payload &amp;amp;&amp;amp;
          &quot;name&quot; in payload &amp;amp;&amp;amp;
          Object.keys(payload).length === 4;

        if (isSystemBroadcast) { 
          return;
        }

        console.log(&quot;[socket] 수신된 메시지:&quot;, payload, topic);
        onMessage(payload);
      } catch (err) {
        console.error(&quot;[socket] 메시지 파싱 오류:&quot;, err);
      }
    },
    { Authorization: jwt, mogakZoneId }
  );

  subscribedTopics.set(topic, {
    mogakZoneId,
    onMessage: (msg) =&amp;gt; onMessage(msg as T),
  });
}

let onConnectedCallback: (() =&amp;gt; void) | null = null;

export function setOnConnectedCallback(cb: () =&amp;gt; void) {
  onConnectedCallback = cb;
}

export async function ensureConnected(mogakZoneId: string): Promise&amp;lt;void&amp;gt; {
  const token = getJwtFromCookie();
  if (!token) {
    throw new Error(&quot;JWT 없음&quot;);
  }

  if (!stompClient || !stompClient.active) {
    stompClient = new Client({
      webSocketFactory: () =&amp;gt;
        new SockJS(`${process.env.NEXT_PUBLIC_BACKEND_API_URL}/api/mogak/ws`),
      connectHeaders: {
        Authorization: token,
        mogakZoneId,
      },
      reconnectDelay: 5000,
      onConnect: () =&amp;gt; {
        console.log(&quot;[socket] 웹소켓 연결 성공&quot;);
        if (onConnectedCallback) {
          onConnectedCallback();
        }
        subscribedTopics.forEach((sub, topic) =&amp;gt;
          subscribe(topic, sub.mogakZoneId, sub.onMessage)
        );
      },
      onStompError: (frame) =&amp;gt; {
        console.error(&quot;[socket] STOMP 에러:&quot;, frame);
      },
    });
  }

  if (!stompClient.connected &amp;amp;&amp;amp; !isConnecting) {
    isConnecting = true;
    stompClient.activate();
    await waitUntilConnected();
    isConnecting = false;
  } else {
    await waitUntilConnected();
  }
}

function waitUntilConnected(timeout = 10000): Promise&amp;lt;void&amp;gt; {
  return new Promise((resolve, reject) =&amp;gt; {
    const start = Date.now();
    const check = () =&amp;gt; {
      if (stompClient &amp;amp;&amp;amp; stompClient.connected) {
        resolve();
      } else if (Date.now() - start &amp;gt; timeout) {
        reject(&quot;[socket] 소켓 연결 타임아웃&quot;);
      } else {
        setTimeout(check, 100);
      }
    };
    check();
  });
}

export async function sendStatus(
  zoneId: string,
  status: string,
  memberId: string
) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}/status`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
      body: JSON.stringify({ memberId, status }),
    }); 
  } catch (err) {
    console.error(&quot;[socket] status 전송 실패&quot;, err);
  }
}

export async function sendChat(
  zoneId: string,
  memberId: string,
  message: string
) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}/message`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
      body: JSON.stringify({ memberId, message }),
    }); 
  } catch (err) {
    console.error(&quot;[socket] chat 전송 실패&quot;, err);
  }
}

export async function sendDetail(zoneId: string) { 
  const jwt = getJwtFromCookie();
  if (!jwt) {
    return;
  }

  try {
    await ensureConnected(zoneId);
    stompClient!.publish({
      destination: `/app/api/mogak/zone/${zoneId}`,
      headers: { Authorization: jwt, mogakZoneId: String(zoneId) },
    }); 
  } catch (err) {
    console.error(&quot;[socket] detail 전송 실패&quot;, err);
  }
}

export function disconnectSocket() {
  if (stompClient) {
    console.log(&quot;[socket] 웹소켓 연결 종료 및 구독 초기화&quot;);
    stompClient.deactivate();
    stompClient = null;
    subscribedTopics.clear();
    isConnecting = false;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;웹 소켓 연결 / 구독 / 각 기능별 브로드캐스팅 / 연결 끊기&lt;/b&gt; 가 가능하며 let 으로 선언한 stompClient 와 isConneting 으로 연결 여부를 판단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이를 기능이 필요한 곳에서 연결 시도 및 구독, 그리고 이벤트가 발생했을 때 브로드캐스팅만 해 주면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;모각존 detail 의 page.tsx 는 server component 로 유지하고 싶었으므로 zone-wrapper.tsx 파일을 생성하여 page.tsx 에서는 import 하는 방식으로 사용하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1753935455562&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; useEffect(() =&amp;gt; {
    setOnConnectedCallback(() =&amp;gt; {
      console.log(&quot;소켓 연결 완료&quot;);
      setConnected(true);
    });

    const timeout = setTimeout(() =&amp;gt; {
      if (!connected &amp;amp;&amp;amp; id === data.hostMemberId) {
        console.warn(&quot;소켓 연결 실패: 재참가 필요&quot;);
        setJoined(false);
        setShowReconnectModal(true);
      }
    }, 3000);

    connectAndSubscribeSocket&amp;lt;ZoneDetailResponse&amp;gt;({
      topic: `/topic/api/mogak/zone/${id}`,
      mogakZoneId: id,
      onMessage: (parsedRes) =&amp;gt; {
        setLoadData((prev) =&amp;gt; {
          const base = prev ?? data;
          return {
            ...base!,
            zoneMemberInfoList: parsedRes.zoneMemberInfoList,
            joinedUserCount: parsedRes.joinedUserCount,
          };
        });
      },
    }).then(() =&amp;gt; {
      const userId = localStorage.getItem(&quot;memberId&quot;);

      if (joined) {
        sendStatus(id, &quot;RESTING&quot;, String(userId));
      }
    });

    return () =&amp;gt; {
      clearTimeout(timeout);
      setConnected(false);
      disconnectSocket();
    };
  }, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;위는 참여 멤버의 입/퇴장 이벤트를 위한 구독 및 브로드캐스팅이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;.then()을 사용하여 입장 후 자동으로 '휴식 중' status 설정되도록 하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;같은 방식으로 상태 변경을 위한 user-card 와 chatting-ui 에도 웹소켓 코드를 삽입하였는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;처음에 user-card 에 connectAndSubscribeSocket 함수를 작성했더니 유저 component 별로 구독을 시도하고 memberId 등이 꼬여 에러가 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그래서 &lt;i&gt;&lt;b&gt;page.tsx &lt;/b&gt;-&lt;b&gt; zone-wrapper.tsx &lt;/b&gt;- &lt;b&gt;user-card-wrapper.tsx &lt;/b&gt;-&lt;b&gt; user-card&lt;/b&gt;&lt;/i&gt; 의 구조로 리팩토링하고, user-card-wrapper 에서 한 번만 연결 시도 및 구독하도록 하여 해결했다.&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/232</guid>
      <comments>https://dazz6study.tistory.com/232#entry232comment</comments>
      <pubDate>Sat, 2 Aug 2025 18:00:20 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #2-1. 홈 화면</title>
      <link>https://dazz6study.tistory.com/231</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBQrbB/btsPC7oRGON/zOMBvFZdffoJLSALslbET0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBQrbB/btsPC7oRGON/zOMBvFZdffoJLSALslbET0/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%; margin-right: 10px;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBQrbB/btsPC7oRGON/zOMBvFZdffoJLSALslbET0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBQrbB%2FbtsPC7oRGON%2FzOMBvFZdffoJLSALslbET0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdtrxP/btsPC55AnVj/rThQODRKbG2KgPCtNvCZG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdtrxP/btsPC55AnVj/rThQODRKbG2KgPCtNvCZG0/img.png&quot; data-origin-width=&quot;3024&quot; data-origin-height=&quot;1964&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4186%;&quot; data-widthpercent=&quot;50&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdtrxP/btsPC55AnVj/rThQODRKbG2KgPCtNvCZG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdtrxP%2FbtsPC55AnVj%2FrThQODRKbG2KgPCtNvCZG0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;3024&quot; height=&quot;1964&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;첫 진입시 출력되는 홈 화면으로, 모바일 대응 반응형도 구현했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&quot;모여서 각자 공부하기&quot; 라는 콘셉트를 보여 줄 수 있는 이미지를 위해 다양한 공부 및 작업하는 캐릭터를 삽입했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;&lt;b&gt;캐릭터 디자인은 디자이너 지인이 도와주었습니다 &lt;a href=&quot;https://www.youtube.com/@티거-r9c&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.youtube.com/@티거-r9c&lt;/a&gt;&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Next.js 의 장점, 서버 사이드 렌더링을 최대한 살리기 위해 메인은 당연히 server component 여야 한다고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그렇지만 모각존과 모각챌은 참여자가 많은 순으로 소개하는 기능이므로&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;실시간까지는 아니더라도 길지 않은 텀으로 업데이트되어야 할 것 같아 revalidate 를 10분으로 설정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1753934449606&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export async function MainZoneChallenge() {
  const res = await fetch(`${process.env.BACKEND_API_URL}`, {
    next: { revalidate: 60 * 10 },
  });

  if (!res.ok) {
    const err = await res.text();
    console.error(&quot;서버 응답:&quot;, err);
    throw new Error(`메인 불러오기 실패: ${res.status}`);
  }

  const data: MainResponse = await res.json();

  return data;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;또한 이전 프로젝트에서 느꼈던 큰 문제점 중 하나인, &quot;react 의 가장 큰 장점인 component 화를 살리지 못하고 있어 가독성 및 효율이 떨어짐&quot; 을 개선하기 위해 메인에서 사용하는 ImageCard section 도 component 로 분리하여 props 로 값을 넘기는 방식을 사용했다.&lt;/p&gt;
&lt;pre id=&quot;code_1753934583556&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; &amp;lt;ImageCard
          type=&quot;home&quot;
          title=&quot;모여서 각자, 모각&quot;
          description1=&quot;모각에 처음 오셨나요?&quot;
          description2=&quot;간단한 챌린지로 시작해 보세요!&quot;
          button=&quot;챌린지 시작하기&quot;
          buttonUrl=&quot;/challenge&quot;
          img1={student.src}
          img2={coding.src}
          img3={designer.src}
          bgImageLight={MainBgLight.src}
          bgImageDark={MainBgDark.src}
        /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1753934602453&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&quot;use client&quot;;

import Link from &quot;next/link&quot;;
import Button from &quot;../ui/button&quot;;
import { ImageCardProps } from &quot;@/types/shared.type&quot;;

export default function ImageCard({
  title,
  description1,
  description2,
  button,
  buttonUrl,
  img1,
  img2,
  img3,
  bgImageLight,
  bgImageDark,
}: ImageCardProps) {
  return (
    &amp;lt;div className=&quot;flex justify-center w-full px-4&quot;&amp;gt;
      &amp;lt;div className=&quot;relative w-full max-w-7xl flex flex-col lg:flex-row items-center gap-6&quot;&amp;gt;
        &amp;lt;div
          className=&quot;relative w-full lg:w-4/5 aspect-[5/4] sm:aspect-[5/3] md:aspect-[5/2] bg-cover bg-left rounded-lg overflow-hidden&quot;
          style={{ backgroundImage: `url(${bgImageLight})` }}
        &amp;gt;
          &amp;lt;div
            className=&quot;hidden dark:block absolute inset-0 z-0&quot;
            style={{
              backgroundImage: `url(${bgImageDark})`,
              backgroundSize: &quot;cover&quot;,
              backgroundPosition: &quot;left&quot;,
            }}
          /&amp;gt;

          {img1 &amp;amp;&amp;amp; (
            &amp;lt;img
              src={img1}
              alt=&quot;img1&quot;
              className=&quot;absolute top-10 right-6 w-20 sm:w-40 lg:w-52 z-10&quot;
            /&amp;gt;
          )}
          {img2 &amp;amp;&amp;amp; (
            &amp;lt;img
              src={img2}
              alt=&quot;img2&quot;
              className=&quot;absolute top-10 left-6 w-20 sm:w-40 lg:w-52 z-10&quot;
            /&amp;gt;
          )}
          {img3 &amp;amp;&amp;amp; (
            &amp;lt;img
              src={img3}
              alt=&quot;img3&quot;
              className=&quot;absolute bottom-6 left-1/2 transform -translate-x-1/2 w-20 sm:w-40 lg:w-56 z-10&quot;
            /&amp;gt;
          )}

          &amp;lt;div className=&quot;absolute inset-0 flex items-center justify-center lg:hidden z-10&quot;&amp;gt;
            &amp;lt;div className=&quot;backdrop-blur-md border border-borders text-gray-900 text-center p-6 rounded-lg max-w-md&quot;&amp;gt;
              &amp;lt;h2 className=&quot;text-2xl sm:text-3xl text-primary dark:text-primary-dark font-bold&quot;&amp;gt;
                {title}
              &amp;lt;/h2&amp;gt;
              &amp;lt;div className=&quot;text-base text-text dark:text-borders sm:text-lg mt-2&quot;&amp;gt;
                &amp;lt;p&amp;gt;{description1}&amp;lt;/p&amp;gt;
                {description2 &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{description2}&amp;lt;/p&amp;gt;}
              &amp;lt;/div&amp;gt;
              &amp;lt;Link href={buttonUrl}&amp;gt;
                &amp;lt;Button variant=&quot;secondary&quot; className=&quot;mt-4&quot;&amp;gt;
                  {button}
                &amp;lt;/Button&amp;gt;
              &amp;lt;/Link&amp;gt;
            &amp;lt;/div&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;div className=&quot;hidden lg:flex flex-col justify-center gap-4 lg:w-1/5&quot;&amp;gt;
          &amp;lt;h2 className=&quot;text-3xl font-bold text-primary&quot;&amp;gt;{title}&amp;lt;/h2&amp;gt;
          &amp;lt;div className=&quot;text-base text-gray-700 dark:text-gray-300&quot;&amp;gt;
            &amp;lt;p&amp;gt;{description1}&amp;lt;/p&amp;gt;
            {description2 &amp;amp;&amp;amp; &amp;lt;p&amp;gt;{description2}&amp;lt;/p&amp;gt;}
          &amp;lt;/div&amp;gt;
          &amp;lt;Link href={buttonUrl}&amp;gt;
            &amp;lt;Button variant=&quot;secondary&quot;&amp;gt;{button}&amp;lt;/Button&amp;gt;
          &amp;lt;/Link&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/231</guid>
      <comments>https://dazz6study.tistory.com/231#entry231comment</comments>
      <pubDate>Fri, 1 Aug 2025 18:00:36 +0900</pubDate>
    </item>
    <item>
      <title>모각 프로젝트 - #1. 소개</title>
      <link>https://dazz6study.tistory.com/230</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;프로젝트 주제&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&quot;모여서 각자 공부하기&quot; 라는 주제로, 함께 공부하거나 챌린지를 통해 서로 교류할 수 있는 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;비밀 게시판에 작성한 고민은 설정한 시간이 지나면 자동으로 삭제되어, 부담없이 고민을 나눌 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;프로젝트 기간&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;2025년 4월 6일 ~ 7월 28일&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;프로젝트 인원 및 역할&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;2인 (백엔드 1명, &lt;b&gt;프론트엔드 1명&lt;/b&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;프론트엔드 사용 기술&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;React, TypeScript, Next.js, Vercel, Zustand, Tailwind CSS, Figma&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;와이어프레임&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brThNY/btsPCLfgCIK/eSd4D30YBgrz1AqwskHkO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brThNY/btsPCLfgCIK/eSd4D30YBgrz1AqwskHkO0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brThNY/btsPCLfgCIK/eSd4D30YBgrz1AqwskHkO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrThNY%2FbtsPCLfgCIK%2FeSd4D30YBgrz1AqwskHkO0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1472&quot; height=&quot;1412&quot; data-origin-width=&quot;1472&quot; data-origin-height=&quot;1412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;2025년 7월 31일 기준, 아래 링크로 접속 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;http://www.mogak.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;www.mogak.kr&lt;/a&gt;&lt;/p&gt;</description>
      <category>Dazz6/ABOUT</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/230</guid>
      <comments>https://dazz6study.tistory.com/230#entry230comment</comments>
      <pubDate>Thu, 31 Jul 2025 18:00:38 +0900</pubDate>
    </item>
    <item>
      <title>Next.js에서 Recoil 사용 에러</title>
      <link>https://dazz6study.tistory.com/229</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;&lt;b&gt;세 줄 요약!&lt;/b&gt;&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;1. 클라이언트 컴포넌트인데 인식을 못 하는 것 같은 오류가 뜸&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;2. Recoil 과 Next.js 의 충돌&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;i&gt;3. Recoil 대신 Zustand 사용&lt;/i&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;상황&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;평소에 사용했던 것처럼 recoil 라이브러리를 Install 하고 useRecoilState 사용했는데,&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;서버 컴포넌트에서 hook을 사용했을 때 뜨는 오류가 출력됨&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;환경: Next.js 15 + React 19&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;시도&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;use client 지시자를 빼먹었는가?&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;-&amp;gt; 첫 줄에 잘 넣어 둠&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;Root Layout에 Provider를 추가하지 않았는가?&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;-&amp;gt; 추가함&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;Recoil 사용을 중단하고 Zustand를 사용함&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;이유&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;일단 Next.js로 프로젝트를 진행하는 것 자체가 처음이었으므로 아무 생각 없이 install 하고 적용했는데, Next.js의 가장 큰 특징인 서버 컴포넌트에서는 당연히 Recoil context 사용 불가능함. 그래서 SSR, CSR 모두 대응 가능한 Zustand로 변경함.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;검색해 봐도 Next.js에서 Recoil이 아예 사용 불가능한 것은 아니라는데, wrapper 로 한 번 더 감싸 주거나 use client 지시자를 사용해서 클라이언트 컴포넌트로 지정하고 로직을 아무리 되짚어도 해결되지 않아서 Zustand 로 사용! (컴포넌트 구성이나 렌더링 순서 문제로 인해 오류가 발생할 수 있다고 한다 ㅠㅠ)&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;사실 오류를 해결한 게 아니라 recoil을 포기한 게 맞긴 하지만, Next.js 환경에서는 recoil 보다 zustand가 더 안정적이라는 걸 깨달아서 기록해 둔다.&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;작업할 때 전에 했었으니까~ 라는 이유로 스택 / 기술을 적용하지 말고 다른 스택이나 구현 기술을 고려하는 버릇이 필요!&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Dazz6/SHOOTING</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/229</guid>
      <comments>https://dazz6study.tistory.com/229#entry229comment</comments>
      <pubDate>Tue, 13 May 2025 18:00:13 +0900</pubDate>
    </item>
    <item>
      <title>React 09 - 커스텀 훅과 HOC (고차 컴포넌트)</title>
      <link>https://dazz6study.tistory.com/228</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. 커스텀 훅 (Custom Hook)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;React의 Hook을 조합해서 만든 함수&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;useState, useEffect 같은 기존 hook을 활용해 공통 로직을 재사용할 수 있게 도와준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;단순한 함수처럼 사용되어 JSX를 리턴하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. HOC (Higher-Order Component, 고차 컴포넌트)&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;컴포넌트를 인자로 받아서 새로운 컴포넌트를 리턴하는 함수&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컴포넌트를 감싸서 기능을 추가하는 데 쓰인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;클래스형 컴포넌트에서 주로 사용되었으며, 최근에는 커스텀 훅이 더 많이 사용되는 추세다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그럼 상황에 따라서 어떤 걸 선택해야 할까?&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;API 데이터 패칭&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;매번 useEffect, fetch 코드를 복사/붙여넣기 하는 건 비효율적이므로 커스텀 훅으로 로직을 분리하는 것이 유리하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;로딩과 같은 공통 UI&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컴포넌트를 렌더링하기 전에 공통 UI 처리가 필요하므로 HOC를 사용하는 것이 유리하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;잘못 이해하고 있었던 점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;기존에는 react-query나 react-toast 같은 외부 라이브러리에서 제공하는 훅만 커스텀 훅인 줄 알았는데 아니었다...&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;내가 &lt;b&gt;로직을 분리하기 위해 작성한 함수도 커스텀 훅&lt;/b&gt;이라는 것... ^_ㅠ&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이런 기술, 라이브러리에 대해서는 대충 이해하고 넘어가는 게 아니라 확실하게 짚고 넘어가야 된다는 걸 느꼈다&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;실무뿐만 아니라 이론적인 부분도 공부하기... 아자자&lt;/p&gt;</description>
      <category>FRONT/React, Vue</category>
      <author>dazz6</author>
      <guid isPermaLink="true">https://dazz6study.tistory.com/228</guid>
      <comments>https://dazz6study.tistory.com/228#entry228comment</comments>
      <pubDate>Wed, 7 May 2025 18:00:20 +0900</pubDate>
    </item>
  </channel>
</rss>