<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>dayannne.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Mon, 08 Sep 2025 01:42:31 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>dayannne.log</title>
            <url>https://velog.velcdn.com/images/day_1226/profile/f92d441c-23ad-4f27-b4f2-429f5a7fd61f/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. dayannne.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/day_1226" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Next.js | next-translate 기반 다국어(i18n) 설정 탐구하기]]></title>
            <link>https://velog.io/@day_1226/next-translate-%EA%B8%B0%EB%B0%98-%EB%8B%A4%EA%B5%AD%EC%96%B4i18n-%EC%84%A4%EC%A0%95-%ED%83%90%EA%B5%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/next-translate-%EA%B8%B0%EB%B0%98-%EB%8B%A4%EA%B5%AD%EC%96%B4i18n-%EC%84%A4%EC%A0%95-%ED%83%90%EA%B5%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 08 Sep 2025 01:42:31 GMT</pubDate>
            <description><![CDATA[<h2 id="🌍-next-translate-기반-다국어-설정-탐구하기">🌍 next-translate 기반 다국어 설정 탐구하기</h2>
<p>인턴 중인 회사에서 글로벌 플랫폼을 운영함에 따라 다국어 설정 라이브러리로 <code>next-translate</code>를 사용하고 있었다.</p>
<p><code>next-translate</code>를 기반으로 i18n 세팅을 프로젝트에 어떻게 구성하는지, 특히 <strong>i18n.ts 설정 / I18nProvider 적용 / useTranslation 네임스페이스 사용</strong>에 대해 탐구한 것을 바탕으로 정리해보려 한다.</p>
<hr>
<h3 id="nextjs-다국어i18n-라이브러리">Next.js 다국어(i18n) 라이브러리</h3>
<p>많이 사용하는 라이브러리로는 <code>react-i18next</code> <code>next-i18next</code> <code>next-translation</code> 가 있는 것 같다.</p>
<h4 id="1️⃣-react-i18next">1️⃣ react-i18next</h4>
<ul>
<li>기반: i18next의 React 버전</li>
<li>사용 대상: 일반 React 프로젝트 or CSR 중심 프로젝트</li>
<li>특징:
가장 널리 쓰이는 i18n 라이브러리 중 하나
훅(useTranslation) 기반으로 컴포넌트에 쉽게 다국어 적용 가능
Lazy loading, namespace, fallback language 등 다양한 기능 지원
서버사이드 렌더링(SSR) 지원은 있지만 Next.js와 완벽한 통합은 아님</li>
</ul>
<h4 id="2️⃣-next-i18next">2️⃣ next-i18next</h4>
<ul>
<li>기반: react-i18next + Next.js</li>
<li>사용 대상: Next.js 프로젝트에서 SSR과 SSG를 모두 지원하고 싶을 때</li>
<li>특징:
Next.js에 최적화된 i18n 솔루션
서버 사이드에서 번역 리소스를 로딩하며 SEO에 유리
파일 기반 번역(json), 언어 별 namespace 구조 제공
app/ 디렉토리 (Next.js 13+) 지원도 진행 중이지만 제한적일 수 있음</li>
</ul>
<h4 id="3️⃣-next-translation">3️⃣ next-translation</h4>
<ul>
<li>기반: Next.js 전용 경량화된 i18n 솔루션</li>
<li>사용 대상: 최신 Next.js (app/ 디렉토리 기반)와 함께 사용하는 경우</li>
<li>특징:
작은 번들 사이즈, 빠른 빌드 속도
직관적인 API와 폴더 구조
클라이언트와 서버 모두에서 동작
app/ 디렉토리 기반의 App Router 완전 지원
번역 파일은 페이지 또는 컴포넌트 단위로 나눠 관리 가능</li>
</ul>
<hr>
<h3 id="🔧-구조-개요">🔧 구조 개요</h3>
<pre><code class="language-tsx">/locales
    /ko
        common.json
        signup.json
    /en
        common.json
        signup.json
/i18n
    i18n.ts
    i18n.js
/pages
    _app.tsx
/components
    SignUpForm.tsx</code></pre>
<hr>
<h3 id="🗂-번역-파일-예시">🗂 번역 파일 예시</h3>
<pre><code class="language-tsx">// locales/ko/signup.json
{
  &quot;title&quot;: &quot;회원가입&quot;,
  &quot;step1&quot;: {
    &quot;heading&quot;: &quot;1단계&quot;
  }
}</code></pre>
<pre><code class="language-tsx">// locales/en/signup.json
{
  &quot;title&quot;: &quot;Sign Up&quot;,
  &quot;step1&quot;: {
    &quot;heading&quot;: &quot;Step 1&quot;
  }
}</code></pre>
<hr>
<h3 id="📁-i18nts--i18njs-설정">📁 i18n.ts / i18n.js 설정</h3>
<p>기본 세팅은 <code>i18n.js</code> 또는 <code>i18n.ts</code>에 정의한다.</p>
<p>이 파일은 <code>next.config.js</code>와 연결되며, <code>next-translate</code>가 참조하게 된다.</p>
<pre><code class="language-tsx">// i18n.ts (or i18n.js)
module.exports = {
  locales: [&#39;ko&#39;, &#39;en&#39;],
  defaultLocale: &#39;ko&#39;,
  pages: {
    &#39;*&#39;: [&#39;common&#39;],
    &#39;/signup&#39;: [&#39;signup&#39;]
  }
}</code></pre>
<ul>
<li>locales: 지원 언어 목록</li>
<li>defaultLocale: 초기 언어</li>
<li>pages: 경로별로 사용할 네임스페이스(json 파일 이름)를 정의</li>
</ul>
<p><code>pages</code> 안에 어떤 페이지에서 어떤 json 파일을 불러올지 배열 형식으로 작성해 준다.
예를 들어 <code>/signup</code> 페이지에선 <code>signup.json</code>과 <code>common.json</code> 두 네임스페이스가 사용 된다.</p>
<p>위와 같이 세팅 후, 번역을 사용할 코드에서는 <code>useTranslation</code> 키워드를 사용하면 된다.</p>
<p>만약 여기서 지정해주지 않은 채로 뷰단에서 <code>useTranslation(&#39;signup&#39;)</code>을 사용할 시 적용되지 않기 때문에 주의해야 한다..!</p>
<hr>
<h3 id="🔁-usetranslation">🔁 useTranslation</h3>
<p><code>next-translate</code>는 훅 기반의 API를 제공하고 있다.
컴포넌트에서 다음처럼 사용할 수 있다.</p>
<pre><code class="language-tsx">import useTranslation from &#39;next-translate/useTranslation&#39;;

const SignUpForm = () =&gt; {
  const { t } = useTranslation(&#39;signup&#39;);

  return &lt;h1&gt;{t(&#39;title&#39;)}&lt;/h1&gt;;
};</code></pre>
<ul>
<li><code>useTranslation(&#39;signup&#39;)</code>: signup 네임스페이스 로드</li>
<li><code>t(&#39;title&#39;)</code> : signup.json 내 <code>&quot;title&quot;</code> key 참조</li>
</ul>
<p>📌 t의 타입이 명확하지 않을 때 아래와 같이 타입 선언도 가능하다 :</p>
<pre><code class="language-tsx">
const { t }: { t: (key: string) =&gt; string } = useTranslation(&#39;signup&#39;);</code></pre>
<hr>
<h3 id="🧪-i18nprovider">🧪 I18nProvider</h3>
<p>일반적으로 <code>next-translate</code>는 자체적으로 컨텍스트를 주입하므로, 대부분의 경우 <code>I18nProvider</code>를 따로 쓸 필요는 없다.</p>
<p>그러나 테스트 환경이나, Next.js의 렌더링 흐름 외부에서 번역을 사용할 때는 <code>I18nProvider</code>로 명시적으로 <code>context</code>를 감쌀 수 있다.</p>
<p>예시:</p>
<pre><code class="language-tsx">import { I18nProvider } from &#39;next-translate&#39;;
import i18nConfig from &#39;../i18n/i18n&#39;;

&lt;I18nProvider lang=&quot;ko&quot; namespaces={{ signup: { title: &#39;회원가입&#39; } }} config={i18nConfig}&gt;
  &lt;SignUpForm /&gt;
&lt;/I18nProvider&gt;</code></pre>
<ul>
<li><code>lang</code>: 언어 지정</li>
<li><code>namespaces</code>: 직접 번역 값 주입</li>
<li><code>config</code>: i18n.ts 설정 전달</li>
</ul>
<p>📌 이 방식은 주로 Storybook, Unit Test, Preview 환경 등에서 사용 되는 듯하다.</p>
<hr>
<h3 id="⚠️-주의할-점">⚠️ 주의할 점</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>네임스페이스 누락</td>
<td><code>useTranslation(&quot;ns&quot;)</code> 호출 시 해당 파일이 로드되지 않음</td>
</tr>
<tr>
<td>JSON key 오타</td>
<td><code>t()</code>가 fallback으로 key 자체를 출력함</td>
</tr>
<tr>
<td><code>pages</code> 설정 누락</td>
<td>자동 로딩되지 않아 runtime 오류 발생 가능</td>
</tr>
</tbody></table>
<hr>
<h3 id="✅-요약">✅ 요약</h3>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>사용 라이브러리</td>
<td><code>next-translate</code></td>
</tr>
<tr>
<td>번역 파일 위치</td>
<td><code>/locales/{lang}/{namespace}.json</code></td>
</tr>
<tr>
<td>주요 훅</td>
<td><code>useTranslation(&#39;namespace&#39;)</code></td>
</tr>
<tr>
<td>설정 파일</td>
<td><code>i18n.ts</code> or <code>i18n.js</code></td>
</tr>
<tr>
<td>Provider 사용</td>
<td>대부분 불필요 (예외: 테스트/프리뷰 환경)</td>
</tr>
</tbody></table>
<hr>
<h2 id="✍️-마치며">✍️ 마치며</h2>
<p>그동안 글로벌 플랫폼은 어떻게 운영되는지에 대한 궁금증이 있었는데, 실제로 현업에서 <code>next-translate</code>를 통해 다국어 설정이 어떻게 되어있는지 살펴보고, 또 관련한 버그를 수정해 보면서 배울 수 있었다.</p>
<p>단순히 텍스트를 번역하는 것을 넘어, 언어별 파일 구조 관리, 페이지 기반 네임스페이스 로딩, 그리고 SSR을 고려한 번역 처리 흐름까지 생각해야 한다는 점이 인상 깊고 알면 알수록 디테일한 고려사항들이 숨어 있는 신비한(?) 프론트엔드 세계...</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js | Tanstack Query로 prefetch 적용하기]]></title>
            <link>https://velog.io/@day_1226/Next.js-tanstack-query%EB%A1%9C-prefetch-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-tanstack-query%EB%A1%9C-prefetch-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 13 Oct 2024 09:39:34 GMT</pubDate>
            <description><![CDATA[<p>Next.js + Prisma로 DB를 조회하면서 API를 구현해 작업하고 있는 프로젝트에서,</p>
<p>개발 후 셀프 QA를 하면서 초기 페이지 렌더링 + 데이터 fetching 시간까지 더해 로딩이 무지하게 느려지는 부분이 아쉽게 느껴졌다. </p>
<p>방법을 고민하다 Tanstack Query(React Query)에서 제공하는 <strong><a href="https://tanstack.com/query/v5/docs/framework/react/guides/ssr#using-the-hydration-apis">Hydration API</a></strong> 를 발견! 이 기능을 통해 Next.js의 서버 컴포넌트에 data prefetching을 사용할 수 있다고 하여 도입해 보기로 했다.</p>
<p>원리는 다음과 같다.</p>
<blockquote>
<ol>
<li>서버 컴포넌트에서 <code>queryClient.prefetchQuery</code>를 사용해 데이터를 불러오고 이를 <code>dehydrate</code>하여 하위 컴포넌트를 <code>HydrationBoundary</code>로 감싸 <code>state</code>를 넘겨 준다.</li>
<li>데이터를 사용하는 컴포넌트에서 <code>useQuery</code>로 동일한 데이터를 불러오면 해당 데이터는 <code>prefetch</code> 된 상태로 넘어와 이를 사용한다.</li>
</ol>
</blockquote>
<h2 id="초기-세팅">초기 세팅</h2>
<ul>
<li><p><code>useReactQuery.tsx</code></p>
<pre><code class="language-tsx">&#39;use client&#39;;

import { ReactQueryDevtools } from &#39;@tanstack/react-query-devtools&#39;;
import { QueryClient, QueryClientProvider } from &#39;@tanstack/react-query&#39;;

export default function ReactQueryProviders({
  children,
}: React.PropsWithChildren) {
  function makeQueryClient() {
    return new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60 * 1000,
        },
      },
    });
  }

  let browserQueryClient: QueryClient | undefined = undefined;

  function getQueryClient() {
    if (typeof window === &#39;undefined&#39;) {
      // Server일 경우
      return makeQueryClient();
    } else {
      // Browser일 경우
      if (!browserQueryClient) browserQueryClient = makeQueryClient();
      return browserQueryClient;
    }
  }

  const queryClient = getQueryClient();

  return (
    &lt;QueryClientProvider client={queryClient}&gt;
  {children}
{/* &lt;ReactQueryDevtools initialIsOpen={false} /&gt; */}
&lt;/QueryClientProvider&gt;
);
}
</code></pre>
<pre><code class="language-tsx">function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        staleTime: 60 * 1000,
      },
    },
  });
}</code></pre>
<p>SSR에서 클라이언트에서 즉시 refetch하는 것을 피하기 위해 <code>staleTime</code>을 0보다 크게 설정하는 것이 좋다.
실제로 staleTime을 설정하지 않았다가 query key 변경 사항이 없음에도 무한 <code>refetch</code>를 경험한 적이 있다..b</p>
<pre><code class="language-tsx">function getQueryClient() {
  if (typeof window === &#39;undefined&#39;) {
    // Server
    return makeQueryClient();
  } else {
    // Browser
    if (!browserQueryClient) browserQueryClient = makeQueryClient();
    return browserQueryClient;
  }
}</code></pre>
<ul>
<li><code>typeof window === &#39;undefined&#39;</code> (서버인 경우) : 매번 새로운 queryClient를 만든다.</li>
<li>(브라우저인 경우) : queryClient가 존재하지 않을 경우에만 새로운 <code>queryClient</code>를 만든다.<blockquote>
<p>기존에 React Query를 설정할 때는 클라이언트에서 QueryClient를 생성하여 지속적으로 사용하는 방식이다. 그러나 prefetching 기능을 사용할 때는 서버에서도 QueryClient를 생성해야 하므로, 서버에서 이미 생성된 경우에는 다시 생성하지 않도록 로더 함수에 조건문을 추가해 주는 방식이다.</p>
</blockquote>
</li>
</ul>
</li>
<li><p><code>app/layout.tsx</code></p>
<pre><code class="language-tsx">import type { Metadata } from &#39;next&#39;;
import Script from &#39;next/script&#39;;

import { Noto_Sans_KR } from &#39;next/font/google&#39;;

import &#39;../styles/globals.css&#39;;

import ReactQueryProviders from &#39;./_hooks/useReactQuery&#39;;

export const metadata: Metadata = {
  title: &#39;나의 산책 일기&#39;,
  description: &#39;나의 산책 일기 - my walk log&#39;,
};

const font = Noto_Sans_KR({
  subsets: [&#39;latin&#39;],
});
export default async function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&#39;en&#39;&gt;
      &lt;body className={`${font.className} text-sm lg:text-base`}&gt;
        &lt;Script
          async
          type=&#39;text/javascript&#39;
          src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.KAKAO_JS_KEY}&amp;libraries=services,clusterer&amp;autoload=false`}
        &gt;&lt;/Script&gt;
        &lt;ReactQueryProviders&gt;
          {children}
        &lt;/ReactQueryProviders&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<p><code>ReactQueryProviders</code>를 <code>chiildren</code>에 싹 감싸준다~</p>
</li>
</ul>
<h2 id="prefetchquery">prefetchQuery</h2>
<hr>
<p>기본 세팅 방법은 아래와 같다.</p>
<h3 id="1-먼저-서버-컴포넌트를-준비">1. 먼저 서버 컴포넌트를 준비!</h3>
<p>내 프로젝트에서는 레이아웃 컴포넌트를 모두 서버 컴포넌트로 분리해 두고 있기 때문에 
페이지 컴포넌트에 GET 요청이 필요한 컴포넌트가 있는 경우 <code>prefetch</code> &amp; <code>dehydrate</code> 를 적용해 주었다.</p>
<blockquote>
<p><code>prefetch</code> &amp; <code>dehydrate</code>는 useQuery를 사용하는 컴포넌트 바로 상위에서 적용해 주는 것이 좋다.
한 번 상위에서 적용하면 모든 하위 컴포넌트에 전역적으로 적용이 가능하지만, 모든 컴포넌트에서 해당 쿼리 데이터를 미리 불러오게 되기 때문에 불필요한 데이터 로딩이 이뤄질 수 있기 때문이다.</p>
</blockquote>
<pre><code class="language-tsx">import Header from &#39;@/app/_component/common/Header&#39;;
import getQueryClient from &#39;@/app/shared/utils/getQueryCLient&#39;;
import { getDiaryDetail } from &#39;@/app/store/server/diary&#39;;
import { dehydrate, HydrationBoundary } from &#39;@tanstack/react-query&#39;;

export interface DiaryLayoutProps {
  children: React.ReactNode;

  params: { diaryId: number };
}

const DiaryLayout = async ({ children, params }: DiaryLayoutProps) =&gt; {
  const queryClient = getQueryClient();

  await queryClient.prefetchQuery({
    queryKey: [&#39;diaryDetail&#39;, Number(params?.diaryId)],
    queryFn: () =&gt; getDiaryDetail(Number(params?.diaryId)),
  });

  return (
    &lt;div className=&#39;flex basis-full flex-col overflow-y-auto&#39;&gt;
      &lt;Header title=&#39;일기 상세&#39; enableBackButton /&gt;
      &lt;HydrationBoundary state={dehydrate(queryClient)}&gt;
        {children}
      &lt;/HydrationBoundary&gt;
    &lt;/div&gt;
  );
};

export default DiaryLayout;
</code></pre>
<pre><code class="language-tsx">export const getDiaryDetail = async (diaryId: number) =&gt; {
  const response = await axios.get(
    `${process.env.NEXT_PUBLIC_DOMAIN}/api/diary/${diaryId}`,
  );

  return response.data.data;
};</code></pre>
<blockquote>
<p>여기서 주의할 점 ⚠️
보통 axios를 사용한다면 </p>
</blockquote>
<pre><code class="language-tsx">const response = await axios.get(`/api/diary/${diaryId}`,);</code></pre>
<p>이렇게 위와 같이 도메인을 빼고 사용하는데, <strong>서버에서 API를 요청할 때에는 도메인을 꼭 붙여 사용해야 한다.</strong> 
이는 서버와 클라이언트 환경에서의 실행 방식 차이로, API 요청을 서버에서도 처리하기 위해서는 localhost나 로컬 환경의 도메인과 무관하게 외부 도메인을 포함한 URL이 필요 절대 경로가 필요하기 때문이다!
(이 부분을 모르고 세팅했다가 전혀 적용이 되지 않아 바보 고생을 했다는 이야기✌️)</p>
<h3 id="2-하위-클라이언트-컴포넌트">2. 하위 클라이언트 컴포넌트</h3>
<pre><code class="language-tsx">export const useGetDiaryDetail = (diaryId: number) =&gt;
  queryOptions({
    queryKey: [&#39;diaryDetail&#39;, diaryId],
    queryFn: () =&gt; getDiaryDetail(diaryId),
    enabled: !!diaryId,
  });</code></pre>
<pre><code class="language-tsx">&#39;use client&#39;;

import PlaceDetailModal from &#39;@/app/_component/common/Modal/PlaceDetailModal&#39;;
import Commentform from &#39;@/app/_component/diary/Commentform&#39;;
import CommentList from &#39;@/app/_component/diary/CommentList&#39;;
import DiaryItem from &#39;@/app/_component/diary/DiaryItem&#39;;
import { useModalStore } from &#39;@/app/store/client/modal&#39;;
import { useUserStore } from &#39;@/app/store/client/user&#39;;
import {
  useDiaryLike,
  useDeleteDiary,
  useGetDiaryDetail,
} from &#39;@/app/store/server/diary&#39;;
import { useSuspenseQuery } from &#39;@tanstack/react-query&#39;;
import { useEffect, useState } from &#39;react&#39;;

export interface DiaryPageProps {}

const DiaryPage = ({ params }: { params: { diaryId: number } }) =&gt; {
  const { diaryId } = params;

  const queryOptions = useGetDiaryDetail(params.diaryId);
  const { data: diary, isLoading, error } = useSuspenseQuery(queryOptions);

  // ...나머지 코드

  return (
    &lt;&gt;
      &lt;div className=&#39;flex basis-full flex-col overflow-y-scroll bg-white&#39;&gt;
        &lt;DiaryItem
          diary={diary}
          onConfirm={handleConfirm}
          onClick={() =&gt; handleClick(diary?.id)}
        /&gt;
        {/* ...나머지 코드*/}
      &lt;/div&gt;
      {/* ...나머지 코드*/}
    &lt;/&gt;
  );
};

export default DiaryPage;
</code></pre>
<p>그리고 하위 컴포넌트인 Page 컴포넌트에서 <code>useSuspenseQuery</code> / <code>useQuery</code>를  적용해 주면 끝!
이렇게 사용하면 Next.js와 React Query를 함께 사용하여 SSR페이지를 만들 수 있다.</p>
<h2 id="prefetchinfinitequery">prefetchInfiniteQuery</h2>
<hr>
<p>이번엔 하위 컴포넌트에서 <code>useInfiniteQuery</code>를 사용하는 경우이다. 이때는 <code>prefetchInfiniteQuery</code>를 사용하면 된다.</p>
<h3 id="1-서버-컴포넌트">1. 서버 컴포넌트</h3>
<pre><code class="language-tsx">export const getFeed = async (pageParam = 1) =&gt; {
  const response = await axios.get(
    `${process.env.NEXT_PUBLIC_DOMAIN}/api/feed`,
    {
      params: { page: pageParam, size: 10 },
    },
  );
  return response.data;
};</code></pre>
<pre><code class="language-tsx">import Header from &#39;@/app/_component/common/Header&#39;;
import { IDiary } from &#39;@/app/shared/types/diary&#39;;
import getQueryClient from &#39;@/app/shared/utils/getQueryCLient&#39;;
import { getFeed } from &#39;@/app/store/server/feed&#39;;
import { HydrationBoundary, dehydrate } from &#39;@tanstack/react-query&#39;;

export interface FeedLayoutProps {
  children: React.ReactNode;
}

interface FeedData {
  pages: IDiary[];
  pageParams: number[];
}

const FeedLayout = async ({ children }: FeedLayoutProps) =&gt; {
  const queryClient = getQueryClient();
  await queryClient.prefetchInfiniteQuery({
    queryKey: [&#39;feed&#39;],
    queryFn: () =&gt; getFeed(1),
    getNextPageParam: (lastPage: any) =&gt; {
      const { page, totalPages } = lastPage;
      return page &lt; totalPages ? page + 1 : undefined;
    },
    initialPageParam: 1,
    retry: 1,
    staleTime: 60 * 1000,
  });

  queryClient.setQueryData([&#39;feed&#39;], {
    pages: (queryClient.getQueryData([&#39;feed&#39;]) as FeedData)?.pages || [],
    pageParams: [0],
  });

  return (
    &lt;div
      className={`sm-md:overflow-y-hidden relative z-20 flex w-full shrink-0 basis-full flex-col bg-white lg:flex lg:w-96 lg:min-w-96 lg:basis-auto`}
    &gt;
      &lt;div className=&#39;flex h-full w-full basis-full flex-col&#39;&gt;
        &lt;Header title=&#39;피드&#39; /&gt;
        &lt;div className=&#39;flex basis-full flex-col overflow-y-scroll&#39;&gt;
          &lt;HydrationBoundary
            state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
          &gt;
            {children}
          &lt;/HydrationBoundary&gt;
        &lt;/div&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
};

export default FeedLayout;</code></pre>
<p><code>prefetchInfiniteQuery</code>에서는 몇가지 에러를 방지하기 위한 추가 설정이 필요하다.</p>
<ul>
<li><p>pageParam이 undefined로 반환되는 문제
클라이언트에서 data fetching 시에는 잘 불러와 지는 데이터가 서버 prefetch 시 불러와 지지 않는 문제가 있었다.</p>
<pre><code class="language-tsx">  queryClient.setQueryData([&#39;feed&#39;], {
    pages: (queryClient.getQueryData([&#39;feed&#39;]) as FeedData)?.pages || [],
    pageParams: [0],
  });
</code></pre>
<p><code>queryClient.setQueryData</code>를 적용해 초기 데이터 로딩 시 항상 첫 페이지를 불러오도록 <code>pageparams</code> [0]으로 명시해 준다.</p>
</li>
<li><p>데이터 무결성 문제 방지</p>
<pre><code class="language-tsx">&lt;HydrationBoundary
  state={JSON.parse(JSON.stringify(dehydrate(queryClient)))}
  &gt;
 {children}
&lt;/HydrationBoundary&gt;</code></pre>
<p><code>JSON.stringify</code>와 <code>JSON.parse</code>를 사용하여 불필요한 속성을 제거하고 불변성을 유지하기 위함인데, pageParam undefined 문제가 발생하지 않는다면 <code>JSON.parse</code>, <code>stringify</code>는 지워줘도 된다.</p>
</li>
</ul>
<h3 id="2-하위-클라이언트-컴포넌트-1">2. 하위 클라이언트 컴포넌트</h3>
<pre><code class="language-tsx">export const useGetFeed = () =&gt;
  infiniteQueryOptions({
    queryKey: [&#39;feed&#39;],
    queryFn: () =&gt; getFeed(1),
    getNextPageParam: (lastPage: any) =&gt; {
      const { page, totalPages } = lastPage;
      return page &lt; totalPages ? page + 1 : undefined;
    },
    initialPageParam: 1,
    refetchOnWindowFocus: false,
    refetchOnMount: true,
    refetchOnReconnect: true,
    retry: 1,
  });</code></pre>
<pre><code class="language-tsx">&#39;use client&#39;;

import React, { useRef, useEffect } from &#39;react&#39;;
import { useDiaryLike } from &#39;@/app/store/server/diary&#39;;
import { WEATHERS } from &#39;@/app/shared/constant&#39;;
import { formatTimeAgo } from &#39;@/app/shared/function/format&#39;;
import Link from &#39;next/link&#39;;
import Image from &#39;next/image&#39;;
import { useUserStore } from &#39;@/app/store/client/user&#39;;
import useInfiniteScroll from &#39;@/app/_hooks/useInfiniteScroll&#39;;
import { useGetFeed } from &#39;@/app/store/server/feed&#39;;
import { useInfiniteQuery } from &#39;@tanstack/react-query&#39;;
import { IDiary } from &#39;@/app/shared/types/diary&#39;;

const FeedPage = () =&gt; {
  const { user } = useUserStore();
  const queryOptions = useGetFeed();
  const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery(queryOptions);

  // ... 나머지 코드

  return (
    &lt;&gt;
      &lt;ul className=&#39;grid grid-cols-2 gap-2 bg-white p-4&#39;&gt;
        {diaries?.map((diary: IDiary) =&gt; (
          &lt;li
            className=&#39;rounded-2xl&#39;
            key={diary.id}
            style={{
              backgroundImage: `linear-gradient(rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5)),url(&#39;${diary.diaryImages[0]}&#39;)`,
              backgroundSize: &#39;cover&#39;,
              backgroundPosition: &#39;center&#39;,
            }}
          &gt;
          {/* ...나머지 코드 */}
          &lt;/li&gt;
        ))}

      &lt;/ul&gt;
    &lt;/&gt;
  );
};

export default FeedPage;</code></pre>
<h2 id="결과">결과</h2>
<hr>
<p>아래는 fetching 후 가장 페이지 내 마지막 데이터를 로딩하기까지의 시간을 비교한 결과이다.</p>
<ul>
<li><p>prefetch 적용 전 (4~5초)</p>
<p>  <img src="https://velog.velcdn.com/images/day_1226/post/d2afce79-f17e-49d0-893d-780c8821f8ad/image.png" alt=""> 이미지 기준 5.73초</p>
</li>
<li><p>prefetch 적용 후 (1초대)</p>
<p>  <img src="https://velog.velcdn.com/images/day_1226/post/f01910c3-9709-4e68-a7fe-75791fe59de1/image.png" alt=""></p>
</li>
</ul>
<pre><code>1.21초

![](https://velog.velcdn.com/images/day_1226/post/e1ee1d88-a79e-4743-bf82-50d1f66088bb/image.png)


1.53초</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Discord + AWS Lambda | 커밋 메시지 / 시간 알림 웹 훅 만들기]]></title>
            <link>https://velog.io/@day_1226/Discord-AWS-Lambda-%EC%BB%A4%EB%B0%8B-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC-%EC%9B%B9-%ED%9B%85-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Discord-AWS-Lambda-%EC%BB%A4%EB%B0%8B-%EB%A9%94%EC%8B%9C%EC%A7%80-%EC%8B%9C%EA%B0%84-%EC%95%8C%EB%A6%BC-%EC%9B%B9-%ED%9B%85-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Fri, 04 Oct 2024 07:57:12 GMT</pubDate>
            <description><![CDATA[<p>6월부터 시작해 어느덧 3개월 넘게 부트캠프부터 함께 프론트엔드를 공부해 온 멤버들과 캠스터디를 하고 있다. 
나를 포함해 다들 조금씩 사기가 떨어지고 있는 시기...동기부여가 필요했다!
디스코드를 좀더 활용하고, 스스로도 동기부여가 되었으면 해서 커밋 메시지(+ 멤버의 아이디어✨)와 시간 알림을 웹 훅으로 구현해 디스코드에 알림으로 띄워보기로 했다.</p>
<hr>
<h2 id="🌱-커밋-메시지-띄우기">🌱 커밋 메시지 띄우기</h2>
<p>1일 1커밋 실천을 위해 커밋을 push할 때마다 커밋 메시지를 채팅 채널에 띄워보기로 했다.</p>
<h3 id="1-webhook-생성">1. Webhook 생성</h3>
<p>1) 커밋을 띄우고자 하는 채팅 채널의 <code>채널 편집</code>으로 들어간다.
<img src="https://velog.velcdn.com/images/day_1226/post/9dd745cc-be14-40de-9575-2c67cf5cfe2d/image.png" alt="">2) <code>연동</code> 메뉴를 클릭하고 <code>웹후크</code>로 들어간다.
<img src="https://velog.velcdn.com/images/day_1226/post/c0921e3b-391a-4b14-aba3-1a6479452cfe/image.png" alt="">3) 새 Webhook 생성 후 Webhook 이름을 자유롭게 정한다.
<img src="https://velog.velcdn.com/images/day_1226/post/a3a51484-dd71-478d-8027-4b9fa1b2be19/image.png" alt="">Webhook 생성 후 <code>웹후크 URL 복사</code>로 URL을 복사한 후 나의 깃허브 레포지토리로 이동!</p>
<h3 id="2-깃허브-레포지토리-내-webhook-생성">2. 깃허브 레포지토리 내 Webhook 생성</h3>
<p>1) <code>Algorithm</code>이라는 레포지토리로 들어가 <code>Settings</code>-<code>Webhooks</code>로 이동
<img src="https://velog.velcdn.com/images/day_1226/post/aba6d3b5-22f0-440f-bd67-cedafaef2567/image.png" alt="">
2) <code>Add webhook</code>클릭 후 내용을 다음과 같이 채워준다.
<img src="https://velog.velcdn.com/images/day_1226/post/3b67ac67-03e2-44be-a297-51b9c72d6dc6/image.png" alt=""></p>
<ul>
<li>Payload URL : 디스코드에서 복사한 Webhook URL + <code>/github</code>를 붙여 입력한다.
<img src="https://velog.velcdn.com/images/day_1226/post/6dd02ab3-94d8-4b22-a813-d0ce5267498b/image.png" alt=""></li>
<li>Content type - <code>application/json</code></li>
<li>SSL verification - <code>Enabla SSL  verigication</code></li>
<li><code>just the push event</code></li>
</ul>
<p>그리고 <code>Update webhook</code>으로 저장하면 세팅 완료!</p>
<h3 id="결과">결과</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ee9b64dc-490b-4f06-a78a-f0f05baaead6/image.png" alt=""></p>
<p>push할 때마다 디스코드에 커밋 알림이 잘 뜨는 것을 확인할 수 있다.
여러 커밋을 한번에 푸시하면 맨 아래 커밋 알림과 같이 한번에 알림이 표시된다. </p>
<blockquote>
<ul>
<li>한 채널에 여러 Webhook를 등록 가능</li>
</ul>
</blockquote>
<ul>
<li>하나의 Webhook URL을 여러 레포지토리에 등록 가능</li>
</ul>
<p>이기 때문에 스터디 뿐만 아니라 협업 시에도 활용하기에 좋을 것 같다.</p>
<hr>
<h2 id="⏰-시간-알림-띄우기">⏰ 시간 알림 띄우기</h2>
<p>그동안 스스로 시간을 지켜서 스터디에 참여해왔다보니 헤이해지는 때가 많아서 시간마다 알림 메시지를 띄워서 좀더 다같이 습관화해보면 어떨까 싶었다.
아래와 같이 정한 캠스터디의 시간표에 따라 시간 알림을 띄워보기로 했다.</p>
<blockquote>
<p><code>오전 9시</code> - 스터디 시작
<code>오후 12시</code> - 점심 시작
<code>오후 1시 반</code> - 점심 끝
<code>오후 6시</code> - 스터디 종료</p>
</blockquote>
<p>그런데 깃허브 커밋 알림과 달리 내가 직접 코드를 만들어 디스코드에 알림을 띄우려면 본래는 서버를 만들고 이를 관리해야 할 터,
찾아보니 AWS에서 제공하는 <code>Lambda(람다)</code>라는 서버리스 서비스가 있어 이를 활용해보기로 했다.</p>
<h3 id="aws-lambda">AWS Lambda</h3>
<blockquote>
<p>AWS 에서 제공하는 서버리스 컴퓨팅 플랫폼이다. 
여기서의 &#39;서버리스&#39;란,서버가 없다는 뜻이 아니고 개발자가 서버의 존재를 신경쓸 필요가 없다, 즉 <strong>직접 서버를 관리할 필요 없이 코드를 실행할 수 있다</strong>는 것이다. 
Lambda 함수에 코드를 구성하면 되는데, 필요할 때만 함수를 실행하고 자동으로 확장된다는 특징이 있어 특정한 시기에만 실행시키는 경우에 사용하기 유용하다.</p>
</blockquote>
<ul>
<li>서버 띄우지 않고 간단한 코드를 실행시키고 싶은 경우</li>
<li>특정 기간 또는 특정 주기로 코드를 실행시켜야 하는 경우</li>
<li>트리거가 실행될때만 코드를 실행시키고 싶은 경우</li>
</ul>
<p>특정 시간에 알림 메시지를 띄우는 함수를 실행하는 것이 목적이라 아주 적합!</p>
<h3 id="1-webhook-생성-1">1. Webhook 생성</h3>
<p>커밋 알림 설정 때와 동일하게 원하는 채팅 채널에 Webhook를 생성한다.
<img src="https://velog.velcdn.com/images/day_1226/post/71e6facb-3617-4711-85d2-2eac57081476/image.png" alt=""></p>
<h3 id="2-aws-lambda-함수-생성">2. AWS Lambda 함수 생성</h3>
<p>1) AWS Lambda로 들어간 다음,
<img src="https://velog.velcdn.com/images/day_1226/post/d2dc0516-c44e-46bb-bffa-86d4593466f3/image.png" alt="">2) <code>함수 생성</code>을 클릭한다.
<img src="https://velog.velcdn.com/images/day_1226/post/68b0ef90-345c-451d-b1d4-7264250fef8b/image.png" alt=""><code>스터디 시작</code> / <code>점심 시작</code> / <code>점심 종료</code> / <code>스터디 종료</code> 이렇게 4개의 알림 메시지를 주어야 하니 4개의 함수를 만들 것이다. </p>
<p> (본래는 하나의 함수에 여러개의 트리거를 줘보려 했으나 잘 실행되지 않았고, 각각 함수를 만들어야 한다.)</p>
<p> 먼저 <code>스터디 시작</code> 함수를 만들어 보자.
<img src="https://velog.velcdn.com/images/day_1226/post/f7cdf088-2270-4295-8cf4-57ac1081b06a/image.png" alt=""><code>함수 이름</code> 입력 후 다른 설정 변경 없이 함수를 생성한다.</p>
<h3 id="3-함수-코드-작성-in-vs-code">3. 함수 코드 작성 (in VS Code)</h3>
<p>함수로 들어가보면 아래에 <code>코드</code> - <code>코드 소스</code> 부분에서 코드를 수정할 수 있는데, 여기서 코드 소스를 수정하지 않고 <code>VS Code</code>를 열자. (axios 설치가 필요하기 때문!)
<img src="https://velog.velcdn.com/images/day_1226/post/5a7f81a0-4c4e-401d-bc70-7a16713c4113/image.png" alt="">1) 컴퓨터 내 편한 곳에 임의의 폴더를 생성 후 <code>npm init</code>을 통해 <code>node_modules</code> &amp; <code>package.json</code>을 생성한다.
<img src="https://velog.velcdn.com/images/day_1226/post/2d0362b3-c008-46c5-9351-f2f7d1155611/image.png" alt="">2) 그 다음 <code>axios</code>를 설치한다.
<img src="https://velog.velcdn.com/images/day_1226/post/02671247-68e3-4432-90cf-cf9ec4623dd4/image.png" alt=""><img src="https://velog.velcdn.com/images/day_1226/post/4a5e2afa-be29-4576-aab9-6264c1b3eec9/image.png" alt="">3) 그 다음 디스코드에서 Webhook URL을 복사해 가져온 후, <code>index.mjs</code>파일을 생성해 아래와 같이 작성한다.</p>
<pre><code class="language-js">import axios from &#39;axios&#39;;

export const handler = async (event) =&gt; {
  try {
    const payload = {
      // content: &#39;&#39;,
      embeds: [
        {
          title: &#39;⏰ 스터디 시작 📔&#39;,
          description: &#39;타이머 켜고⏲️ 스터디룸으로🚶️\n오늘 하루도 화이팅! 🔥&#39;,
          color: 0xd3d3d3,
        },
      ],
    };
    await axios.post(
      &#39;{복사한 Webhook URL}&#39;,
      payload,
    );
    console.info(&#39;웹훅 성공&#39;);
  } catch (error) {
    console.error(&#39;웹훅 실패&#39;, error);
  }

  // 기본 응답
  const response = {
    statusCode: 200,
    body: JSON.stringify(&#39;Hello from Lambda!&#39;),
  };
  return response;
};
</code></pre>
<p><code>import axios from &#39;axios&#39;;</code>로 axios를 불러온 후 Webhook URL을 통해 POST 요청을 보낸다.</p>
<p><code>payload</code>를 잠깐 살펴보면</p>
<ul>
<li><code>content</code> - 기본 채팅 문구로 표시</li>
<li><code>embeds</code> - 박스 인용구로 표시
<img src="https://velog.velcdn.com/images/day_1226/post/e9378e2b-137a-4e6a-900e-a0dd7eb799e7/image.png" alt=""><ul>
<li><code>title</code> - 인용구 제목</li>
<li><code>description</code> - 인용구 설명</li>
<li><code>color</code> - 글씨 색상</li>
</ul>
</li>
</ul>
<p>이렇게 구성되어 있고, <code>content</code>와 <code>embeds</code> 둘 다 보여주거나 코드처럼 하나를 생략해 payload를 작성해도 된다.</p>
<p>4) <code>node_modules</code>, <code>index.mjs</code>, <code>package.json</code>, <code>package-lock.json</code>을 <code>zip</code>파일로 압축한다. 
| (<code>.Zip</code> X <code>.zip</code> O)
<img src="https://velog.velcdn.com/images/day_1226/post/f3788211-1b4f-4363-a14e-0506b1037563/image.png" alt=""><code>index.mjs</code>에서 <code>payload</code> 내 문구만 변경하여 4가지 버전으로 압축 파일을 만들었다.</p>
<p>5) 이제 다시 AWS lambda로 돌아와, <code>코드 소스</code> 우측의 <code>.zip</code>파일 업로드를 통해 압축파일을 올려 준다.
<img src="https://velog.velcdn.com/images/day_1226/post/6d255f2a-4afe-45ce-a3f2-a24c35d381cc/image.png" alt=""></p>
<blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/a4838281-311e-4f7d-9e8c-8e74fde66d90/image.png" alt=""><code>.Zip</code>파일 업로드 시 <code>&#39;잘못된 입력&#39;</code>으로 처리되기 때문에 <code>.zip</code> 파일을 업로드해야 한다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/5798ef9a-ce41-4377-bd9d-d02a43c3995a/image.png" alt=""></p>
<p>업로드 후 코드 소스에 내가 작성한 코드가 잘 들어간 것 확인!</p>
<h3 id="4-일정-시간마다-알림-메시지를-띄우기-위한-트리거-생성">4. 일정 시간마다 알림 메시지를 띄우기 위한 트리거 생성</h3>
<p>이제 마지막으로, 트리거 추가를 통해 원하는 시간에 함수가 실행(알림 메시지 띄우기)될 수 있도록 세팅하자.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/d8a84f62-81d5-4006-894c-a29b13546379/image.png" alt="">1) <code>트리거 추가</code>를 클릭한다.
<img src="https://velog.velcdn.com/images/day_1226/post/8f8e358c-64c5-43cc-95e6-0ed264c05178/image.png" alt="">2) <code>소스 선택</code>에서 <code>EventBridge</code>를 선택한다.</p>
<p>그러면 규칙을 작성하는 폼이 띄워지는데,
<img src="https://velog.velcdn.com/images/day_1226/post/c97bf62e-d4d8-42d2-80a0-bfe3bf7663de/image.png" alt=""><code>새 규칙 생성</code> 후 <code>규칙 이름</code>, <code>규칙 설명(선택)</code>, <code>예약 표현식</code>을 입력한다.</p>
<blockquote>
<p>여기서 예약 표현식을 <code>cron</code>으로 작성 시 UTC 시간을 기준으로 한다.
내가 한국 시간(UTC+9) 기준 <code>오전 09시 00분</code>에 트리거를 설정하려면 UTC 시간으로는 <code>00시 00분</code>이 된다. 그러므로 <code>cron(0 0 ? * MON-FRI *)</code>와 같이 작성하면 끝!</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/9377172f-b917-4246-a853-884a4b473597/image.png" alt="">트리거가 추가되었다.</p>
<blockquote>
<p>+) <strong>설정한 트리거 예약 시간과 상관없이</strong> 알림 메시지가 잘 띄워보는지 즉시 테스트해 보고 싶다면?
<img src="https://velog.velcdn.com/images/day_1226/post/4aacce87-e446-49a5-85f4-c46bbb7c7558/image.png" alt=""><code>코드 소스</code>에서 파란 <code>Test</code>버튼 클릭 시 함수가 바로 실행된다.<img src="https://velog.velcdn.com/images/day_1226/post/42e82825-e44d-4bde-9a29-b3e8affbec83/image.png" alt="">클릭 하자마자 디스코드에 알림 메시지가  띄워졌다b</p>
</blockquote>
<h3 id="결과-1">결과</h3>
<p>나머지 3개의 함수 생성 후, 각 함수마다 예약 시간을 다르게 설정한 결과는?<img src="https://velog.velcdn.com/images/day_1226/post/727d43e6-26c0-4b5d-b733-7f44f3a6bf8b/image.png" alt="">
서버를 열고 닫을 필요 없이, AWS Lambda 함수를 만든 것 만으로 
가만히 있으면 자동으로 알림을 보내주니 아주 편하고 좋았다.</p>
<p>AWS LAmbda를 더 활용해 볼 수 있는 방법이 있는지 시간 나면 더 찾아보고 싶다!💪</p>
<hr>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://takethat.tistory.com/77#2.%20AWS%20Lambda%20%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-1">Discord Bot - WebHook으로 공부시간 알림 만들기</a>
<a href="https://oliviakim.tistory.com/55">[Node.js / AWS Lambda / Discord]　시간 자동 알림 디스코드 웹 훅 만들기</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | AWS S3 이미지 업로드 API 구현하기 (+ AWS S3 버킷 생성, route 작성하기)]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-AWS-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-AWS-S3-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 26 Sep 2024 07:03:13 GMT</pubDate>
            <description><![CDATA[<p>인턴하면서 AWS S3을 통해 이미지를 저장하고 프로젝트에서 이미지를 읽어들일 때 pre-signed URL로 사용했던 경험을 계기로, 
Next.js 개인 프로젝트에서 직접 AWS S3 Bucket을 생성해서 이미지를 저장해 사용해 보기로 했다.</p>
<hr>
<h1 id="aws-s3-버킷-만들기">AWS S3 버킷 만들기</h1>
<h2 id="1-aws-s3-버킷-생성하기">1. AWS S3 버킷 생성하기</h2>
<p>AWS 계정을 이미 생성했다는 가정 하에,</p>
<p>로그인 후 상단 검색을 이용해 <strong><code>S3</code></strong> 서비스를 클릭한다.
<img src="https://velog.velcdn.com/images/day_1226/post/cab4b563-b6f3-4410-8416-ef23e425abd1/image.png" alt=""></p>
<p><strong><code>버킷 만들기</code></strong>를 클릭해 버킷을 만들 것이다.
(버킷은 저장공간을 말한다.)
<img src="https://velog.velcdn.com/images/day_1226/post/722d1b38-424c-4b95-8ee9-b9eb075fbdc6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/4bd052c1-94ac-4a62-96f1-10971312b2e7/image.png" alt=""></p>
<p>먼저 AWS 리전이 올바르게 설정되어 있는지 확인한다.</p>
<blockquote>
<p><strong>AWS 리전(Region)</strong>
AWS 인프라를 지리적으로 나누어 배포한 것을 의미한다. 지리적 영역이며 사용자와 리전이 가까울수록 네트워크 지연을 최소화할 수 있다. 따라서 사용자는 AWS 리전 선택 시 글로벌하게 분포되어 있는 리전 중에 실제 서비스를 사용할 사용자층과 가장 가까운 리전을 선택하여 그 리전 내 클라우드 인프라를 사용하는 것이 일반적인 방법이다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/a4b05bc0-a55d-4905-8e1b-183df9b80e7f/image.png" alt="">올바르게 설정되어 있지 않다면, 오른쪽 상단의 <strong><code>지역</code></strong>을 변경 후 다시 버킷 만들기를 진행한다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/68eb9c5c-2fb3-4a61-878c-aefd7595f5a2/image.png" alt=""></p>
<p><strong>원하는 버킷이름</strong>을 입력한 후, 그 다음은 아래 기본 설정을 따라간다.</p>
<blockquote>
<p>객체 소유권 : ACL 비활성화됨 (권장)
버킷 버전 관리 : 비활성화
태그 - 선택사항 : 미지정기
본 암호화 : Amazon S3 관리형 키 (SSE-S3)를 사용한 서버 측 암호화 </p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/9ed9e2c8-059b-4b30-8b23-4d4f984fe4c9/image.png" alt=""></p>
<p>그 후 <strong><code>이 버킷의 퍼블릭 액세스 차단 설정</code></strong>에서 <strong>모든 퍼블릭 액세스 차단</strong>을 해제한 후 노란 박스에 체크한다.</p>
<p>최종 <strong><code>버킷 만들기</code></strong>를 클릭하고 대시보드로 돌아가면 버킷이 생성된 것을 확인할 수 잇다.
<img src="https://velog.velcdn.com/images/day_1226/post/e3419f88-24c0-4987-b286-6cc6c34d949c/image.png" alt=""></p>
<h2 id="2-aws-s3-버킷-정책-변경-cors-설정하기">2. AWS S3 버킷 정책 변경, CORS 설정하기</h2>
<h3 id="1-버킷-정책-변경">1) 버킷 정책 변경</h3>
<p>누가 버킷을 읽고, 수정, 삭제할 수 있는지에 대한 권한을 정의해야 한다.
해당 버킷의 <strong><code>권한</code></strong> 탭을 클릭 후, <strong><code>버킷 정책</code></strong> - <strong><code>편집</code></strong>버튼을 클릭해 준다.</p>
<p>그 다음 아래와 같이 작성한다.</p>
<pre><code>{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
        {
            &quot;Sid&quot;: &quot;1&quot;,
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: &quot;*&quot;, 
            &quot;Action&quot;: &quot;s3:GetObject&quot;,
            &quot;Resource&quot;: &quot;arn:aws:s3:::버킷명/*&quot;
        },
        {
            &quot;Sid&quot;: &quot;2&quot;,
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: {
                &quot;AWS&quot;: &quot;arn:aws:iam::나의계정아이디:root&quot;
            },
            &quot;Action&quot;: [
                &quot;s3:PutObject&quot;,
                &quot;s3:DeleteObject&quot;
            ],
            &quot;Resource&quot;: &quot;arn:aws:s3:::버킷명/*&quot;
        }
    ]
}</code></pre><p><img src="https://velog.velcdn.com/images/day_1226/post/6df6040b-82f1-4396-9c9d-0f9b32079bc9/image.png" alt="">
다른 양식은 변경하지 않고, 한글로 되어 있는 부분만 수정해 그대로 붙여 넣어준다.</p>
<blockquote>
<p>계정 ID는 오른쪽 상단 <code>username</code> 클릭 시 확인할 수 있다.
<img src="https://velog.velcdn.com/images/day_1226/post/9b460336-c545-4943-9f32-ead326949638/image.png" alt=""></p>
</blockquote>
<h3 id="2-cors-설정">2) CORS 설정</h3>
<p>다음으로 어떤 사이트에서 버킷 안의 파일들을 읽고, 쓰고, 삭제할 수 있는지 권한을 주기 위한 CORS를 설정한다.
권한 탭의 맨 하단에 <strong><code>CORS(Cross-origin 리소스 공유)</code></strong> 로 이동해 <strong><code>편집</code></strong>을 클릭한다.</p>
<p>그 다음 아래와 같이 작성한다. (모든 사이트에 대한 권한이 허용된다.)</p>
<pre><code>[
    {
        &quot;AllowedHeaders&quot;: [
            &quot;*&quot;
        ],
        &quot;AllowedMethods&quot;: [
            &quot;PUT&quot;,
            &quot;POST&quot;,
            &quot;GET&quot;
        ],
        &quot;AllowedOrigins&quot;: [
            &quot;*&quot;
        ],
        &quot;ExposeHeaders&quot;: [
            &quot;ETag&quot;
        ]
    }
]</code></pre><h2 id="3-access-키-발급">3. Access 키 발급</h2>
<p><img src="https://velog.velcdn.com/images/day_1226/post/11e699c3-efa9-4bb1-a339-281823b3fcbf/image.png" alt=""></p>
<p>오른쪽 상단의 username을 클릭 후 <strong><code>보안 자격 증명</code></strong>으로 이동한다.</p>
<p>그 다음 액세스 키 만들기를 통 해 액세스 키를 발급한다.
<img src="https://velog.velcdn.com/images/day_1226/post/1406345a-bfd4-43df-a532-f7ea7479d43a/image.png" alt="">
만들기를 완료하기 전 <strong>꼭 비밀 액세스 키를 다른 곳에 저장해 두거나 CSV 파일을 받아둔다</strong>.</p>
<p>비밀 액세스 키를 받아둔 후 완료를 클릭한다.</p>
<h2 id="버킷에서-직접-이미지-업로드--불러와-보기">버킷에서 직접 이미지 업로드 + 불러와 보기</h2>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ab6e7657-588f-43e7-83ac-8ff1d39d00aa/image.jpg" alt=""> 테스트 삼아 이 이미지를 업로드 한 후 불러와 보기로 했다.
<img src="https://velog.velcdn.com/images/day_1226/post/a97c2ba5-3be7-4a6e-9496-a51c4258fb22/image.png" alt=""><strong><code>버킷</code></strong> - <strong><code>객체</code></strong>에서 <strong><code>업로드</code></strong>를 클릭한다.
<img src="https://velog.velcdn.com/images/day_1226/post/67198d85-87f7-4f65-a2bb-dd4b09601e7f/image.png" alt="">이미지 파일을 추가한 후 <strong><code>업로드</code></strong>를 클릭해 완료한다.
(참고로 여러 파일의 이미지를 한꺼번에 추가해 업로드할 수도 있다.)</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/3c0ae009-c8ff-433c-95cd-23a6435ebcce/image.png" alt="">다시 돌아와 업로드 된 이미지를 선택 후 <strong><code>URL 복사</code></strong>를 클릭하면 아래와 같은 방식의 <code>pre-signed URL</code>이 복사된다.</p>
<pre><code>https://{버킷명}.s3.{AWS리전}.amazonaws.com/{파일명}</code></pre><p>브라우저에 해당 URL을 입력해 이동하면?
<img src="https://velog.velcdn.com/images/day_1226/post/894230c1-7ac9-48fc-814a-5e444f69cfe7/image.png" alt="">이미지가 불러와졌다!</p>
<p>이 원리에 따라 Next.ts 프로젝트에서 이미지 업로드 API 로직을 구현하면, 
API 요청을 통해 <code>AWS S3</code>에 이미지를 업로드한 후 저장된 이미지의 <code>pre-signed URL</code>로 이미지를 불러오는 방식으로 프로젝트에서 이미지를 사용할 수 있게 된다.</p>
<hr>
<h1 id="aws-s3-이미지-업로드-api-구현하기">AWS S3 이미지 업로드 API 구현하기</h1>
<h2 id="1-프로젝트-환경변수-설정">1. 프로젝트 환경변수 설정</h2>
<p>이미지 업로드를 구현할 프로젝트의 <code>env</code>파일에 
<code>AWS 리전 / 버킷 이름 / ACCESS 키 아이디 / ACCESS 비밀 키</code>를 저장한다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/3ff8b928-c24c-48ba-b4d0-1386e6ad5b4b/image.png" alt=""></p>
<h2 id="2-이미지-업로드-api-구현">2. 이미지 업로드 API 구현</h2>
<p>먼저 아래 패키지를 설치해 준다.</p>
<p><a href="https://www.npmjs.com/package/@aws-sdk/client-s3">NPM - @aws-sdk/client-s3 패키지</a></p>
<p><code>app &gt; api &gt; image &gt; route.ts</code>에 이미지를 업로드 CREATE 하기 위한 로직을 작성한다. </p>
<pre><code class="language-ts">import { PutObjectCommand, S3Client } from &#39;@aws-sdk/client-s3&#39;;

const Bucket = process.env.AMPLIFY_BUCKET;
// S3 클라이언트 설정
const s3 = new S3Client({
  region: process.env.AWS_REGION,
  credentials: {
    accessKeyId: process.env.AWS_ACCESS_KEY_ID as string,
    secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY as string,
  },
});

// POST 함수 
export async function POST(req: Request, res: Response) {
  try {
    // 폼 데이터 처리
    const formData = await req.formData();
    const files = formData.getAll(&#39;img&#39;) as File[];

    // 업로드할 이미지 파일 수 제한
    if (files.length &gt; 3) {
      return new Response(
        JSON.stringify({
          message: &#39;업로드할 수 있는 이미지 파일의 수는 최대 3장입니다.&#39;,
        }),
        { status: 400 },
      );
    }

    // 이미지 파일을 S3에 업로드
    const uploadPromises = files.map(async (file) =&gt; {
      const Body = Buffer.from(await file.arrayBuffer());
      const Key = file.name;
      const ContentType = file.type || &#39;image/jpg&#39;;

      await s3.send(
        new PutObjectCommand({
          Bucket,
          Key,
          Body,
          ContentType,
        }),
      );

      return [
        `https://${Bucket}.s3.${process.env.AWS_REGION}.amazonaws.com/${Key}`,
      ];
    });

    const imgUrls = await Promise.all(uploadPromises);

    // 파일의 개수가 1이면 배열 대신 단일 URL 반환
    if (imgUrls.length === 1) {
      return new Response(JSON.stringify({ data: imgUrls[0], message: &#39;OK&#39; }), {
        status: 200,
      });
    }

    // 파일 개수가 여러개면 URL 배열 반환
    return new Response(JSON.stringify({ data: [...imgUrls], message: &#39;OK&#39; }), {
      status: 200,
    });

  } catch (error) {
    console.error(&#39;Error uploading files:&#39;, error);
    return new Response(
      JSON.stringify({ message: &#39;파일 업로드 중 오류가 발생했습니다.&#39; }),
      { status: 500 },
    );
  }
}
</code></pre>
<p>req로 부터 파일 데이터를 받으면,</p>
<ul>
<li>업로드 가능할 이미지 파일 수를 제한하고,</li>
<li>업로드한 이미지는 
<code>https://${Bucket}.s3.${process.env.AWS_REGION}.amazonaws.com/${Key}</code> 와 같이 이미지를 pre-signed URL로 사용할 수 있도록 변환했다.</li>
<li>변환한 이미지 파일 개수가 여러개면 배열 형식으로 반환하고, 단일 이미지라면 URL 문자열만을 반환했다.</li>
</ul>
<p>이를 바탕으로 어떠한 폼 제출 시 이미지 업로드 API 요청 후, 반환한 pre-signed 이미지 URL를 폼 제출 API Request로 넣는 방식으로 구현할 수 있게 되었다.</p>
<hr>
<h1 id="에러">에러</h1>
<blockquote>
<p>The bucket you are attempting to access must be addressed using the specified endpoint.
Please send all future requests to this endpoint.</p>
</blockquote>
<p>코드상의 리전과 버킷이 위치한 리전이 다른 경우 만난 에러이다.
버킷의 지역이 ap-northeast-2로 설정되어 있는데, pre-signed URL에서 
<code>https://{버킷명}.s3.ap-southeast-2.amazonaws.com/{파일명}</code>와 같이 엉뚱한 리전으로 요청될 때이다.
버킷을 만들 때 AWS 리전을 잘 확인하고, ENV 설정에서 올바르게 AWS 리전이 설정되어 있는지 확인한다면 만나지 않을 것이다. 나는 만났지만..^^</p>
<hr>
<h1 id="참고">참고</h1>
<blockquote>
<ul>
<li><a href="https://tech.codedream.co.kr/14">AWS S3 이미지 업로드하기 (with NextJS)</a></li>
</ul>
</blockquote>
<ul>
<li><a href="https://blog.naver.com/rheia/220897796702">[AWS] S3 의문의 에러</a></li>
<li><a href="https://m.blog.naver.com/techtrip/221732911078">[AWS] 리전(Region), 가용 영역(Availability Zone) 그리고 에지 로케이션(Edge Location</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #5 장소 위치 지도에 표시하기 (마커, 커스텀오버레이)]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-5-%EC%9E%A5%EC%86%8C-%EC%9C%84%EC%B9%98-%EC%A7%80%EB%8F%84%EC%97%90-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0-%EB%A7%88%EC%BB%A4-%EC%BB%A4%EC%8A%A4%ED%85%80%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-5-%EC%9E%A5%EC%86%8C-%EC%9C%84%EC%B9%98-%EC%A7%80%EB%8F%84%EC%97%90-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0-%EB%A7%88%EC%BB%A4-%EC%BB%A4%EC%8A%A4%ED%85%80%EC%98%A4%EB%B2%84%EB%A0%88%EC%9D%B4</guid>
            <pubDate>Sun, 08 Sep 2024 15:09:33 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4. 키워드로 장소 검색하기</a>에서 가져온 장소의 위치를 지도에 표시해줄 차례이다.</p>
<hr>
<h2 id="샘플-코드">샘플 코드</h2>
<p>장소 검색 결과를 잘 받아온 후 장소의 위치를 지도에 찍어주고 싶다고 할 때, 
Kakao API에서 사용할 수 있는 객체로는 <strong>마커</strong>, <strong>인포윈도우</strong>, <strong>커스텀오버레이</strong> 이렇게 3개가 있다. </p>
<table>
<thead>
<tr>
<th>기본 마커</th>
<th>인포윈도우</th>
<th>커스텀 오버레이</th>
</tr>
</thead>
<tbody><tr>
<td><code>Marker</code></td>
<td><code>InfoWindow</code></td>
<td><code>CustomOverlay</code></td>
</tr>
<tr>
<td><a href="https://apis.map.kakao.com/web/sample/basicMarker/"><img src="https://velog.velcdn.com/images/day_1226/post/4c0b583d-4ebd-47ef-a64a-92dd9e605230/image.png" alt="기본 마커"></a> (이미지 클릭 시 문서 이동)</td>
<td><a href="https://apis.map.kakao.com/web/sample/markerWithInfoWindow/"><img src="https://velog.velcdn.com/images/day_1226/post/c34c4816-65e0-4a86-8ed1-4e181f696f02/image.png" alt="인포윈도우"></a> (이미지 클릭 시 문서 이동)</td>
<td><a href="https://apis.map.kakao.com/web/sample/customOverlay1/"><img src="https://velog.velcdn.com/images/day_1226/post/829a35dd-de5f-49b3-805b-7ec1db8a3b30/image.png" alt="커스텀 오버레이"></a> (이미지 클릭 시 문서 이동)</td>
</tr>
</tbody></table>
<p>Kakao 지도 API 문서에서 설명하는 코드 예시를 보면 다음과 같이 구현할 수 있다.</p>
<pre><code class="language-ts">  // MAP 객체 생성 시 들어갈 설정
  var mapContainer = document.getElementById(&#39;map&#39;), // 지도를 표시할 div 
      mapOption = { 
          center: new kakao.maps.LatLng(33.450701, 126.570667), // 지도의 중심좌표
          level: 3 // 지도의 확대 레벨
      };

 // MAP 객체 
  var map = new kakao.maps.Map(mapContainer, mapOption);
  var position  = new kakao.maps.LatLng(33.450701, 126.570667); 
  var content = &#39;&#39;

  // 마커 띄우기
  var marker = new kakao.maps.Marker({
      position
  });

  // 인포윈도우
  var infowindow = new kakao.maps.InfoWindow({
      position, 
      content
  });
  // 커스텀 오버레이
  var customOverlay = new kakao.maps.CustomOverlay({
      position, 
      content
  });
   marker.setMap(map); // 마커 띄우기
  infowindow.open(map, marker); 
  customOverlay.setMap(map); // 커스텀 오버레이 띄우기</code></pre>
<ol>
<li><code>Map</code> 객체 생성 / 혹은 생성되어 있음 전제</li>
<li><code>position</code> 값 가져오기 (코드에선 지정된 <code>position</code>을 사용하고 있으나, 이후  장소 데이터 각각의 <code>position</code> 값을 불러올 것임.)</li>
<li><code>Marker, InfoWindow, CustomOverlay</code> 객체 생성</li>
<li><code>setMap()</code> 메서드를 통해 지도에 띄우기<blockquote>
<p>⭐ 객체 정의 + <code>setMap()</code>메서드를 사용하지 않고 parameter에 <code>map</code>을 넣어 객체를 &#39;생성&#39;만 하는 방식으로도 충분히 요소를 지도에 띄울 수 있다.</p>
</blockquote>
<pre><code class="language-tsx">new kakao.maps.Marker({
 map,
 position
});</code></pre>
<blockquote>
<p>⭐ 인포윈도우의 경우 </p>
</blockquote>
<ul>
<li>마커와 함께 사용 시에는 <code>open()</code>메서드를 통해 띄운다.</li>
</ul>
</li>
</ol>
<h3 id="tip-각-기능-사용-시-특징">tip) 각 기능 사용 시 특징</h3>
<p>위를 바탕으로 커스텀하기 위해 세개를 모두 사용해본 결과, 각각의 제한이 존재했다.</p>
<ul>
<li><p><strong>마커</strong> </p>
<ul>
<li><code>option</code>에서 Dom Element 삽입을 지원하지 않음. 단, <code>markerImage</code> 옵션이 존재해 이미지 파일을 적용하는 방식은 가능</li>
</ul>
</li>
<li><p><strong>인포윈도우</strong></p>
<ul>
<li><code>content</code>옵션을 통해 Dom Element 삽입이 가능하나, 이미지처럼 인포 윈도우 최상위 element에서 고정된 스타일이 있어 커스텀이 어려움(이미지 처럼 테두리 하얀 네모박스...못없앱니다...)
<img src="https://velog.velcdn.com/images/day_1226/post/2da4ff3a-15d4-4d4c-b86f-5e670393f78a/image.png" alt=""></li>
</ul>
</li>
</ul>
<ul>
<li><strong>커스텀 오버레이</strong> : 고정 스타일 없음! Dom Element 삽입가능!</li>
</ul>
<p>결론적으로 커스텀을 하고 싶다면 인포윈도우 사용은 좋지 않았다.</p>
<blockquote>
<ol>
<li>마커 표시만 하고 싶다면, <strong>마커</strong>를 사용하고, </li>
<li>마커 위에 장소의 정보를 띄우고 싶다면 <strong>커스텀 오버레이</strong>로 대체하되 
열고 닫기 기능을 추가할 경우 마커에 이벤트를 등록하여 &#39;클릭 시 커스텀 오버레이 생성&#39;하는 방법으로 구현하는 것을 추천한다.</li>
</ol>
</blockquote>
<p>나의 경우 프로젝트에서 필요에 따라 각각 <strong>마커</strong>와  <strong>커스텀 오버레이</strong>를 사용하여 다음과 같이 장소 위치를 띄워주고 있는데, 그 방법을 얘기해보려 한다.</p>
<table>
<thead>
<tr>
<th align="center"><img src="https://velog.velcdn.com/images/day_1226/post/d9a6f092-9f44-4d9c-949b-c63edfe3410c/image.png" alt="">- Marker 사용</th>
<th align="center"><img src="https://velog.velcdn.com/images/day_1226/post/03d44dfa-3254-4b75-be02-ffded614a200/image.png" alt=""> -CustomOverlay 사용</th>
</tr>
</thead>
</table>
<h2 id="마커--커스텀오버레이-띄우기-준비물">마커 / 커스텀오버레이 띄우기 준비물</h2>
<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4. 키워드로 장소 검색하기</a>에 만들었던 <code>useSearchPlaces</code>를 가져왔다.</p>
<pre><code class="language-tsx">const useSearchPlaces = () =&gt; {

  // 장소 검색 함수
  const searchPlaces = (keyword: string) =&gt; {
    if (keyword !== &#39;&#39;) {
      const places = new kakao.maps.services.Places();
      places.keywordSearch(keyword, searchPlacesCB);
    }
  };

  // keywordSearch 콜백 함수
  const searchPlacesCB = async (
    data: any,
    status: kakao.maps.services.Status,
    pagination: any,
  ) =&gt; {
    if (status === kakao.maps.services.Status.OK) {
      console.log(filteredPlaces);
    } else {
      if (status === kakao.maps.services.Status.ZERO_RESULT) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      } else if (status === kakao.maps.services.Status.ERROR) {
        return alert(&#39;검색 결과 중 오류가 발생했습니다.&#39;);
      }
    }
  };

  return { searchPlaces };
};

export default useSearchPlaces;</code></pre>
<ol>
<li>여기에 Map 객체를 가져오고,</li>
<li>장소 검색 결과로 반환된 데이터의 위치 데이터를 이용해
Marker / Custoverlay 객체를 생성하는 함수를 만들어야 한다.</li>
</ol>
<p>1번은 <a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2-%EC%A7%80%EB%8F%84-%EB%9D%84%EC%9A%B0%EA%B8%B0">#2 지도 띄우기</a>를 구현할 때 mapContext에 미리 저장한 Map 객체를 활용하고,
2번은 <code>displayPlaces</code>라는 함수를 만들어 객체 생성을 처리해볼 것이다.</p>
<p>그러면 아래와 같이 세팅 완!</p>
<pre><code class="language-tsx">const useSearchPlaces = () =&gt; {
  const mapContext = useMap(); // mapContext 가져오기

  // 장소 검색 함수
  const searchPlaces = (keyword: string) =&gt; {
    if (keyword !== &#39;&#39;) {
      const places = new kakao.maps.services.Places();
      places.keywordSearch(keyword, searchPlacesCB);
    }
  };

  // keywordSearch 콜백 함수
  const searchPlacesCB = async (
    data: any,
    status: kakao.maps.services.Status,
    pagination: any,
  ) =&gt; {
    if (status === kakao.maps.services.Status.OK) {
      displayPlaces(filteredPlaces); // 마커 / 커스텀오버레이를 생성하는 함수 호출
    } else {
      if (status === kakao.maps.services.Status.ZERO_RESULT) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      } else if (status === kakao.maps.services.Status.ERROR) {
        return alert(&#39;검색 결과 중 오류가 발생했습니다.&#39;);
      }
    }
  };

  // 마커 / 커스텀오버레이 생성
  const displayPlaces = (places: any[]) =&gt; {
      const mapData : kakao.maps.Map = mapContext?.mapData;
    // 만약 Map을 띄움과 동시에 마커를 찍을 거라면 샘플 코드처럼
    // var map = new kakao.maps.Map(mapContainer, mapOption);를 정의해 사용

     places.forEach((place, index) =&gt; {
      const position = new kakao.maps.LatLng(place.y, place.x); // position 값 가져오기 

       // ...여기서 Marker,Customoverlay 등을 생성

     }
  }
  return { searchPlaces };
};

export default useSearchPlaces;</code></pre>
<h2 id="커스텀-마커marker-띄우기">커스텀 마커(Marker) 띄우기</h2>
<p><img src="https://velog.velcdn.com/images/day_1226/post/d31659bc-7cd6-4e29-9ffb-bb1e955742ce/image.png" alt="">사진처럼 내가 커스텀해 만든 마커를 띄우는 방법으로 <a href="https://apis.map.kakao.com/web/sample/basicMarkerImage/">다른 이미지로 마커 생성하기</a>를 활용하면 되는데,</p>
<blockquote>
<h3 id="kakaomapsmarkeroptions">kakao.maps.Marker(options)</h3>
<p>주어진 객체로 마커를 생성한다.
지도 뿐만 아니라 로드뷰 위에도 올릴 수 있다.</p>
</blockquote>
<pre><code>var marker = new kakao.maps.Marker({
    map: map,
    position: new kakao.maps.LatLng(33.450701, 126.570667)
});</code></pre><p>Parameters</p>
<ul>
<li><code>options</code> Object<ul>
<li><code>map</code> Map | Roadview : 마커가 올라갈 지도 또는 로드뷰</li>
<li><code>position</code> LatLng | Viewpoint : 마커의 좌표 또는 로드뷰에서의 시점</li>
<li><code>image</code> MarkerImage : 마커의 이미지</li>
<li><code>title</code> String : 마커 엘리먼트의 타이틀 속성 값 (툴팁)</li>
<li><code>draggable</code> Boolean : 드래그 가능한 마커, 로드뷰에 올릴 경우에는 유효하지 않다</li>
<li><code>clickable</code> Boolean : 클릭 가능한 마커</li>
<li><code>zIndex</code> Number : 마커 엘리먼트의 z-index 속성 값</li>
<li><code>opacity</code> Number : 마커 투명도 (0-1)</li>
<li><code>altitude</code> Number : 로드뷰에 올라있는 마커의 높이 값(m 단위)</li>
<li><code>range</code> Number : 로드뷰 상에서 마커의 가시반경(m 단위), 두 지점 사이의 거리가 지정한 값보다 멀어지면 마커는 로드뷰에서 보이지 않게 된다</li>
</ul>
</li>
</ul>
<p>Marker 객체의 Parameters를 살펴보면 <code>image</code>옵션을 이용해 마커 이미지를 지정할 수 있고,
<a href="https://apis.map.kakao.com/web/documentation/#MarkerImage"><code>MarkerImage</code></a>라는 객체를 받고 있다.</p>
<blockquote>
<h3 id="kakaomapsmarkerimagesrc-size-options">kakao.maps.MarkerImage(src, size[, options])</h3>
<p>Parameters</p>
</blockquote>
<ul>
<li><code>src</code> String : 이미지 주소</li>
<li><code>size</code> Size : 마커의 크기</li>
<li><code>options</code> Obejct<ul>
<li><code>alt</code> String : 마커 이미지의 alt 속성값을 정의한다.</li>
<li><code>coords</code> String : 마커의 클릭 또는 마우스오버 가능한 영역을 표현하는 좌표값</li>
<li><code>offset</code> Point : 마커의 좌표에 일치시킬 이미지 안의 좌표 (기본값: 이미지의 가운데 아래)</li>
<li><code>shape</code> String : 마커의 클릭 또는 마우스오버 가능한 영역의 모양</li>
<li><code>spriteOrigin</code> Point : 스프라이트 이미지 중 사용할 영역의 좌상단 좌표</li>
<li><code>spriteSize</code> Size : 스프라이트 이미지의 전체 크기</li>
</ul>
</li>
</ul>
<p>MarkerImage의 <code>src</code> , <code>size</code>, <code>options - offset</code>를 사용하여 객체 정의 후, 
Marker 객체를 생성하면 된다.</p>
<pre><code class="language-tsx">  // 마커 / 커스텀오버레이 생성
  const displayPlaces = (places: any[]) =&gt; {
      const mapData : kakao.maps.Map = mapContext?.mapData;
    // 만약 Map을 띄움과 동시에 마커를 찍을 거라면 샘플 코드처럼
    // var map = new kakao.maps.Map(mapContainer, mapOption);를 정의해 사용

     places.forEach((place, index) =&gt; {
      const position = new kakao.maps.LatLng(place.y, place.x); // position 값 가져오기 

      // MarkerImage parameter
      const imageSrc = &#39;/icons/icon-marker.svg&#39;,
        imageSize = new kakao.maps.Size(56, 56),
        imageOption = { offset: new kakao.maps.Point(27, 54) };
      // MarkerImage
      const markerImage = new kakao.maps.MarkerImage(
        imageSrc,
        imageSize,
        imageOption,
      );
      // Marker
      const marker = new kakao.maps.Marker({
        position: position,
        image: markerImage,
      });

      // 지도에 띄우기
      marker.setMap(mapContext?.mapData as kakao.maps.Map);

     }
  }
</code></pre>
<h2 id="커스텀-오버레이-띄우기">커스텀 오버레이 띄우기</h2>
<p><img src="https://velog.velcdn.com/images/day_1226/post/7d6843c4-f638-4da7-b9ad-a46527ac4cf1/image.png" alt=""></p>
<p>커스텀 오버레이 사용 시 &#39;장소 이름 데이터&#39;를 사용하면서도 원하는 스타일링으로 장소 위치를 띄울 수 있다.</p>
<blockquote>
<h3 id="kakaomapscustomoverlayoptions">kakao.maps.CustomOverlay(options)</h3>
<p>주어진 객체로 커스텀 오버레이를 생성한다.</p>
</blockquote>
<pre><code class="language-tsx">var customOverlay = new kakao.maps.CustomOverlay({
    map: map,
    clickable: true,
    content: &#39;&lt;div class=&quot;customOverlay&quot;&gt;&lt;a href=&quot;#&quot;&gt;Chart&lt;/a&gt;&lt;/div&gt;&#39;,
    position: new kakao.maps.LatLng(33.450701, 126.570667),
    xAnchor: 0.5,
    yAnchor: 1,
    zIndex: 3
});</code></pre>
<p>Parameters</p>
<ul>
<li>`options Object<ul>
<li><code>clickable</code> Boolean : true 로 설정하면 컨텐츠 영역을 클릭했을 경우 지도 이벤트를 막아준다.</li>
<li><code>content</code> Node | String : 엘리먼트 또는 HTML 문자열 형태의 내용</li>
<li><code>map Map</code> | Roadview : 커스텀 오버레이가 올라갈 지도 또는 로드뷰</li>
<li><code>position</code> LatLng | Viewpoint : 커스텀 오버레이의 좌표 또는 로드뷰에서의 시점</li>
<li><code>xAnchor</code> Number : 컨텐츠의 x축 위치. 0_1 사이의 값을 가진다. 기본값은 0.5</li>
<li><code>yAnchor</code> Number : 컨텐츠의 y축 위치. 0_1 사이의 값을 가진다. 기본값은 0.5</li>
<li><code>zIndex</code> Number : 커스텀 오버레이의 z-index</li>
</ul>
</li>
</ul>
<p><code>content</code> 에 <code>Node</code>를 받고 있어, 컴포넌트를 HTMLElement으로 변환해 넣어 줌으로써 커스텀이 가능하다.</p>
<h3 id="content에-들어갈-컴포넌트-만들기"><code>content</code>에 들어갈 컴포넌트 만들기</h3>
<p>이미지와 같은 말풍선 모양의 마커를 구현하기 위해 장소 이름(<code>placeName</code>)을 받는 <code>MarkerInfo</code>라는 컴포넌트를 만들었다.</p>
<pre><code class="language-tsx">&#39;use client&#39;;

import Image from &#39;next/image&#39;;

interface MarkerInfoProps {
  placeName: string;
}

const MarkerInfo = ({ placeName }: MarkerInfoProps) =&gt; {
  return (
    &lt;div className=&#39;speech-bubble flex w-auto gap-2 px-3 py-2 text-sm font-medium lg:text-lg&#39;&gt;
      &lt;span&gt;{placeName}&lt;/span&gt;
      &lt;Image
        className=&#39;max-w-none&#39;
        width={20}
        height={20}
        src=&#39;/icons/icon-logo-mini(default).svg&#39;
        alt=&#39;로고 그림&#39;
      /&gt;
    &lt;/div&gt;
  );
};

export default MarkerInfo;</code></pre>
<p>이를 String으로 변환해 <code>CustomOverlay</code> 객체 생성 시 <code>content</code>에 지정해 주면 된다.</p>
<pre><code class="language-tsx">import MarkerInfo from &#39;../_component/common/MarkerInfo&#39;;

  //...

  // 마커 / 커스텀오버레이 생성
  const displayPlaces = (places: any[]) =&gt; {
      const mapData : kakao.maps.Map = mapContext?.mapData;
    // 만약 Map을 띄움과 동시에 마커를 찍을 거라면 샘플 코드처럼
    // var map = new kakao.maps.Map(mapContainer, mapOption);를 정의해 사용

     places.forEach((place, index) =&gt; {
      const position = new kakao.maps.LatLng(place.y, place.x); // position 값 가져오기 

       // 커스텀오버레이 content에 들어갈 컴포넌트
      const overlayContent = &lt;MarkerInfo placeName={place.place_name} /&gt;;
      // 컴포넌트를 string으로 전환 후 HTMLElement에 삽입
      const overlay = document.createElement(&#39;div&#39;);
      overlay.innerHTML = ReactDOMServer.renderToString(overlayContent);
      // 커스텀 오버레이 생성
      const newOverlay = new kakao.maps.CustomOverlay({
        position: position,
        content: overlay,
        yAnchor: 1.3, // 높이 지정 (선택)
      });

      newOverlay.setMap(mapContext?.mapData as kakao.maps.Map);

     }
  }
</code></pre>
<h2 id="마커커스텀-오버레이에-클릭-이벤트-등록하기">마커/커스텀 오버레이에 클릭 이벤트 등록하기</h2>
<p>생성과 동시에 클릭 이벤트를 등록해 줌으로써 해당 마커의 위치로 이동하거나, 장소 상세 페이지로 이동하는 등의 기능을 넣어줄 수 있다.
아래는 커스텀오버레이 클릭 시 지도를 해당 마커의 위치로 이동하는 이벤트를 등록한 방법이다.</p>
<pre><code class="language-tsx">import MarkerInfo from &#39;../_component/common/MarkerInfo&#39;;

  //...

  // 마커 / 커스텀오버레이 생성
  const displayPlaces = (places: any[]) =&gt; {
      const mapData : kakao.maps.Map = mapContext?.mapData;
    // 만약 Map을 띄움과 동시에 마커를 찍을 거라면 샘플 코드처럼
    // var map = new kakao.maps.Map(mapContainer, mapOption);를 정의해 사용

     places.forEach((place, index) =&gt; {
      const position = new kakao.maps.LatLng(place.y, place.x); // position 값 가져오기 

       // 커스텀오버레이 content에 들어갈 컴포넌트
      const overlayContent = &lt;MarkerInfo placeName={place.place_name} /&gt;;
      // 컴포넌트를 string으로 전환 후 HTMLElement에 삽입
      const overlay = document.createElement(&#39;div&#39;);
      overlay.innerHTML = ReactDOMServer.renderToString(overlayContent);

     // HTMLElement에 이벤트 등록
     overlay.addEventListener(&#39;click&#39;, () =&gt; {
        mapContext?.mapData?.panTo(position);
      });

      // 커스텀 오버레이 생성
      const newOverlay = new kakao.maps.CustomOverlay({
        position: position,
        content: overlay,
        yAnchor: 1.3, 
        clickable: true, // 클릭 가능 여부 true로 설정
      });

      newOverlay.setMap(mapContext?.mapData as kakao.maps.Map);

     }
  }
</code></pre>
<p><img src="https://velog.velcdn.com/images/day_1226/post/bf4d2347-46b0-4a7b-ac44-7ae1136373ac/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4.5 장소 검색 결과를 원하는 카테고리로 필터링하기]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EC%9B%90%ED%95%98%EB%8A%94-%EC%B9%B4%ED%85%8C%EA%B3%A0%EB%A6%AC%EB%A1%9C-%ED%95%84%ED%84%B0%EB%A7%81%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89-%EA%B2%B0%EA%B3%BC%EB%A5%BC-%EC%9B%90%ED%95%98%EB%8A%94-%EC%B9%B4%ED%85%8C%EA%B3%A0%EB%A6%AC%EB%A1%9C-%ED%95%84%ED%84%B0%EB%A7%81%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 30 Aug 2024 06:26:50 GMT</pubDate>
            <description><![CDATA[<h2 id="장소-검색-결과를-원하는-카테고리로-필터링하기">장소 검색 결과를 원하는 카테고리로 필터링하기</h2>
<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4. 키워드로 장소 검색하기</a>에서 장소 결과를 불러온 다음, 
나는 프로젝트에 필요한 <code>산책 가능한 장소</code>를 필터링해보기로 했다.</p>
<p>사실 Kakao 지도 API 에서는 &#39;키워드로 장소 검색&#39; 기능과 더불어  &#39;카테고리로 장소 검색&#39;기능을 제공하고 있으나...
<img src="https://velog.velcdn.com/images/day_1226/post/f7bc0843-caea-4597-b650-64357d2ade80/image.png" alt=""><img src="https://velog.velcdn.com/images/day_1226/post/dfd9aa9c-fad7-4131-9114-1fce0863eedf/image.png" alt=""></p>
<blockquote>
<p><a href="https://apis.map.kakao.com/web/sample/categoryFromBounds/">KaKao 지도 API - 카테고리별 장소 검색하기</a></p>
</blockquote>
<p>불러올 수 있는 카테고리종류가 다양하지만 내가 원하는 카테고리는 없는 상황이었다...</p>
<p>그래서 다른 방법을 모색하다 적용한 방법이 있다.
&#39;키워드로 장소 검색&#39; 기능을 활용하되 필터링할 수 있는💡</p>
<hr>
<pre><code class="language-json">{
    &quot;address_name&quot;: &quot;서울 성동구 성수동1가 678-1&quot;,
    &quot;category_group_code&quot;: &quot;&quot;,
    &quot;category_group_name&quot;: &quot;&quot;,
    &quot;category_name&quot;: &quot;여행 &gt; 공원 &gt; 도시근린공원&quot;,
    &quot;distance&quot;: &quot;28619&quot;,
    &quot;id&quot;: &quot;11331488&quot;,
    &quot;phone&quot;: &quot;02-460-2905&quot;,
    &quot;place_name&quot;: &quot;서울숲&quot;,
    &quot;place_url&quot;: &quot;http://place.map.kakao.com/11331488&quot;,
    &quot;road_address_name&quot;: &quot;&quot;,
    &quot;x&quot;: &quot;127.037617759165&quot;,
    &quot;y&quot;: &quot;37.5443222301513&quot;
}</code></pre>
<p><code>서울숲</code>을 검색 후 받아온 결과이다.
<code>category_name</code>을 주목해 보았다.</p>
<p>다른 <code>산책 가능한 장소</code>도 검색해 보았다. <code>중랑천</code>(하천), <code>일월수목원</code>(수목원)...</p>
<pre><code class="language-json">{
    &quot;address_name&quot;: &quot;서울 중랑구 면목동 1338-12&quot;,
    &quot;category_group_code&quot;: &quot;&quot;,
    &quot;category_group_name&quot;: &quot;&quot;,
    &quot;category_name&quot;: &quot;여행 &gt; 관광,명소 &gt; 하천&quot;,
    &quot;distance&quot;: &quot;34196&quot;,
    &quot;id&quot;: &quot;8089008&quot;,
    &quot;phone&quot;: &quot;&quot;,
    &quot;place_name&quot;: &quot;중랑천&quot;,
    &quot;place_url&quot;: &quot;http://place.map.kakao.com/8089008&quot;,
    &quot;road_address_name&quot;: &quot;&quot;,
    &quot;x&quot;: &quot;127.070847357397&quot;,
    &quot;y&quot;: &quot;37.5897928353235&quot;
}</code></pre>
<pre><code class="language-json">{
    &quot;address_name&quot;: &quot;경기 수원시 장안구 천천동 430&quot;,
    &quot;category_group_code&quot;: &quot;AT4&quot;,
    &quot;category_group_name&quot;: &quot;관광명소&quot;,
    &quot;category_name&quot;: &quot;여행 &gt; 관광,명소 &gt; 수목원,식물원&quot;,
    &quot;distance&quot;: &quot;189&quot;,
    &quot;id&quot;: &quot;380088639&quot;,
    &quot;phone&quot;: &quot;031-369-2380&quot;,
    &quot;place_name&quot;: &quot;일월수목원&quot;,
    &quot;place_url&quot;: &quot;http://place.map.kakao.com/380088639&quot;,
    &quot;road_address_name&quot;: &quot;경기 수원시 장안구 일월로 61&quot;,
    &quot;x&quot;: &quot;126.97665617545374&quot;,
    &quot;y&quot;: &quot;37.28835545840041&quot;
}</code></pre>
<p>모두 <code>여행</code> &gt; <code>관광,명소</code> &gt; <code>하천</code>으로 카테고리가 분류되어 있는 것을 볼 수 있었다.
&#39;카테고리로 장소 검색&#39;기능에 명시되어 있는 카테고리 외로도 장소 데이터마다 카테고리가 잘 분류되어 있는 점을 활용하기로 했다.</p>
<hr>
<h3 id="1-카테고리-정의하기">1. 카테고리 정의하기</h3>
<p>먼저 여러 장소를 검색해보며 <code>산책 가능한 장소</code>마다의 하위 카테고리를 모아 보았고, 
상수로 정의한 배열을 만들었다.</p>
<pre><code class="language-ts">export const FILTER_CATEGORIES = [
  &#39;도보여행&#39;,
  &#39;둘레길&#39;,
  &#39;하천&#39;,
  &#39;공원&#39;,
  &#39;도시근린공원&#39;,
  &#39;국립공원&#39;,
  &#39;도립공원&#39;,
  &#39;산&#39;,
  &#39;오름&#39;,
  &#39;호수&#39;,
  &#39;저수지&#39;,
  &#39;수목원,식물원&#39;,
];</code></pre>
<h3 id="2-필터링-로직-만들기">2. 필터링 로직 만들기</h3>
<p>그 다음 필터링을 위한 함수를 만들었다.</p>
<ul>
<li><p>일반 필터링</p>
<pre><code class="language-ts">export const filterPlacesByKeyword = (places: any[]) =&gt; {
  return places.filter((place) =&gt; {
    const categories = place.category_name.split(` &gt; `);

    return FILTER_CATEGORIES.some((keyword) =&gt; categories.includes(keyword));
  });
};</code></pre>
</li>
<li><p>강한 필터링
<code>여행</code> <code>관광,명소</code>를 포함한 카테고리까지 검색 결과를 정확하게 불러오고 싶다면 강한 필터링을 걸어 놓으면 될 것 같다.
(나의 경우 필터링하고자 하는 <code>산책 가능한 장소</code> 데이터 중 <code>여행</code> <code>관광,명소</code>가 포함되지 않는 일부 장소도 있어 제외하였다)</p>
<pre><code class="language-ts">export const filterPlacesByKeyword = (places: any[]) =&gt; {
  return places.filter((place) =&gt; {
    const categories = place.category_name.split(` &gt; `);

    const isValidPath =
      categories[0] === &#39;여행&#39; &amp;&amp; categories[1] === &#39;관광,명소&#39;;
    if (!isValidPath) return false;

    return FILTER_CATEGORIES.some((keyword) =&gt; categories.includes(keyword));
  });
};</code></pre>
</li>
</ul>
<h3 id="3-필터링-적용하기">3. 필터링 적용하기</h3>
<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4. 키워드로 장소 검색하기</a>에서 <a href="https://apis.map.kakao.com/web/documentation/#services_Places_keywordSearch"><code>keywordSearch</code></a>메서드를 사용할 때 인자로 콜백 함수를 받고 그 곳에서 검색 결과를 처리한다는 부분에 대해 설명한 적이 있다.</p>
<p>검색 결과에서 받아온 데이터를 필터링 함수를 통해 필터링해주면 된다.</p>
<ul>
<li><p>필터링 적용 전</p>
<pre><code class="language-ts">// keywordSearch 콜백 함수
  const searchPlacesCB = async (
    data: any,
    status: kakao.maps.services.Status,
    pagination: any,
  ) =&gt; {
    if (status === kakao.maps.services.Status.OK) {
      console.log(data);
    } else {
      if (status === kakao.maps.services.Status.ZERO_RESULT) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      } else if (status === kakao.maps.services.Status.ERROR) {
        return alert(&#39;검색 결과 중 오류가 발생했습니다.&#39;);
      }
    }
  };

  return { searchPlaces };
};</code></pre>
</li>
<li><p>필터링 적용 후</p>
<pre><code class="language-ts">  // keywordSearch 콜백 함수
  const searchPlacesCB = async (
    data: any,
    status: kakao.maps.services.Status,
    pagination: any,
  ) =&gt; {
    if (status === kakao.maps.services.Status.OK) {
      clearMarkersAndInfo();

      const filteredPlaces = filterPlacesByKeyword(data);
      if (filteredPlaces.length === 0) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      }
      console.log(filteredPlaces)

      searchPlace(filteredPlaces);
      displayMarkers(filteredPlaces);
    } else {
      if (status === kakao.maps.services.Status.ZERO_RESULT) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      } else if (status === kakao.maps.services.Status.ERROR) {
        return alert(&#39;검색 결과 중 오류가 발생했습니다.&#39;);
      }
    }
  };</code></pre>
<h3 id="4-검색-결과">4. 검색 결과</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ac47b602-5f75-42c2-8e17-16c5afad6c87/image.png" alt="">
이전 포스팅과 마찬가지로 <code>서호공원</code>이라는 장소가 위치한 곳에서, 지도에 보이는 영역만을 기준으로 <code>공원</code>을 검색했을 때의 결과는 다음과 같다.</p>
</li>
<li><p>필터링 적용 전<img src="https://velog.velcdn.com/images/day_1226/post/dc17db1c-eebf-4c1c-9bfb-750bd86ec1be/image.png" alt=""></p>
</li>
<li><p>필터링 적용 후<img src="https://velog.velcdn.com/images/day_1226/post/609edaf2-9e48-410a-9385-ae42af49b012/image.png" alt=""></p>
</li>
</ul>
<h2 id="🤔-필터링할-수-있는-다른-카테고리에는-어떤-것이-있을까">🤔 필터링할 수 있는 다른 카테고리에는 어떤 것이 있을까?</h2>
<p>KaKao 지도 API 장소 검색 기능을 활용해서 토이 프로젝트를 해보려는 분들에게 조금이라도 활용하는 데 도움이 되었으면 해서 다른 카테고리도 찾아 보았다.</p>
<ul>
<li><strong>휴게소</strong> <code>교통,수송 &gt; 휴게소</code> / <code>교통,수송 &gt; 휴게소 &gt; 고속도로 휴게소</code>
<img src="https://velog.velcdn.com/images/day_1226/post/4a2f0fe8-a71d-44d5-a37e-3e4d22553ef2/image.png" alt=""></li>
<li><strong>초,중,고,대학교</strong> - <code>교육,학문 &gt; 학교 &gt; 대학교</code>
<img src="https://velog.velcdn.com/images/day_1226/post/a477b851-e4e7-4f8c-bf06-5b6f415c8f3c/image.png" alt=""></li>
<li><strong>고속,시외버스터미널</strong>
<code>교통,수송 &gt; 교통시설 &gt; 고속,시외버스터미널</code> 
<img src="https://velog.velcdn.com/images/day_1226/post/6ce9bdad-6d54-415b-9908-4da58a17e385/image.png" alt="">
어쩌면 임시방편의 방법 같기도 하지만, 나의 경우 기획한 프로젝트 아이디어를 꼭 만들어보고 싶어서 모색한 방법이라 유레카였다ㅎ 
이 글을 보고 아이디어를 구현할 방법을 찾은 한 분이라도 있다면 좋을 것 같다😊</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #4. 키워드로 장소 검색하기]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-4.-%ED%82%A4%EC%9B%8C%EB%93%9C%EB%A1%9C-%EC%9E%A5%EC%86%8C-%EA%B2%80%EC%83%89%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 23 Aug 2024 05:14:25 GMT</pubDate>
            <description><![CDATA[<p>프로젝트 작업이 어느정도 진행된 상황에서 기록을 하려니 힘들구만...하지만 최대한 정리해 보았다.</p>
<p>이번엔 Kakao 지도 API를 사용하게 된 가장 큰 목적, &lt;장소 검색&gt;기능을 구현한 과정을 얘기해 보려 한다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/c5c4cbb5-166d-4fda-a1f9-470133715710/image.png" alt="">Kakao 지도 API 샘플 중 &#39;키워드로 장소검색하고 목록으로 표출하기&#39;를 사용할 것이다.</p>
<blockquote>
<p><a href="https://apis.map.kakao.com/web/sample/keywordList/">Kakao 지도 API - 키워드로 장소검색하고 목록으로 표출하기</a></p>
</blockquote>
<p>샘플 코드를 보면 목록 + 마커를 함께 생성하고 있는데, 장소 목록을 불러오는 과정까지 얘기하고 마커 생성은 다음 5탄에서 자세히 다뤄볼 예정!</p>
<p>나는 이 키워드 장소 검색 로직을 hook으로 만들어서 </p>
<ul>
<li>위치 이동 시 재검색</li>
<li>카테고리 버튼으로 재검색</li>
<li>새로고침 시 재검색</li>
</ul>
<p>등의 기능에 재사용해볼 것이다.</p>
<p>구현 순서는 아래와 같이! </p>
<blockquote>
<ol>
<li>장소 검색 Input 만들기 (SearchForm)</li>
<li>장소 검색 Hook 만들기(useSearchPlaces)</li>
<li>검색 결과</li>
<li>위치 기준 검색하기</li>
<li>일정 영역 내에서 검색하기</li>
</ol>
</blockquote>
<h2 id="1-장소-검색-input-만들기">1. 장소 검색 input 만들기</h2>
<h3 id="searchformtsx">SearchForm.tsx</h3>
<pre><code class="language-tsx">import React, { useState } from &#39;react&#39;;
import { useParams, useRouter } from &#39;next/navigation&#39;;
import Image from &#39;next/image&#39;;

import useSearchPlaces from &#39;@/app/_hooks/useSearchPlaces&#39;;

const SearchForm = () =&gt; {
  const router = useRouter();
  const { searchPlaces } = useSearchPlaces();

  const [keyword, setKeyword] = useState(&#39;&#39;);

  const onSubmit = (e: React.FormEvent&lt;HTMLFormElement&gt;) =&gt; {
    e.preventDefault();
    searchPlaces(keyword);
    router.push(`/place/search/${keyword}`);
  };

  const handleInputChange = (e: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    setKeyword(e.currentTarget.value);
  };

  return (
    &lt;div className=&#39;border-olive-green border-b p-4&#39;&gt;
      &lt;form
        onSubmit={onSubmit}
        className=&#39;border-olive-green box-content flex h-9 justify-between gap-1 rounded-lg border border-solid bg-white py-1 pl-2 pr-3 shadow-md&#39;
      &gt;
        &lt;input
          type=&#39;text&#39;
          value={keyword}
          onChange={handleInputChange}
          size={15}
          className=&#39;basis-full rounded-full pl-1 outline-none&#39;
          placeholder=&#39;장소 검색하기&#39;
        /&gt;
        &lt;button
          type=&#39;submit&#39;
          className=&#39;relative flex w-4 shrink-0 items-center justify-center&#39;
        &gt;
          &lt;Image fill={true} src=&#39;/icons/icon-search.svg&#39; alt=&#39;검색 아이콘&#39; /&gt;
        &lt;/button&gt;
      &lt;/form&gt;
    &lt;/div&gt;
  );
};

export default SearchForm;

</code></pre>
<h2 id="2-장소-검색-hook-만들기">2. 장소 검색 Hook 만들기</h2>
<h3 id="usesearchplacestsx">useSearchPlaces.tsx</h3>
<ul>
<li><p>useSearchPlaces - <code>searchPlaces</code>
useSearchPlaces훅에서 반환할 장소 검색 함수를 만든다.</p>
<pre><code class="language-tsx">const useSearchPlaces = () =&gt; {

  // 장소 검색 함수
  const searchPlaces = (keyword: string) =&gt; {
    if (keyword !== &#39;&#39;) {
      const places = new kakao.maps.services.Places();
      places.keywordSearch(keyword, searchPlacesCB);
    }
  };

  //...

  return { searchPlaces };
};

export default useSearchPlaces;</code></pre>
<ul>
<li>검색 키워드에 해당하는 <code>keyword</code> 매개변수를 받고 빈 문자열인지 확인한다.</li>
<li>카카오 지도 API의 장소 검색 서비스를 제공하는 <code>kakao.maps.services.Places</code> 객체를 생성한다. </li>
<li><code>places.keywordSearch</code> 메서드를 호출해 키워드로 장소를 검색한다. 두번째 인자에서 콜백 함수를 호출하는데, 콜백 함수를 만들어 검색 후의 결과를 처리하면 된다.</li>
</ul>
</li>
<li><p>useSearchPlaces - <code>searchPlaceCB</code></p>
<pre><code class="language-tsx">const useSearchPlaces = () =&gt; {

  // 장소 검색 함수
  const searchPlaces = (keyword: string) =&gt; {
    if (keyword !== &#39;&#39;) {
      const places = new kakao.maps.services.Places();
      places.keywordSearch(keyword, searchPlacesCB);
    }
  };

  // keywordSearch 콜백 함수
  const searchPlacesCB = async (
    data: any,
    status: kakao.maps.services.Status,
    pagination: any,
  ) =&gt; {
    if (status === kakao.maps.services.Status.OK) {
      console.log(data);
    } else {
      if (status === kakao.maps.services.Status.ZERO_RESULT) {
        return alert(&#39;검색 결과가 존재하지 않습니다.&#39;);
      } else if (status === kakao.maps.services.Status.ERROR) {
        return alert(&#39;검색 결과 중 오류가 발생했습니다.&#39;);
      }
    }
  };

  return { searchPlaces };
};

export default useSearchPlaces;</code></pre>
<ul>
<li><code>data</code> : 검색된 장소의 결과 데이터</li>
<li><code>status</code> : 검색의 성공 여부<pre><code class="language-ts">// kakao.maps.services.Status
export enum Status {
ERROR = &quot;ERROR&quot;, // 서버 응답에 문제가 있는 경우
OK = &quot;OK&quot;, //검색 결과 있음
ZERO_RESULT = &quot;ZERO_RESULT&quot;, // 정상적으로 응답 받았으나 검색 결과는 없음
}</code></pre>
</li>
<li><code>pagination</code> : 페이지네이션 정보로, 검색 결과가 여러 페이지에 걸쳐 있을 때 이를 처리하는 데 사용</li>
</ul>
</li>
</ul>
<h2 id="3-검색-결과">3. 검색 결과</h2>
<p>여기까지 작성 후 결과를 확인해 보자.
&#39;공원&#39;을 검색했을 때의 결과이다.
<img src="https://velog.velcdn.com/images/day_1226/post/5224ed0a-9e06-49b0-b5c6-e68eef9ab689/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/56619416-8d82-4797-803e-6968319f3c8a/image.png" alt=""></p>
<p>최대 15개의 목록 결과가 잘 불러와 진다.</p>
<h2 id="-장소-검색-옵션-설정하기">+ 장소 검색 옵션 설정하기</h2>
<p>그런데 검색 결과의 주소들을 자세히 보면 여러 지역에서 골고루 결과를 가져오고 있다.</p>
<ul>
<li>현재 나의 위치, 혹은 지도가 띄워진 <code>중심 좌표</code>를 기준으로 검색하고 싶을 때</li>
<li>지도가 띄워진 <code>영역</code> 내의 검색 결과만 가져오고 싶을때</li>
</ul>
<p>의 경우에는 위 검색 결과가 아쉬울 수 있다.
이 때 지도 객체에서 중심좌표나, 영역 정보를 가져와 검색 옵션을 지정해 줄 수 있다.</p>
<blockquote>
<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2-%EC%A7%80%EB%8F%84-%EB%9D%84%EC%9A%B0%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #2 지도 띄우기</a>에서 만든 <code>useMap() Context</code>에서 <code>mapData</code> Map 객체(<code>kakao.maps.Map</code>)를 가져와 활용해보자.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/0faa7908-3dec-4244-af25-e6eaed928685/image.png" alt="">예시로 지도가 이미지와 같이 &#39;서호공원&#39;이라는 장소를 zoom해 보여지고 있고 지도의 중심좌표, 영역을 각각 지정해 주었을 때의 결과를 비교해 보자.</p>
<ul>
<li><p><strong>지도의 <code>중심 좌표</code>를 기준으로 검색하기</strong>
<code>places.keywordSearch</code> 메서드를 다시 살펴 보자.
<img src="https://velog.velcdn.com/images/day_1226/post/0180fac2-26ca-4e2e-850b-e2bd34048078/image.png" alt="">알고 보니 <code>keyword</code>, <code>콜백 함수</code>, <code>options</code> 이렇게 3개의 인자를 받는다. 
option 중 <code>location</code>을 지정해 주면 된다.</p>
<pre><code class="language-tsx">import { useMap } from &#39;../shared/contexts/Map&#39;;

const useSearchPlaces = () =&gt; {
 const mapContext = useMap();
 // 장소 검색 함수
 const searchPlaces = (keyword: string) =&gt; {
   if (keyword !== &#39;&#39;) {
     const { mapData } = mapContext!;
     const location = mapData?.getCenter();
     const places = new kakao.maps.services.Places();
     places.keywordSearch(keyword, searchPlacesCB, {
       location,
     });
   }
 };

 // keywordSearch 콜백 함수
 // ...

};

export default useSearchPlaces;
</code></pre>
<p><code>mapData</code>라는 이름으로 저장했던 Map 객체의 내장메서드 <code>getCenter()</code>를 통해 지도의 중심값을 불러와, keywordSearch의 3번째 인자에 넣어주면 끝!
<img src="https://velog.velcdn.com/images/day_1226/post/77c60bd3-ccb2-4e6f-a984-3d9bd68d2911/image.png" alt="">지도에 보이는 &#39;서호공원&#39;과 
주변 &#39;공원&#39;에 해당하는 검색 결과가 모두 불러와 졌다.</p>
<ul>
<li><strong>현재 내 위치의 <code>좌표</code>를 기준으로 검색하기</strong><blockquote>
<p><a href="https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-3-%ED%98%84%EC%9E%AC-%EC%9C%84%EC%B9%98-%EB%B6%88%EB%9F%AC%EC%98%A4%EA%B8%B0">Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #3 현재 위치 불러오기</a>에서 만들어보았던 <code>useGeolocation</code>훅을 여기에 활용할 수 있다.</p>
<pre><code class="language-tsx">import { useState, useEffect } from &#39;react&#39;;

</code></pre>
</blockquote>
const useGeolocation = () =&gt; {
const [location, setLocation] = useState&lt;Latlng | null&gt;(null); // 현재 위치를 저장할 상태<blockquote>
</blockquote>
return { location };
};<blockquote>
</blockquote>
export default useGeolocation;<blockquote>
<pre><code></code></pre></blockquote>
</li>
</ul>
<pre><code class="language-tsx">// import { useMap } from &#39;../shared/contexts/Map&#39;;
import useGeolocation from &#39;./useGeolocation&#39;;

const useSearchPlaces = () =&gt; {
 // const mapContext = useMap();
 const { location: myLocation } = useGeolocation();

 // 장소 검색 함수
 const searchPlaces = (keyword: string) =&gt; {
   if (keyword !== &#39;&#39;) {
     // const { mapData } = mapContext!;
     // const location = mapData?.getCenter();
     const places = new kakao.maps.services.Places();
     places.keywordSearch(keyword, searchPlacesCB, {
       location: new kakao.maps.LatLng(
         myLocation?.latitude as number,
         myLocation?.longitude as number,
       ),
     });
   }
 };

 // keywordSearch 콜백 함수
 // ...

 return { searchPlaces };
};

export default useSearchPlaces;
</code></pre>
<p>&#39;현재 내 위치에서 재검색&#39; 과 같은 기능을 넣고 싶다면 이 로직을 활용하면 된다.  </p>
<ul>
<li><strong>지도의 <code>영역</code>을 기준으로 검색하기</strong>
영역은  <code>places.keywordSearch</code>메서드의 option 중 <code>bounds</code>을 지정해 주면 된다.</li>
</ul>
<pre><code class="language-tsx">import { useMap } from &#39;../shared/contexts/Map&#39;;

const useSearchPlaces = () =&gt; {
   const mapContext = useMap();
   // 장소 검색 함수
   const searchPlaces = (keyword: string) =&gt; {
     if (keyword !== &#39;&#39;) {
       const { mapData } = mapContext!;
       const bounds = mapData?.getBounds();
       // const location = mapData?.getCenter();
       const places = new kakao.maps.services.Places();
       places.keywordSearch(keyword, searchPlacesCB, {
         bounds,
       });
     }
   };

 // keywordSearch 콜백 함수
 // ...

 return { searchPlaces };
};

export default useSearchPlaces;
</code></pre>
<p><img src="https://velog.velcdn.com/images/day_1226/post/772e7860-ca4a-472b-bf7a-b05ea3ff6b02/image.png" alt=""></p>
<p>지도 내 표시된 관련 장소만 불러오는 것을 확인할 수 있다.</p>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #2.5 지도가 안뜨는 문제🥲]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2.5-%EC%A7%80%EB%8F%84%EA%B0%80-%EC%95%88%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2.5-%EC%A7%80%EB%8F%84%EA%B0%80-%EC%95%88%EB%9C%A8%EB%8A%94-%EB%AC%B8%EC%A0%9C</guid>
            <pubDate>Sun, 11 Aug 2024 12:43:09 GMT</pubDate>
            <description><![CDATA[<p>분명 APP Key도 잘 받아왔고, Map을 띄우는 코드도 잘 작성한 것 같은데 에러가 난다..?
이전에 포스팅으로 다뤘던 에러 사항에 더해 추가의 경우를 모아 새로 작성해 보려 한다.</p>
<h2 id="⚠️-문제-상황1---typeerror">⚠️ 문제 상황1 - TypeError</h2>
<hr>
<blockquote>
<p><strong>TypeError: Cannot read properties of null (reading &#39;currentStyle&#39;)</strong> </aside></p>
</blockquote>
<p>App key를 받아 <code>layout.tsx</code>에 <code>Script</code> 태그를 잘 적용했고, Map 객체를 불러오는 코드도 잘 작성했는데 위와 같은 오류가 만날 때이다.</p>
<p><strong>&#39;#2 지도 띄우기&#39;</strong> 포스팅과 같이 Kakao Maps API를 활용한 지도 컴포넌트를 구현했고, MapProvider로 지도가 필요한 페이지마다 사용할 수 있도록 구현한 상태이다.</p>
<pre><code class="language-ts">const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {

  const mapRef = useRef&lt;HTMLDivElement&gt;(null);

  //...

  useEffect(() =&gt; {
    const { kakao } = window;

    kakao?.maps.load(() =&gt; {
      const mapElement = mapRef.current;
      // 컴포넌트 mount 후 DOM 요소에 접근
      if (mapElement) {
        const options = {
          center: new kakao.maps.LatLng(
            location?.latitude as number,
            location?.longitude as number,
          ),
          level: 3,
          smooth: true,
          tileAnimation: false,
        };
        let zoomControl = new kakao.maps.ZoomControl();
        // 지도 생성
        const kakaoMap = new kakao.maps.Map(mapElement, options);
        kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
          // 지도 중심좌표
          const latlng = kakaoMap.getCenter();
          setCurrLocation(latlng);
        });
        kakaoMap.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);
        setMap(kakaoMap);
      }
    });
  }, [location?.latitude, location?.longitude]);

  //...

  return (
    &lt;&gt;
      {location &amp;&amp; (
        &lt;MapContext.Provider value={values}&gt;
          &lt;div className=&#39;flex h-full w-full&#39;&gt;
            {children}
            &lt;div id=&#39;map&#39; ref={mapRef} className=&#39;h-full w-full&#39;&gt;&lt;/div&gt;
          &lt;/div&gt;
        &lt;/MapContext.Provider&gt;
      )}
    &lt;/&gt;
  );
};

export default MapProvider;</code></pre>
<p>이 코드를 사용하여 지도를 렌더링하던 중, MapProvider가 포함된 현재 페이지에서 새로고침 또는 다른 MapProvider가 포함된 페이지로 이동할 때 위와 같은 TypeError 에러가 발생했다.</p>
<h3 id="🧐-원인">🧐 원인</h3>
<hr>
<p>React 컴포넌트의 생명주기를 보면, 컴포넌트가 처음 마운트될 때 useEffect 훅이 실행되는데, 이 때 <code>mapRef.current</code>가 <code>null</code>일 수 있다. </p>
<p>즉, DOM 요소가 아직 마운트(생성)되지 않은 상태에서 해당 요소에 접근하려고 했기 때문에 에러가 발생한 것이다.</p>
<p><a href="https://devtalk.kakao.com/t/api-currentstyle-null/35781">카카오 Dev 페이지 답변 - 카카오 api 사용 시 currentStyle null값 에러</a>에 의하면, 여기서는 <code>&lt;div id = &#39;map&#39;&gt;</code>가 생성되지 않은 상태에서 <code>#map</code>을 참조하려고 하기 때문에 발생한다.</p>
<h3 id="✅-해결">✅ 해결</h3>
<hr>
<p>해결을 위해서는 DOM 요소가 마운트된 후에 지도 생성 로직을 실행해야 힌다. </p>
<p>이를 위해 useEffect 내부에서 <code>mapRef.current</code>가 <code>null</code>이 아닌지 확인하는 조건문을 추가한다.</p>
<pre><code class="language-ts">  useEffect(() =&gt; {
    const { kakao } = window;

    kakao?.maps.load(() =&gt; {
      const mapElement = mapRef.current;
      // 컴포넌트 mount 후 DOM 요소에 접근
      if (mapElement) {
        const options = {
          center: new kakao.maps.LatLng(
            location?.latitude as number,
            location?.longitude as number,
          ),
          level: 3,
          smooth: true,
          tileAnimation: false,
        };
        let zoomControl = new kakao.maps.ZoomControl();
        // 지도 생성
        const kakaoMap = new kakao.maps.Map(mapElement, options);
        kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
          // 지도 중심좌표
          const latlng = kakaoMap.getCenter();
          setCurrLocation(latlng);
        });
        kakaoMap.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);
        setMap(kakaoMap);
      }
    });
  }, [location?.latitude, location?.longitude]);</code></pre>
<br>

<h2 id="⚠️-문제-상황2---흰-화면만-보여요">⚠️ 문제 상황2 - 흰 화면만 보여요</h2>
<hr>
<p><img src="https://velog.velcdn.com/images/day_1226/post/7ff50e7e-fc25-4af4-9585-6067b6bf077e/image.png" alt=""> 분명 지도가 띄워져야 할 자리에 흰 화면만 보인다..? 
console 창에 에러도 안뜨는 상황이 당황스럽다면...</p>
<h3 id="🧐-원인-1">🧐 원인</h3>
<hr>
<p>해당 문제가 </p>
<blockquote>
<ul>
<li>배포 시</li>
</ul>
</blockquote>
<ul>
<li>혹은 로컬에서 <code>localhost:3000</code>이 아닌 다른 Port번호로 실행했을 때(<code>3001</code>, <code>3002</code>)</li>
</ul>
<p>발생한다면 원인은 해당 프로젝트를 실행한 도메인에 있다.</p>
<p>이는 Kakao Developers에서 처음 App key를 발급하기 위해 생성했던 어플리케이션에 등록된 도메인과 다르기 때문이다.</p>
<p>도메인을 추가로 등록해 주어야 한다.</p>
<h3 id="✅-해결-1">✅ 해결</h3>
<hr>
<p><img src="https://velog.velcdn.com/images/day_1226/post/86bd557c-ed5b-40a1-9a78-bfa2e52005a2/image.png" alt=""><a href="https://developers.kakao.com/console/app/1080119/config/platform">KaKao Developers</a> 사이트에서 <code>내 애플리케이션 &gt; 앱 설정 &gt; 플랫폼</code>에서 <code>Web &gt; 수정</code> 버튼을 눌러 수정한다.
<img src="https://velog.velcdn.com/images/day_1226/post/1a7d8673-f86e-40fe-8292-4c838c866e85/image.png" alt=""></p>
<p>위와 같이 여러개의 도메인을 줄바꿈으로 추가 작성해 준다. 
나는 포트번호가 다른 경우, 그리고 배포 URL을 함께 등록해 주었다.</p>
<blockquote>
<p>⚠️ <code>http://localhost:3000/</code> (x) 
-&gt; 도메인 끝에 <code>/</code>(슬래시)는 붙이면 안됨</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ef96aae8-b73d-423d-9e68-e029b42f18d7/image.png" alt=""></p>
<p>해결!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #3 현재 위치 불러오기]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-3-%ED%98%84%EC%9E%AC-%EC%9C%84%EC%B9%98-%EB%B6%88%EB%9F%AC%EC%98%A4%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-3-%ED%98%84%EC%9E%AC-%EC%9C%84%EC%B9%98-%EB%B6%88%EB%9F%AC%EC%98%A4%EA%B8%B0</guid>
            <pubDate>Sun, 28 Jul 2024 14:56:35 GMT</pubDate>
            <description><![CDATA[<h1 id="현재-위치-불러오기">현재 위치 불러오기</h1>
<p>이전에 임의로 location을 설정했다면 이번에는 처음 페이지 접속/실행 시 현재 위치를 기준으로 지도를 렌더링하는 훅을 구현해 보자.</p>
<h2 id="usegeolocation-훅-작성하기"><code>useGeolocation</code> 훅 작성하기</h2>
<p><code>useGeolocation.ts</code>라는 파일을 생성 한다. </p>
<h3 id="11-상태-초기화">1.1 상태 초기화</h3>
<pre><code class="language-tsx">import { useState, useEffect } from &#39;react&#39;;
import { Latlng } from &#39;../shared/types/map&#39;;

const useGeolocation = () =&gt; {
  const [location, setLocation] = useState&lt;Latlng | null&gt;(null); // 현재 위치를 저장할 상태


  return { location };
};

export default useGeolocation;
</code></pre>
<p>우선, <code>useState</code>를 사용하여 현재 위치 정보를 저장할 상태를 초기화한 다음 상태의 초기값은 null로 설정한다.</p>
<p>여기서 Latlng은 위도(latitude)와 경도(longitude)를 미리 설정한 타입이다.</p>
<pre><code class="language-ts">export interface Latlng {
  latitude: number;
  longitude: number;
}</code></pre>
<h3 id="12-위치-정보-요청">1.2 위치 정보 요청</h3>
<p>컴포넌트가 처음 렌더링될 때 위치 정보를 요청해야 하므로, useEffect를 사용하여 위치 정보 요청 로직을 실행한다.</p>
<pre><code class="language-ts">import { useState, useEffect } from &#39;react&#39;;
import { Latlng } from &#39;../shared/types/map&#39;;

const useGeolocation = () =&gt; {
  const [location, setLocation] = useState&lt;Latlng | null&gt;(null); // 현재 위치를 저장할 상태

  useEffect(() =&gt; {
    navigator.geolocation.getCurrentPosition(successHandler, errorHandler); // 성공시 successHandler, 실패시 errorHandler 함수가 실행된다.
  }, []);

  const successHandler = () =&gt; {};

  const errorHandler = () =&gt; {};

  return { location };
};

export default useGeolocation;</code></pre>
<p>컴포넌트가 마운트될 때 <code>usenavigator.geolocation.getCurrentPosition</code> 메서드를 통해 브라우저의 Geolocation API를 사용하여 현재 위치를 가져올 수 있으며, 메소드는 순서대로 성공, 실패 시의 콜백 함수 2개를 인자로 받는다.</p>
<h3 id="콜백-함수-작성">콜백 함수 작성</h3>
<pre><code class="language-tsx">import { useState, useEffect } from &#39;react&#39;;
import { Latlng } from &#39;../shared/types/map&#39;;

const useGeolocation = () =&gt; {
  const [location, setLocation] = useState&lt;Latlng | null&gt;(null); // 현재 위치를 저장할 상태

  useEffect(() =&gt; {
    navigator.geolocation.getCurrentPosition(successHandler, errorHandler); // 성공시 successHandler, 실패시 errorHandler 함수가 실행된다.
  }, []);

  const successHandler = (response: {
    coords: { latitude: number; longitude: number };
  }) =&gt; {
    const { latitude, longitude } = response.coords;
    setLocation({ latitude, longitude });
  };

  const errorHandler = (error: GeolocationPositionError) =&gt; {
    console.log(error);
  };

  return { location };
};

export default useGeolocation;
</code></pre>
<h4 id="successhandler">successHandler</h4>
<p>위치 정보 요청이 성공했을 때 호출되는 <code>successHandler</code> 함수는 <code>response</code> 객체를 인자로 받는다.
<code>response.coords</code>는 Geolocation API가 반환하는 객체로, 여기서 <code>latitude</code>와 <code>longitude</code>를 추출해 <code>setLocation</code>을 통해 상태를 업데이트한다.</p>
<h4 id="errorhandler">errorHandler</h4>
<p>위치 정보 요청이 실패했을 때 호출되는 <code>errorHandler</code> 함수는 <code>GeolocationPositionError</code> 타입의 error 객체를 인자로 받는다.</p>
<h3 id="적용">적용</h3>
<p>이전 2탄 지도 띄우기 포스트 글 작성 시 Map 객체 생성 시 임의의 위치값을 넣었었는데, 
이번에는 useGeoLocation을 사용해 현재 위치 값을 넣어 Map 객체를 생성해 보자.</p>
<pre><code class="language-tsx">import useGeolocation from &#39;@/app/_hooks/useGeolocation&#39;;

//...

const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
  const { location } = useGeolocation(); // 현재 위치 값 불러오기

  const mapRef = useRef&lt;HTMLDivElement&gt;(null);
  const [map, setMap] = useState&lt;kakao.maps.Map | null&gt;(null);
  const [markerClusterer, setMarkerClusterer] =
    useState&lt;kakao.maps.MarkerClusterer | null&gt;(null);
  const [overlays, setOverlays] = useState&lt;kakao.maps.CustomOverlay[]&gt;([]);
  const [prevKeyword, setPrevKeyword] = useState&lt;string[]&gt;([]);
  const [places, setPlaces] = useState&lt;IPlace[]&gt;([]);
  const [prevLocation, setPrevLocation] = useState&lt;kakao.maps.LatLng | null&gt;(
    null,
  );
  const [currLocation, setCurrLocation] = useState&lt;kakao.maps.LatLng | null&gt;(
    null,
  );

  useEffect(() =&gt; {
    const { kakao } = window;

    kakao?.maps.load(() =&gt; {
      const mapElement = mapRef.current;
      // 컴포넌트 mount 후 DOM 요소에 접근
      if (mapElement) {
        const options = {
          center: new kakao.maps.LatLng(
            location?.latitude as number,
            location?.longitude as number, // 현재 경도, 위도 적용
          ),
          level: 3,
          smooth: true,
          tileAnimation: false,
        };
        // 지도 생성
        const kakaoMap = new kakao.maps.Map(mapElement, options);

        // 현재 중심좌표 값 갱신
        kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
          const latlng = kakaoMap.getCenter();
          setCurrLocation(latlng);
        });

        setMap(kakaoMap);
      }
    });
  }, [location?.latitude, location?.longitude]);
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #2 지도 띄우기]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2-%EC%A7%80%EB%8F%84-%EB%9D%84%EC%9A%B0%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-Kakao-%EC%A7%80%EB%8F%84-API-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-2-%EC%A7%80%EB%8F%84-%EB%9D%84%EC%9A%B0%EA%B8%B0</guid>
            <pubDate>Fri, 19 Jul 2024 07:38:46 GMT</pubDate>
            <description><![CDATA[<p>1탄에서 받아온 <strong>App Key</strong>를 이용해 Next.js + TS 프로젝트에 Kakao 지도 띄워보기!</p>
<p>Next.js에서 KaKao 지도를 띄우기 위한 방법에는
기본적으로 Map 컴포넌트를 만들어 활용할 수 있지만, 
나는 다음과 같은 이유로 <strong>Provider</strong>로 만들어 지도를 띄우기로 했다.</p>
<blockquote>
<ul>
<li>Provider를 통해 지도를 쉽게 초기화하고 관리</li>
</ul>
</blockquote>
<ul>
<li>필요한 페이지에만 지도를 불러오기</li>
<li>useMap 훅으로 만들고 Context API를 통해 지도 객체, 마커 클러스터, 오버레이, 위치 등의 상태를 전역적으로 관리하기</li>
<li>상태 관리와 로직을 분리해 자식 컴포넌트들이 필요한 상태에 쉽게 접근하고 수정 가능</li>
</ul>
<p>그리고 이를 위한 설정 방법을 다룰 예정이다.</p>
<hr>
<h1 id="🗂️-프로젝트-환경">🗂️ 프로젝트 환경</h1>
<pre><code>npx create-next-app@latest (프로젝트 명) --typescript
cd (프로젝트 명)</code></pre><p>현재 진행 중인 프로젝트는 
위와 같이 Next.js +TS의 프로젝트 환경이며 app router 방식을 사용하고 있다.
(+ Tailwind CSS)</p>
<hr>
<h1 id="🗂️-환경변수-설정">🗂️ 환경변수 설정</h1>
<p>발급한 App Key를 <code>.env</code>파일에 저장한다.
<img src="https://velog.velcdn.com/images/day_1226/post/0d19039e-3d17-439e-99d8-fa4af5f7a443/image.png" alt=""></p>
<hr>
<h1 id="🗂️-applayouttsx">🗂️ <code>app/layout.tsx</code></h1>
<p>layout.tsx 파일을 수정하여 Kakao 지도 스크립트를 로드한다.</p>
<pre><code class="language-tsx">import type { Metadata } from &#39;next&#39;;
import Script from &#39;next/script&#39;;

import { Noto_Sans_KR } from &#39;next/font/google&#39;;

import &#39;../styles/globals.css&#39;;

export const metadata: Metadata = {
  title: &#39;나의 프로젝트&#39;,
  description: &#39;나의 프로젝트 - my project&#39;,
};

const font = Noto_Sans_KR({
  subsets: [&#39;latin&#39;],
});
export default async function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&#39;en&#39;&gt;
      &lt;body className={font.className}&gt;
        &lt;Script
          async
          type=&#39;text/javascript&#39;
          src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.KAKAO_JS_KEY}&amp;libraries=services,clusterer&amp;autoload=false`}
        &gt;&lt;/Script&gt;
           {children}
      &lt;/body&gt;
    &lt;/html&gt;
  );
}
</code></pre>
<blockquote>
<p>가이드에 따르면 스크립트 태그는 반드시 <strong>실행 코드보다 먼저 선언되어야 한다</strong>고 되어 있다.
그렇기 때문에 다른 컴포넌트들이 렌더링 되는 <code>children</code>보다 위쪽에 배치하자.</p>
</blockquote>
<ul>
<li><p>기본적으로 <code>//dapi.kakao.com/v2/maps/sdk.js?appkey=발급받은 APP KEY</code>를 삽입</p>
</li>
<li><p><code>libraries</code> - 라이브러리 불러오기</p>
<blockquote>
<p>Kakao 지도 API 는 아래와 같이 지도와 함께 사용할 수 있는 라이브러리를 지원하고 있다.</p>
</blockquote>
<ul>
<li><code>clusterer</code>: 마커를 클러스터링 할 수 있는 클러스터러 라이브러리</li>
<li><code>services</code>: 장소 검색 과 주소-좌표 변환 을 할 수 있는 services 라이브러리</li>
<li><code>drawing</code>: 지도 위에 마커와 그래픽스 객체를 쉽게 그릴 수 있게 그리기 모드를 지원</li>
</ul>
<p>(프로젝트에서 <strong>클러스터러</strong>와 <strong>장소 검색</strong> 기능을 사용하기 떄문에 <code>services</code>, <code>clusterer</code>를 불러왔다.)</p>
</li>
<li><p><code>autoload</code> - Kakao 지도 API 스크립트를 로드할 때 자동으로 지도를 초기화할지 여부</p>
<blockquote>
</blockquote>
<ul>
<li><code>autoload=true</code>(기본값) - Kakao 지도 API 스크립트가 로드되면 자동으로 지도 관련 리소스를 초기화</li>
<li><code>autoload=false</code> - 스크립트가 로드된 후, <code>kakao.maps.load()</code> 함수를 호출해야만 지도 관련 리소스가 초기화됨
<code>false</code>로 설정 시 스크립트가 로드된 후 지도를 초기화하는 시점을 제어할 수 있고, 필요하지 않을 때에도 지도가 초기화 되는 것을 막을 수 있다.</li>
</ul>
<p>(Provider를 통해 <code>kakao.maps.load()</code>로 지도를 불러올 예정이기 때문에 <code>false</code>로 설정해 두었다.)</p>
</li>
</ul>
<hr>
<h1 id="🛠️-mapprovider-만들기">🛠️ MapProvider 만들기</h1>
<p>MapProvider 파일을 생성한다.
나의 경우 <code>app\shared\contexts\Map.tsx</code>와 같은 파일 경로로 생성해 주었다.</p>
<h2 id="📍-1-context-생성">📍 1. Context 생성</h2>
<pre><code class="language-tsx">// Map.tsx

&#39;use client&#39;;

// ...import

interface IMapContextValue {
  mapData: kakao.maps.Map | null;
  // 아래는 그 외 상태 관리가 필요한 데이터들을 추가한 것
  markerClusterer: kakao.maps.MarkerClusterer | null;
  setMarkerClusterer: (markers: kakao.maps.MarkerClusterer | null) =&gt; void;
  overlays: kakao.maps.CustomOverlay[];
  setOverlays: (markers: kakao.maps.CustomOverlay[]) =&gt; void;
  places: IPlace[];
  setPlaces: React.Dispatch&lt;React.SetStateAction&lt;IPlace[]&gt;&gt;;
  prevKeyword: string[];
  setPrevKeyword: React.Dispatch&lt;React.SetStateAction&lt;string[]&gt;&gt;;
  currLocation: kakao.maps.LatLng | null;
  setCurrLocation: React.Dispatch&lt;
    React.SetStateAction&lt;kakao.maps.LatLng | null&gt;
  &gt;;
  prevLocation: kakao.maps.LatLng | null;
  setPrevLocation: React.Dispatch&lt;
    React.SetStateAction&lt;kakao.maps.LatLng | null&gt;
  &gt;;
}

const MapContext = createContext&lt;IMapContextValue | null&gt;({
  mapData: null,
  // 아래는 그 외 상태 관리가 필요한 데이터들을 추가한 것
  markerClusterer: null,
  setMarkerClusterer: () =&gt; {},
  overlays: [],
  setOverlays: () =&gt; {},
  places: [],
  setPlaces: () =&gt; {},
  prevKeyword: [],
  setPrevKeyword: () =&gt; {},
  currLocation: null,
  setCurrLocation: () =&gt; {},
  prevLocation: null,
  setPrevLocation: () =&gt; {},
});</code></pre>
<p>먼저 지도의 상태와 관련 상태를 업데이트하는 역할의 컨텍스트 객체 <code>MapContext</code>를 만들어 준다. </p>
<ul>
<li>초기값으로는 모든 상태와 함수를 빈 값으로 설정한다.</li>
<li>처음에는 <code>mapData</code>만 담으면 되고, 그 외에는 위 코드와 같이 필요할 때마다 상태 관리가 필요한 데이터들을 추가하면 된다.</li>
</ul>
<p>Map 객체를 포함한 다른 지도 관련 데이터 type 정의 시에는 <code>kakao.maps.~</code> 와 같이 지정된 타입을 불러와 사용한다.</p>
<h2 id="📍-2-mapprovider-컴포넌트">📍 2. MapProvider 컴포넌트</h2>
<p>그 다음 <code>MapProvider</code> 컴포넌트를 만들어 주어야 한다.</p>
<h3 id="1-변수-생성">1) 변수 생성</h3>
<pre><code class="language-tsx">// Map.tsx

// ...MapContext 코드

interface MapProps {
  children?: React.ReactNode;
}

const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
  const location = { latitude: 37.5665, longitude: 126.9780 }; // 임의의 위도와 경도
</code></pre>
<p>먼저 지도를 불러올 때의 <strong>중심 위치</strong>를 설정한다. 
이해를 위해 임의의 좌표 값으로 설정해둔 상태이고, 프로젝트에서는 사용자의 현재 위치를 불러오는 훅을 만들어 사용하고 있다.</p>
<blockquote>
<p>(3탄에 사용자 현재 위치 불러오기 구현 방법을 다룰 예정!)</p>
</blockquote>
<pre><code class="language-tsx">// Map.tsx

const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
  const location = { latitude: 37.5665, longitude: 126.9780 }; // 임의의 위도와 경도  

  const mapRef = useRef&lt;HTMLDivElement&gt;(null);
  const [map, setMap] = useState&lt;kakao.maps.Map | null&gt;(null);
  const [markerClusterer, setMarkerClusterer] = useState&lt;kakao.maps.MarkerClusterer | null&gt;(null);
  const [overlays, setOverlays] = useState&lt;kakao.maps.CustomOverlay[]&gt;([]);
  const [prevKeyword, setPrevKeyword] = useState&lt;string[]&gt;([]);
  const [places, setPlaces] = useState&lt;IPlace[]&gt;([]);
  const [prevLocation, setPrevLocation] = useState&lt;kakao.maps.LatLng | null&gt;(null);
  const [currLocation, setCurrLocation] = useState&lt;kakao.maps.LatLng | null&gt;(null);

  //...

  return ();
};

export default MapProvider;</code></pre>
<ul>
<li>mapRef - <code>useRef</code>로 지도 DOM 요소를 참조하기 위함</li>
<li><code>useState</code> 훅을 사용해 지도 객체(Map), 그 외 마커 클러스터러, 오버레이 목록, 이전 검색어, 장소 목록, 이전 위치, 현재 위치 등의 상태를 관리하는 변수를 생성한다.</li>
</ul>
<h3 id="2-지도-초기화-및-설정">2) 지도 초기화 및 설정</h3>
<pre><code class="language-tsx">// Map.tsx

 const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
     // ...변수 세팅

  useEffect(() =&gt; {
    const { kakao } = window;

    kakao?.maps.load(() =&gt; {
      const mapElement = mapRef.current;
      // 컴포넌트 mount 후 DOM 요소에 접근
      if (mapElement) {
        const options = {
          center: new kakao.maps.LatLng(
            location?.latitude as number,
            location?.longitude as number,
          ),
          level: 3,
          smooth: true,
          tileAnimation: false,
        };
        // 지도 생성
        const kakaoMap = new kakao.maps.Map(mapElement, options);

        // 현재 중심좌표 값 갱신
        kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
          const latlng = kakaoMap.getCenter();
          setCurrLocation(latlng);
        });

        // 줌 컨트롤
        let zoomControl = new kakao.maps.ZoomControl();
        kakaoMap.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);
        setMap(kakaoMap);
      }
    });
  }, [location?.latitude, location?.longitude]);

      // ...

  return (

  );
};</code></pre>
<ul>
<li><p>Kakao Maps API 로드</p>
<pre><code class="language-tsx">const { kakao } = window;

kakao?.maps.load(() =&gt; {
  // 지도 초기화 및 설정 내용
});</code></pre>
<ul>
<li>window 객체에서 <code>kakao</code> 속성을 가져와 Kakao Maps API를 사용</li>
<li><code>kakao?.maps.load()</code>는 Kakao Maps API가 로드되었을 때 실행되는 콜백 함수</li>
</ul>
</li>
<li><p>지도 초기화</p>
<pre><code class="language-tsx">const mapElement = mapRef.current;
 if (mapElement) {
    const options = {
      center: new kakao.maps.LatLng(location?.latitude as number, location?.longitude as number),
      level: 3,
      smooth: true,
      tileAnimation: false,
    };
    const kakaoMap = new kakao.maps.Map(mapElement, options);

   //...
 }</code></pre>
<ul>
<li><code>mapElement</code>가 존재하는 경우, Kakao Maps API를 사용하여 지도를 생성한다.<blockquote>
<p>이전에 이 부분 관련 에러 핸들링으로 다뤘던 글 참고 
<a href="https://velog.io/@day_1226/React-%EC%B9%B4%EC%B9%B4%EC%98%A4-api-Cannot-read-properties-of-null-reading-currentStyle">⚛️React | 카카오맵 api - Cannot read properties of null (reading &#39;currentStyle&#39;)</a></p>
</blockquote>
</li>
<li><code>options</code><ul>
<li>(필수) <code>center</code> : 지도의 중심 좌표, kakao.maps.LatLng 객체를 사용해 위도와 경도 지정</li>
<li><code>level</code> : 지도의 확대 레벨 (1~10) (기본값: 3)</li>
<li><code>smooth</code> : 지도 줌/팬 애니메이션을 부드럽게 설정</li>
<li><code>tileAnimation</code> : 지도 타일 애니메이션 설정 여부 (기본값: true)<blockquote>
<p>그 외 옵션 참고 API Docs - Map<br><a href="https://apis.map.kakao.com/web/documentation/#Map">https://apis.map.kakao.com/web/documentation/#Map</a></p>
</blockquote>
</li>
</ul>
</li>
</ul>
</li>
<li><p>(그 외) 지도 이벤트 리스너 등록하기
여기 부터는 프로젝트를 위해 따로 추가한 기능이라 참고로 보면 좋을 것 같다.
API Docs를 보면 등록할 수 있는 다양한 이벤트가 존재하는데, </p>
<blockquote>
<p>API Docs - Map Events 
<a href="https://apis.map.kakao.com/web/documentation/#Map_Events">https://apis.map.kakao.com/web/documentation/#Map_Events</a></p>
</blockquote>
<p>프로젝트에서 지도를 움직일 때마다 &#39;현재 위치 기준 재검색&#39; 하는 기능을 구현하기 위해 
<code>dragend</code> 이벤트로 지도가 드래그 될 때마다(=지도의 위치가 움직일 때마다) 계속해서 현재 위치를 갱신해 저장하고 있다.</p>
<pre><code class="language-tsx">kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
  const latlng = kakaoMap.getCenter();
  setCurrLocation(latlng);
});</code></pre>
</li>
<li><p>(그 외) 줌 컨트롤 띄우기
<img src="https://velog.velcdn.com/images/day_1226/post/84701ef9-1def-4246-9861-cd1fb4a47b9f/image.png" alt="">지도를 불러올 때 아래 설정을 추가해 사진과 같은 줌 컨트롤을 띄울 수 있다.</p>
<pre><code class="language-tsx">let zoomControl = new kakao.maps.ZoomControl();
kakaoMap.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);</code></pre>
<ul>
<li><code>kakao.maps.ZoomControl</code> 객체를 생성</li>
<li><code>kakao.maps.ControlPosition.RIGHT</code> : 줌 컨트롤을 지도의 오른쪽에 배치<blockquote>
<p>그 외 컨트롤 배치 위치 참고 API Docs - ControlPosition
<a href="https://apis.map.kakao.com/web/documentation/#ControlPosition">https://apis.map.kakao.com/web/documentation/#ControlPosition</a> </p>
</blockquote>
</li>
</ul>
</li>
</ul>
<h3 id="3-메모이제이션">3) 메모이제이션</h3>
<pre><code class="language-tsx">// Map.tsx

const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
 // ...변수 세팅

 // ...useEffect 훅

  const values: IMapContextValue = useMemo(
    () =&gt; ({
      currLocation,
      setCurrLocation,
      prevLocation,
      setPrevLocation,
      mapData: map,
      markerClusterer,
      setMarkerClusterer,
      overlays,
      setOverlays,
      places,
      setPlaces,
      prevKeyword,
      setPrevKeyword,
    }),
    [
      currLocation,
      prevLocation,
      map,
      markerClusterer,
      overlays,
      places,
      prevKeyword,
    ],
  );

  return ();
};

export default MapProvider;

export const useMap = () =&gt; useContext(MapContext);
</code></pre>
<p><code>useMemo</code>로 컨텍스트에 제공할 값을 메모이제이션한다. 
의존성 배열에 있는 값들이 변경될 때만 재계산된다.</p>
<h3 id="4--컨텍스트-프로바이더와-지도-렌더링">4)  컨텍스트 프로바이더와 지도 렌더링</h3>
<pre><code class="language-tsx">return (
    &lt;&gt;
      {location &amp;&amp; (
        &lt;MapContext.Provider value={values}&gt;
          &lt;div className=&#39;flex h-full w-full&#39;&gt;
            {children}
            &lt;div id=&#39;map&#39; ref={mapRef} className=&#39;h-full w-full&#39;&gt;&lt;/div&gt;
          &lt;/div&gt;
        &lt;/MapContext.Provider&gt;
      )}
    &lt;/&gt;
  );
};</code></pre>
<ul>
<li>위치 정보가 있을 때만 MapContext.Provider를 렌더링한다.</li>
<li><code>values</code> 객체를 컨텍스트 값으로 전달한다.</li>
<li><code>children</code>을 렌더링하고</li>
<li><code>&lt;div id=&#39;map&#39; ref={mapRef} className=&#39;h-full w-full&#39;&gt;&lt;/div&gt;</code> 지도가 표시되는 영역</li>
</ul>
<h3 id="5-usemap-훅-정의">5) useMap 훅 정의</h3>
<p>마지막으로 useMap 훅을 정의하여 컨텍스트를 쉽게 사용할 수 있도록 해주면 MapProvider 설정 끝!</p>
<pre><code class="language-tsx">export const useMap = () =&gt; useContext(MapContext);</code></pre>
<h3 id="mapprovider-전체-코드">MapProvider 전체 코드</h3>
<pre><code class="language-tsx">&#39;use client&#39;;

import React, {
  createContext,
  useContext,
  useEffect,
  useMemo,
  useRef,
  useState,
} from &#39;react&#39;;
import { IPlace } from &#39;@/app/shared/types/map&#39;;

interface MapProps {
  children?: React.ReactNode;
}

interface IMapContextValue {
  mapData: kakao.maps.Map | null;
  // 아래는 그 외 상태 관리가 필요한 데이터들을 추가한 것
  markerClusterer: kakao.maps.MarkerClusterer | null;
  setMarkerClusterer: (markers: kakao.maps.MarkerClusterer | null) =&gt; void;
  overlays: kakao.maps.CustomOverlay[];
  setOverlays: (markers: kakao.maps.CustomOverlay[]) =&gt; void;
  places: IPlace[];
  setPlaces: React.Dispatch&lt;React.SetStateAction&lt;IPlace[]&gt;&gt;;
  prevKeyword: string[];
  setPrevKeyword: React.Dispatch&lt;React.SetStateAction&lt;string[]&gt;&gt;;
  currLocation: kakao.maps.LatLng | null;
  setCurrLocation: React.Dispatch&lt;
    React.SetStateAction&lt;kakao.maps.LatLng | null&gt;
  &gt;;
  prevLocation: kakao.maps.LatLng | null;
  setPrevLocation: React.Dispatch&lt;
    React.SetStateAction&lt;kakao.maps.LatLng | null&gt;
  &gt;;
}

const MapContext = createContext&lt;IMapContextValue | null&gt;({
  mapData: null,
  // 아래는 그 외 상태 관리가 필요한 데이터들을 추가한 것
  markerClusterer: null,
  setMarkerClusterer: () =&gt; {},
  overlays: [],
  setOverlays: () =&gt; {},
  places: [],
  setPlaces: () =&gt; {},
  prevKeyword: [],
  setPrevKeyword: () =&gt; {},
  currLocation: null,
  setCurrLocation: () =&gt; {},
  prevLocation: null,
  setPrevLocation: () =&gt; {},
});

const MapProvider: React.FC&lt;MapProps&gt; = ({ children }) =&gt; {
  const { location } = useGeolocation();

  const mapRef = useRef&lt;HTMLDivElement&gt;(null);
  const [map, setMap] = useState&lt;kakao.maps.Map | null&gt;(null);
  const [markerClusterer, setMarkerClusterer] =
    useState&lt;kakao.maps.MarkerClusterer | null&gt;(null);
  const [overlays, setOverlays] = useState&lt;kakao.maps.CustomOverlay[]&gt;([]);
  const [prevKeyword, setPrevKeyword] = useState&lt;string[]&gt;([]);
  const [places, setPlaces] = useState&lt;IPlace[]&gt;([]);
  const [prevLocation, setPrevLocation] = useState&lt;kakao.maps.LatLng | null&gt;(
    null,
  );
  const [currLocation, setCurrLocation] = useState&lt;kakao.maps.LatLng | null&gt;(
    null,
  );

  useEffect(() =&gt; {
    const { kakao } = window;

    kakao?.maps.load(() =&gt; {
      const mapElement = mapRef.current;
      // 컴포넌트 mount 후 DOM 요소에 접근
      if (mapElement) {
        const options = {
          center: new kakao.maps.LatLng(
            location?.latitude as number,
            location?.longitude as number,
          ),
          level: 3,
          smooth: true,
          tileAnimation: false,
        };
        // 지도 생성
        const kakaoMap = new kakao.maps.Map(mapElement, options);

        // 현재 중심좌표 값 갱신
        kakao.maps.event.addListener(kakaoMap, &#39;dragend&#39;, function () {
          const latlng = kakaoMap.getCenter();
          setCurrLocation(latlng);
        });

        // 줌 컨트롤
        let zoomControl = new kakao.maps.ZoomControl();
        kakaoMap.addControl(zoomControl, kakao.maps.ControlPosition.RIGHT);
        setMap(kakaoMap);
      }
    });
  }, [location?.latitude, location?.longitude]);

  const values: IMapContextValue = useMemo(
    () =&gt; ({
      currLocation,
      setCurrLocation,
      prevLocation,
      setPrevLocation,
      mapData: map,
      markerClusterer,
      setMarkerClusterer,
      overlays,
      setOverlays,
      places,
      setPlaces,
      prevKeyword,
      setPrevKeyword,
    }),
    [
      currLocation,
      prevLocation,
      map,
      markerClusterer,
      overlays,
      places,
      prevKeyword,
    ],
  );

  return (
    &lt;&gt;
      {location &amp;&amp; (
        &lt;MapContext.Provider value={values}&gt;
          &lt;div className=&#39;flex h-full w-full&#39;&gt;
            {children}
            &lt;div id=&#39;map&#39; ref={mapRef} className=&#39;h-full w-full&#39;&gt;&lt;/div&gt;
          &lt;/div&gt;
        &lt;/MapContext.Provider&gt;
      )}
    &lt;/&gt;
  );
};

export default MapProvider;

export const useMap = () =&gt; useContext(MapContext);
</code></pre>
<h1 id="🗺️-지도-띄우기">🗺️ 지도 띄우기</h1>
<p>자 이제 길었던 MapProvider 설정 얘기는 끝...! 
지도를 띄우는 방법은 간단하다.</p>
<p>MapProvider를 사용할 때
페이지마다 전역으로 띄우는 방법과 일부 페이지에서만 띄우는 방법이 있다.</p>
<h2 id="전역모든-페이지">전역(모든 페이지)</h2>
<p>처음 스크립트 태그를 설정했던 <code>app/layout.tsx</code> 페이지에서 
children에 <code>MapProvider</code>를 감싸주면 모든 페이지에 지도를 띄울 수 있다.</p>
<pre><code class="language-tsx">//app/layout.tsx
  import type { Metadata } from &#39;next&#39;;
  import Script from &#39;next/script&#39;;

  import { Noto_Sans_KR } from &#39;next/font/google&#39;;

  import &#39;../styles/globals.css&#39;;

  import MapProvider from &#39;./shared/contexts/Map&#39;;

  export const metadata: Metadata = {
    title: &#39;나의 산책 일기&#39;,
    description: &#39;나의 산책 일기 - my walk log&#39;,
  };

  const font = Noto_Sans_KR({
    subsets: [&#39;latin&#39;],
  });
  export default async function RootLayout({
    children,
  }: Readonly&lt;{
    children: React.ReactNode;
  }&gt;) {
    return (
      &lt;html lang=&#39;en&#39;&gt;
        &lt;body className={font.className}&gt;
          &lt;Script
            async
            type=&#39;text/javascript&#39;
            src={`//dapi.kakao.com/v2/maps/sdk.js?appkey=${process.env.KAKAO_JS_KEY}&amp;libraries=services,clusterer&amp;autoload=false`}
          &gt;&lt;/Script&gt;
          &lt;MapProvider&gt;{children}&lt;/MapProvider&gt;
        &lt;/body&gt;
      &lt;/html&gt;
    );
  }
</code></pre>
<h2 id="일부-페이지만-띄우기">일부 페이지만 띄우기</h2>
<p>만약 페이지별로 다른 레이아웃을 지정해 사용하고 있다면
지도를 사용할 페이지의 layout 컴포넌트에 MapProvider를 불러온다.</p>
<ul>
<li><p>예시 <code>app\(search)\layout.tsx</code></p>
<pre><code class="language-tsx">&#39;use client&#39;;

import MapProvider from &#39;@/app/shared/contexts/Map&#39;;

export interface layoutProps {
  children: React.ReactNode;
}

const SearchLayout = ({ children }: layoutProps) =&gt; {
  return (
    &lt;MapProvider&gt;
      {children}
    &lt;/MapProvider&gt;
  );
};

export default SearchLayout;</code></pre>
</li>
</ul>
<h2 id="container-사용하기">Container 사용하기</h2>
<p>  지도를 일부 페이지에서만 불러오되 Header와 같은 다른 요소들도 항상 같이 불러오려면? 
  Container 컴포넌트를 만들고 이렇게 MapProvider를 활용할 수 있다.</p>
<pre><code class="language-tsx">// Container.tsx

  &#39;use client&#39;;

  import { ReactNode } from &#39;react&#39;;

  import Header from &#39;./Header&#39;;
  import MapProvider from &#39;@/app/shared/contexts/Map&#39;;

  interface ContainerProps {
    children: ReactNode;
  }

  const Container = ({ children }: ContainerProps) =&gt; {
    return (
      &lt;&gt;
        &lt;MapProvider&gt;
          &lt;div className=&#39;z-10 flex bg-white shadow-2xl&#39;&gt;
            &lt;Header /&gt;
            &lt;div className=&#39;relative flex w-80 min-w-80 flex-col gap-4 bg-white&#39;&gt;
              {children}
            &lt;/div&gt;
          &lt;/div&gt;
        &lt;/MapProvider&gt;
      &lt;/&gt;
    );
  };

  export default Container;</code></pre>
<pre><code class="language-tsx">// `app\(search)\layout.tsx`

  &#39;use client&#39;;

  import Container from &#39;@/app/_component/common/Container&#39;;

  export interface layoutProps {
    children: React.ReactNode;
  }

  const SearchLayout = ({ children }: layoutProps) =&gt; {
    return (
      &lt;Container&gt;
        {children}
      &lt;/Container&gt;
    );
  };

  export default SearchLayout;</code></pre>
<h1 id="🔍-결과">🔍 결과</h1>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ca15a962-83d5-4b04-80dd-707290c6e014/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js + TS | Kakao 지도 API 프로젝트에 사용하기 #1 App key 발급하기]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-kakao-map-api-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-1-App-key-%EB%B0%9C%EA%B8%89%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-kakao-map-api-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0-1-App-key-%EB%B0%9C%EA%B8%89%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 12 Jul 2024 06:46:17 GMT</pubDate>
            <description><![CDATA[<p>현재 개발 중인 Next.js + TS 프로젝트에서 Kakao 지도 API를 사용해 지도를 불러와 구현 중에 있는데,
구현 단계를 기록해 두려 시리즈로 만들어 보려고 한다!</p>
<hr>
<h1 id="kakao-지도-api">Kakao 지도 API</h1>
<p><a href="https://apis.map.kakao.com/">Kakao 지도 API</a>
<img src="https://velog.velcdn.com/images/day_1226/post/f59eda15-d7d4-4b19-bbd7-433bb6fd10e7/image.png" alt="">
Kakao 지도 API는 웹사이트와 모바일 애플리케이션에서
지도를 이용한 서비스를 제작할 수 있도록 카카오에서 지원하는 오픈 API이다.
<img src="https://velog.velcdn.com/images/day_1226/post/e0d4c17c-943a-4764-aa52-166e92e42005/image.png" alt=""></p>
<p>가이드와 샘플, 문서가 자세하게 나와있고, Wizard를 통해 지도를 커스텀해볼 수도 있다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/1732b131-d906-4e78-9c96-d68f0f11cc33/image.png" alt=""></p>
<p>(사실 가이드에 key 발급부터 지도 생성까지 가이드에 잘 나와 있어 이를 참고해도 좋다.)</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/c1dab011-da80-4b67-a375-057d2b36c4e7/image.png" alt=""></p>
<p>기본 언어는 JavaScript 기준이다.</p>
<blockquote>
<p>TypeScript로의 코드 가이드는 나와있지 않아서 TypeScript로 구현 시 구글링이 좀 필요하다.
앞으로 TypeScript 기준으로 구현한 내용을 담을 예정이니 참고하면 좋을 것 같다.</p>
</blockquote>
<hr>
<h1 id="app-key-발급하기">App key 발급하기</h1>
<p>내 프로젝트에 지도를 불러오려면 먼저 App key가 필요하다.</p>
<ol>
<li><a href="https://developers.kakao.com">카카오 개발자사이트</a> 에 접속해 로그인하기
<img src="https://velog.velcdn.com/images/day_1226/post/151d166d-9ab3-4641-9d5b-9028782bf4b9/image.png" alt="">
갖고 있는 카카오 계정으로 로그인할 수 있다.</li>
<li>로그인 후 상단의 [내 어플리케이션] 메뉴로 들어가 애플리케이션을 추가한다.
<img src="https://velog.velcdn.com/images/day_1226/post/958cfa5a-55d6-4977-acda-8a571791961e/image.png" alt="">
<img src="https://velog.velcdn.com/images/day_1226/post/e4ae125a-8144-4492-8803-f07af1d21f2c/image.png" alt=""></li>
<li>애플리케이션을 누르면 설정 페이지로 이동되고, [앱 키] 메뉴에 들어간다.
<img src="https://velog.velcdn.com/images/day_1226/post/c57612b4-9c2f-42e0-8850-1211ad2ff28f/image.png" alt="">플랫폼 별로 앱키가 나와 있고, &#39;JavaScript 키&#39;를 복사해 프로젝트에 가져오면 발급 끝!</li>
</ol>
<hr>
<h1 id="-react-kakao-maps-sdk-라이브러리">+) react-kakao-maps-sdk 라이브러리</h1>
<p><a href="https://react-kakao-maps-sdk.jaeseokim.dev/">react-kakao-maps-sdk 라이브러리</a>
<img src="https://velog.velcdn.com/images/day_1226/post/b53ac295-ed3c-4c76-ae27-e752c3f6d035/image.png" alt=""></p>
<p>기존 JavaScript 기반의 Kakao 지도 API(Web)를 react에 맞게 포팅한 라이브러리가 있어 같이 소개하고 싶다.
Map, MapMarker, MapInfoWindow 등 이미 만들어진 컴포넌트를 통해 react 환경 안에서 좀더 쉽게 구현할 수 있는 장점이 있고, TypeScript도 제공한다!</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/5cfd9222-b518-4959-acd2-044cc9810acf/image.png" alt=""></p>
<p>가이드도 Kakao 지도 API 사이트와 같이 자세하게 설명되어 있고, Hook으로 사용하는 법, Next.js에서의 설정 방법도 나와있다.</p>
<blockquote>
<p>Next.js에서도 사용 가능하나, <strong>tailwind css</strong> 사용은 어려운 단점이 있어 참고 바란다.  <br/></p>
</blockquote>
<ol>
<li>컴포넌트에 <code>className</code> props 사용 불가<img src="https://velog.velcdn.com/images/day_1226/post/f4122d2e-0946-4356-b21a-a0412111b86e/image.png" alt=""> 초기 react-kakao-maps-sdk를 프로젝트에 적용했다가 위와 같이 라이브러리에서 제공하는 MapMarker 컴포넌트에 <code>className</code>을 지정하려고 하니 &quot;<code>className</code> 속성이 없다&quot;는 에러가 발생하는 걸 볼 수 있었다. 
그러나 마커는 이미지 삽입으로 마커 커스텀이 가능하고, 다른 컴포넌트는 <code>styles</code> props가 지정 가능한 경우가 있기 때문에 tailwind 외 다른 스타일 라이브러리 사용 시에는 문제없을 것 같다. <br/></li>
<li>고정된 기본 스타일
![] (<a href="https://velog.velcdn.com/images/day_1226/post/cd39e576-9324-46d3-a51e-f1204a62a606/image.png">https://velog.velcdn.com/images/day_1226/post/cd39e576-9324-46d3-a51e-f1204a62a606/image.png</a>)<pre><code>&lt;MapMarker
key={`${marker.position.latitude}-${marker.position.longitude}`}
position={{
 lat: marker.position.latitude,
 lng: marker.position.longitude,
}}
&gt;
&lt;CustomOverlayMap
 position={{
   lat: marker.position.latitude,
   lng: marker.position.longitude,
 }}
 yAnchor={-1}
&gt;
 &lt;Link
   className=&#39;border-olive-green rounded-xl border-2 border-solid bg-white p-1&#39;
   href=&#39;https://map.kakao.com/link/map/11394059&#39;
   target=&#39;_blank&#39;
   rel=&#39;noreferrer&#39;
 &gt;
   &lt;span className=&#39;title&#39;&gt;{marker.placeName}&lt;/span&gt;
 &lt;/Link&gt;
&lt;/CustomOverlayMap&gt;
&lt;/MapMarker&gt;</code></pre>더욱이 위 사진처럼 Marker와 CustomOverlay를 함께 불러오면, 기본 오버레이가 없어지지 않는 버그가 있었다. className 설정 없이 tailwind로 이를 컨트롤 하기도 어렵기에...</li>
</ol>
<p>따라서 나는 프로젝트에서 지도와 관련된 요소들을 좀더 자유롭게 커스텀해 사용하고 싶어 위 기존의 Kakao 지도 web API를 바탕으로 진행하고 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[✍️회고 | 스타트업 프론트엔드 개발 인턴 회고]]></title>
            <link>https://velog.io/@day_1226/%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%B8%ED%84%B4-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@day_1226/%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EA%B0%9C%EB%B0%9C-%EC%9D%B8%ED%84%B4-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Fri, 05 Jul 2024 08:42:18 GMT</pubDate>
            <description><![CDATA[<h1 id="🌻-intern-review">🌻 Intern Review</h1>
<p>올해 초 2월 중순부터 5월 중순까지 일경험 프로그램을 통해 3달간 스타트업에서 인턴으로 근무했다.
그토록 꿈꾸던 실무 경험이었는데 짧지만 경험하고 배운 점이 많아 
전체적인 업무 내용과, 배운 점, 잘한 점, 그리고 아쉬운 점까지 회고로 풀어보려 한다.</p>
<h2 id="📍-where">📍 Where</h2>
<p>내가 근무한 기업은 &#39;건강 및 피트니스&#39; 도메인의 스타트업이다. 
안정적인 사용자층을 확보한 어플리케이션을 운영하고 있고, 
소상공인, 지자체와 함께 협력하며 지역사회 활성화에 기여하고 있는 점에 흥미가 있어 지원했다. 
그리고 무엇보다 스타트업의 분위기와 커뮤니케이션을 경험해 보고 싶었다. </p>
<p>기업에서의 커뮤니케이션 방식</p>
<ul>
<li>스탠드업 미팅 : 매일 아침 10시 반에 모두 일어나 한명 씩 돌아가면서 어제까지 한 일, 하고 있는 일의 진척상황, 오늘 할 일을 다같이 간단히 공유하는 시간을 가졌다. 15분 내로 끝났고 방향을 조정하는 등 간단한 소통도 함께 이뤄졌다. 인턴은 일주일에 한번 참여했는데, 처음엔 개발이사님부터 모든 직원분들에게 주목받는 게 긴장되었지만, 어떤 작업을 하고 계신지 알게 되었고 직책, 직무 상관없이 동일하게 진행하고 귀기울여 주는 방식에서 수평적인 스타트업의 분위기를 많이 느꼈다.</li>
<li>온라인 커뮤니케이션 : 자리가 가깝더라도 우선적으로 슬랙의 DM을 통해 커뮤니케이션이 이뤄졌고, 자세히 얘기할 사항은 구두로 얘기하면서 소통했다. 내 자리의 경우 사수님과 떨어져 있어서 DM으로 질문하는 일이 많았는데, 까다로운 질문의 경우에는 구두보다 소통하기에 좋았던 것 같다.</li>
</ul>
<h2 id="📍-나의-목표">📍 나의 목표</h2>
<blockquote>
<ul>
<li>질문 많이 하기</li>
</ul>
</blockquote>
<p>이거 하나였는데 굉장히 중요하게 여겼던 것 같다.</p>
<p>이전부터 느낀 나의 단점 중 하나는 &#39;<strong>너무 혼자 해결하려고 하는 것</strong>&#39;이다. 
어떻게 보면 책임감이 크고 스스로 주어진 일을 잘 해내고 싶은 마음도 있지만 그런 마음 때문에 도움을 요청하는 것도, 질문하는 것도 막상 하려고 하면 어려웠다. 또 여기에 더해 이게 물어볼 만한 일일까 생각하면서 결국은 혼자 어찌저찌 해결했지만, 사실 그 과정이 비효율적이었던 것 같다.</p>
<p>그래서 입사 초기 &quot;<strong>인턴인 나는 모르는 게 당연하다</strong>&quot;고 생각하려고 노력했다. </p>
<p>그리고 질문도 잘 해야 한다고, 실무에서는 어떻게 물어봐야 하는지 고민이 많았다. 그래서 &quot;<strong>많이 질문해 보면서 늘자</strong>&quot;고 생각했고, 그 과정에서 더 많이 배울 수 있기를 기대했다.</p>
<hr>
<h1 id="🌻-experience-overview">🌻 Experience Overview</h1>
<h2 id="📍--사용-스택">📍  사용 스택</h2>
<ul>
<li>FE : <code>React</code> <code>TypeScript</code> <code>Emotion</code> <code>Recoil</code> <code>React-Query</code> <code>React-Hook-form</code> <code>MUI</code> <code>Storybook</code></li>
<li>협업 툴 : <code>Git</code>, <code>Jira</code> <code>Butbucket</code> <code>Slack</code> <code>Notion</code> <code>Figma</code>, <code>Swagger</code> <code>Postman</code></li>
</ul>
<h2 id="📍--업무">📍  업무</h2>
<h3 id="✔️-1개월-차-소상공인-클라이언트-사이트-개발">✔️ (1개월 차) 소상공인 클라이언트 사이트 개발</h3>
<p>첫 업무는 이전 인턴분이 프로젝트 환경을 구축하고 진행하던 작업을 이어받아 마무리한 프로젝트였다.
기획과 디자인은 이미 완료된 상태였고, 프론트엔드(나), 백엔드 개발자 한 분과 함께 프로젝트를 진행했다. </p>
<blockquote>
<ul>
<li>1달간 미작업 구현 사항을 구현하고 2차 QA까지 진행, 개발계-&gt;운영계 배포를 마쳤다. QA를 진행하면서 사실상 프로젝트 전반을 다뤘는데, 처음 코드를 보았을 때 이전 인턴분이 환경 구축은 탄탄하게 해주셨지만 급하게 최대한 만들다 가신 느낌이 드는 상태였다🥲. 기존 작업 내용은 요구사항에 맞게 로직을 고치고, 복잡한 코드 정리하고, 중복 컴포넌트 분리하고, 폴더 구조도 재정비 하면서 완성하더라도 혹시나 나중에 작업할(?) 누군가를 고려하며 가독성을 높이려 했다.</li>
</ul>
</blockquote>
<ul>
<li>이후에는 모바일 반응형 디자인을 반영하고 발생한 버그와 오류를 해결하다, 퇴사 전 UI/UX 개선을 위한 디자인 변경 &amp; 기능 추가 사항이 있어 이를 반영하고 추가 QA를 마침과 동시에 퇴사했다👼.</li>
</ul>
<p>모르는 건 사수님의 도움을 받았지만, 실무에서 맡은 첫 프로젝트라 책임감이 컸고 나오는 QA 사항은 오로지 나의 것이라는..부담도 있었다. 
그러나 그만큼 다양한 요구사항을 접하고, 다양한 에러 상황을 해결해 보고 스택들도 제대로 다뤄볼 수 있었기 때문에 가장 크게 성장한 경험이 아닐까 싶다. </p>
<h3 id="✔️-2개월-차-데이터-포탈-ui-컴포넌트-구현-참여">✔️ (2개월 차) 데이터 포탈 UI 컴포넌트 구현 참여</h3>
<p>사내 개발 중인 데이터 포탈에서 UI 컴포넌트 구현을 맡았다. 아래는 그 중 가장 기억에 남는 컴포넌트 구현 내용이다.</p>
<ul>
<li>테이블 입력 폼
오디오/이미지/텍스트/날짜 입력 폼이 테이블 형식으로 짜인 컴포넌트를 구현했다. 폼 자체는 구현이 어렵지 않았지만, &#39;테이블 레이아웃&#39;에 맞춘 요구사항을 충족하기 위해 성능보다도 UI에 대한 고민을 많이 하며 개발한 경험이었다.<blockquote>
<ul>
<li>텍스트 입력 폼은 클릭 시 입력 모달을 띄워 입력받기</li>
</ul>
</blockquote>
<ul>
<li>오디오/이미지는 키보드 Delete/Backspace 키로도 삭제할 수 있어야 함</li>
<li>행을 10개 이상 입력 시 상하 스크롤 활성화</li>
<li>좌/우로 폼이 나뉜 상태에서 좌측 폼은 고정, 우측 폼은 shift키를 눌렀을 때에만 좌우 스크롤 기능 활성화</li>
</ul>
</li>
</ul>
<h3 id="✔️-3개월-차-storybook-기반-디자인-시스템-구축">✔️ (3개월 차) Storybook 기반 디자인 시스템 구축</h3>
<p>입사한 지 2달이 되어갈 때쯤 사수님이 &#39;<strong>Storybook</strong>&#39;을 사용해 본 적이 있는지 물어보시면서 내가 있을 때 이를 같이 도입해 보고 싶다고 했다. 
사실 Storybook에 대해 잘 모르고 언제 한번 공부해 보고 싶다는 생각만 하다가 새로운 스택을 사용해 본다는 게 좋아서 호기롭게 &quot;주말 동안 공부해 보고 올게요!&quot; 로 시작된 업무였다.</p>
<p>사수님이랑 같이 일주일에 1-2번 스터디를 진행하고 3주 동안 아래 플로우로 구현했다.</p>
<blockquote>
<ul>
<li>Storybook + 컴포넌트 라이브러리 프로젝트 환경 구축 </li>
</ul>
</blockquote>
<ul>
<li>작업 중인 UI 컴포넌트들을 바탕으로 문서화 진행 (그 외 타이포그래피, 컬러 팔레트 등등)</li>
<li>Chromatic으로 Storybook 배포</li>
<li>UI 컴포넌트들은 NPM 라이브러리로 따로 배포</li>
<li>빗버킷(Bitbucket) CI/CD 파이프라인 세팅으로 두 배포 자동화</li>
</ul>
<hr>
<h1 id="🌻-느낀-점">🌻 느낀 점</h1>
<h2 id="🌱-배운-점">🌱 배운 점</h2>
<h3 id="✔️--문제-파악하기">✔️  문제 파악하기</h3>
<ul>
<li>기획팀으로부터 QA가 들어오면 &quot;~에서 ~와 같이하면 ~동작이 안돼요&quot;와 같은 보여지는 부분에 대한 피드백으로 받게 되는데, 이를 해석해서 <strong>코드 내의 어떤 로직에서 문제가 있는지 도출하기까지 파악</strong>할 수 있게 되었다.</li>
<li>백엔드 API 요청 중 500 에러 제외 에러가 날 경우 이게 백엔드 문제인지 프론트엔드 문제인지 바로 파악 후, <strong>백엔드 에러의 경우 원인을 백엔드로 전달하면서 함께 해결</strong>할 수 있게 되었다.</li>
</ul>
<h3 id="✔️-프론트엔드가-ux를-고려하는-방법">✔️ 프론트엔드가 UX를 고려하는 방법</h3>
<p>쉬운 예로 &#39;999,999개까지의 개수를 입력할 수 있는 인풋&#39;이 있다면 아래와 같은 요구사항이 있을 것이다.</p>
<blockquote>
<ul>
<li>6자리 입력 제한 또는 유효성 검사 </li>
</ul>
</blockquote>
<ul>
<li>0, 음수(-), 문자열, 특수기호, 소수점 입력 방지 / 혹은 에러 문구 표시 </li>
<li>비어 있는 경우 에러 표시</li>
<li>띄어쓰기 금지</li>
<li>천 단위 구분기호(콤마) 넣기</li>
</ul>
<p>&quot;누가 음수를 입력하겠어? 누가 띄어쓰기를 입력하겠어? 아무리 그래도 999,999개+까지 입력하겠어?&quot;와 같은 여지는 실무에서 허용되지 않으며, 무조건 언젠가 QA로 들어올 사항이고😇, 개발자가 구현 시 먼저 충분히 고려해야 함을 느꼈다.</p>
<p>실제로 <strong>180+개</strong>의 프론트엔드 QA를 처리하면서, 구현 전 기획 요구 사항을 꼼꼼히 확인 하에 설계 방법을 세우고, 이 컴포넌트를 마치 &#39;미운 4살&#39; 아이가 다룰 수도 있다고 생각하고 사용하는 <strong>클라이언트의 시선에서 개발하는 마인드</strong>를 지니게 되었다.</p>
<hr>
<h2 id="🌱-좋았던-점">🌱 좋았던 점</h2>
<h3 id="✔️-대안을-제시하다">✔️ 대안을 제시하다</h3>
<p>3개월 차에 진행했다고 언급한 Storybook을 배포할 때 곤란한 문제가 있었다.</p>
<blockquote>
<ul>
<li>Bitbucket 배포가 가능해야 함 </li>
</ul>
</blockquote>
<ul>
<li>다른 배포 플랫폼은 Bitbucket이 안되거나 비공개 레포지토리에 대한 제약 있음</li>
<li>배포 자동화도 필요함</li>
</ul>
<p>사수님과 계속 고민하다가 여러 배포 플랫폼을 조사하던 중 <strong>Chromatic</strong>을 발견했다. Chromatic은 Storybook 관리자가 만든 무료 배포 서비스로 배포 과정이 간단하고, 시각적 테스트 자동화를 지원하면서도 팀과 함께 리뷰할 수 있다는 점이 좋아 보여 사수님에게 제안드렸는데 긍정적으로 여겨주셔서 Chromatic으로 배포를 진행했다. 자동화는 Bitbucket CI/CD 파이프라인 구축으로 처리하게 되었다.</p>
<p>문제 상황에서 열심히 대안을 찾아보고 제안한 것을 바탕으로 무사히 배포까지 이끌어낼 수 있어 뿌듯했다.</p>
<h3 id="✔️-좋은-질문하기">✔️ 좋은 질문하기</h3>
<p>사수님으로부터 좋은 피드백을 받았던 부분이다. </p>
<p>사수님에게 도움 요청 시 기업 슬랙 채널을 통한 소통 + 내 자리와 사수님의 자리가 떨어져 있어 항상 DM으로 먼저 질문을 드리고 사수님이 확인 후 답변 혹은 내 자리에서 조언을 주시는 방식으로 도움을 구했다.
입사 초반에는 인턴으로서 사수님에게 드리는 DM이 긴장되어서 먼저 양해를 잘 구해야 되겠다(?)는 생각이 들었는데, 다른 직원분들 간의 커뮤니케이션 방식이 생각보다 편하고 자유로운 분위기였고, 다들 바쁘게 업무에 임하고 계신 걸 보면서 오히려 빠른 전달이 더 중요하다고 느꼈다. </p>
<blockquote>
<ul>
<li>그래서 질문이 간단한 경우 서론 없이 바로 DM으로 질문을 드렸고,</li>
</ul>
</blockquote>
<ul>
<li>코드 리뷰처럼 설명이 필요한 경우 &quot;현재 ㅇㅇ 관련 문제가 있는데 확인 괜찮으신가요?&quot;와 같은 간단한 DM을 드린 후, &#39;문제 상황 + 코드(파일 경로 포함) + 주요 코드 설명&#39;을 글로 정리해 DM을 보냈다. </li>
</ul>
<p>이 방식에 대해 사수님은 빨리 확인하고 파악해서 도움을 줄 수 있는 방식이 좋았다고 하셨다. </p>
<p>물론 어떤 커뮤니케이션 방식이 옳게 여겨지는 지는 사람, 회사에 따라 또 다를 수도 있겠지만 <strong>요지 파악을 위한 &#39;정확한 전달&#39;</strong>만큼은 커뮤니케이션에서 항상 중요하다고 생각하게 되었다.</p>
<hr>
<h2 id="🌱-아쉬운-점">🌱 아쉬운 점</h2>
<h3 id="✔️-일정-예측-및-관리">✔️ 일정 예측 및 관리</h3>
<p>전달받은 업무에 대해 &quot;얼마나 걸릴 것 같아요?&quot;라고 질문을 받으면 어떻게 구현할지 전혀 감이 잡히지 않아 머뭇거리거나 항상 넉넉잡아 말씀드리곤 했다.
오래 걸릴 수 있음을 드러내는 게 인턴으로서 좋아 보이지 않을 테지만, 내가 말한 기간에 대해 책임져야 한다고 생각했기 때문이다.
이에 사수님은 &quot;나라면 이 작업은 며칠, 저 작업은 며칠 정도면 될 것 같은데&quot; 요렇게 요구사항을 보며 바로 계획을 잡는 모습을 보고 놀랐다. 결과적으론 사수님과 함께 일정을 조율했고, 내가 처음 넉넉잡은 기간보다 빠르게 완성하기도 했다. </p>
<p>요구사항을 보고 머릿속으로 그려보면서 구현 방향을 예측할 수 있게 된다면 다른 이해관계자들과의 협업에도, 프로젝트 일정 관리와 진행에도 큰 도움이 될 것 같다는 생각이 들었다. 그러려면 더 성장해야지💪</p>
<hr>
<h1 id="✍️-마무리">✍️ 마무리</h1>
<p>소통 능력과 스킬에 대한 빠른 적응력을 인정받고, 그동안 공부해오던 스택을 직접 사용해 본 값진 시간이었다.
많이 배운 만큼 부족한 부분도 느꼈고 앞으로 내가 무엇을 채워나가야 하는지 안다.</p>
<p>글을 쓰는 지금은 벌써 인턴 퇴사 두달차가 되어가지만 
조급해하지 않고 단단한 개발자로 계속 나아가고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js | [에러] Next.js 외부 이미지 사용 에러(Error: Invalid src prop)]]></title>
            <link>https://velog.io/@day_1226/Next.js-%EC%97%90%EB%9F%AC-Next.js-%EC%99%B8%EB%B6%80-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%82%AC%EC%9A%A9-%EC%97%90%EB%9F%ACError-Invalid-src-prop</link>
            <guid>https://velog.io/@day_1226/Next.js-%EC%97%90%EB%9F%AC-Next.js-%EC%99%B8%EB%B6%80-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%82%AC%EC%9A%A9-%EC%97%90%EB%9F%ACError-Invalid-src-prop</guid>
            <pubDate>Sun, 30 Jun 2024 11:50:06 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/day_1226/post/7eaebeb1-c782-4f4f-b867-7aecca694901/image.png" alt=""></p>
<blockquote>
<p><strong>Unhandled Runtime Error</strong>
Error: Invalid src prop (<a href="https://storep-phinf.pstatic.net/ogq_618430508a376/original_7.png?type=p100_100">https://storep-phinf.pstatic.net/ogq_618430508a376/original_7.png?type=p100_100</a>) on next/image, hostname &quot;my-walklog.s3.ap-southeast-2.amazonaws.com&quot; is not configured under images in your next.config.js
See more info: <a href="https://nextjs.org/docs/messages/next-image-unconfigured-host">https://nextjs.org/docs/messages/next-image-unconfigured-host</a></p>
</blockquote>
<p>Next.js에서 외부 이미지 데이터를 불러와 Image 컴포넌트로 사용할 때 다음과 같은 에러가 발생했다.
AWS S3에 저장한 이미지 url을 사용할 때에도 위 에러가 발생했다.</p>
<p>해석하면 다음과 같다.</p>
<blockquote>
<ol>
<li>next/image 컴포넌트에 전달된 src 속성의 값이 유효하지 않음</li>
<li>이미지의 호스트 도메인 &quot;my-walklog.s3.ap-southeast-2.amazonaws.com&quot;이  next.config.js 파일에 구성되어 있지 않음</li>
</ol>
</blockquote>
<h2 id="원인">원인</h2>
<p>Next.js에서 <code>next/image</code> 컴포넌트를 사용해 외부 호스트에서 호스팅하는 이미지를 렌더링하려면 해당 호스트를 next.config.js에 등록해야 한다.</p>
<h2 id="해결">해결</h2>
<p><code>next.config.js</code> 파일에서 <code>images</code>-<code>domains</code>에 허용하고자 하는 도메인을 설정해 준다.</p>
<pre><code>/** @type {import(&#39;next&#39;).NextConfig} */
const nextConfig = {

  //...다른 설정

  // 이미지 호스트 추가
  images: {
    domains: [
      &#39;my-walklog.s3.ap-southeast-2.amazonaws.com&#39;,
    ],
  },
};

export default nextConfig;
</code></pre><p>여러 이미지 도메인을 등록할 수 있다.
저장한 뒤, 만약 npm run dev로 실행 중이었다면 종료 후 재실행해야 적용되는 것을 볼 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js | prisma로 csv 파일 DB에 저장하기 ]]></title>
            <link>https://velog.io/@day_1226/Next.js-TS-csv-%ED%8C%8C%EC%9D%BC-prisma%EB%A1%9C-db%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/Next.js-TS-csv-%ED%8C%8C%EC%9D%BC-prisma%EB%A1%9C-db%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 16 Jun 2024 08:55:29 GMT</pubDate>
            <description><![CDATA[<p>현재 구현 중인 Next.js 프로젝트에 필요한 오픈 API를 찾던 중 
필요한 데이터가 CSV로만 제공하는 데이터였고, CSV 파일을 DB에서 저장해 관리해야 하는 문제가 있었다.
그래서 prisma를 사용해 DB에 미리 CSV 파일을 업로드해 저장하는 로직을 구현해보기로 했는데...,</p>
<p>구글링 해보니 관련 자료가 없었고, 있더라도 딱 세팅까지의 내용만 있어서 
이후는 혼자 시행착오를 겪으며 결국 성공한😭 업로드까지의 과정을 남겨보려 한다.</p>
<hr>
<h2 id="1-prisma-환경-세팅">1. prisma 환경 세팅</h2>
<p>프로젝트를 vercel로 배포되어 있고,
db는 vercel postgres를 선택해 미리 기본 스키마 및 db를 세팅해 둔 상태이다.</p>
<ul>
<li>vercel postgres db 세팅 참고
  아래 vercel 문서 URL 내용과 같이 세팅해 두었다.
  <a href="https://vercel.com/guides/nextjs-prisma-postgres">https://vercel.com/guides/nextjs-prisma-postgres</a></li>
</ul>
<p>그리고 나는 아래와 같은 산책로 공공테이터를 DB에 업로드해보려 한다.
<a href="https://www.bigdata-culture.kr/bigdata/user/data_market/detail.do?id=9d4e73e0-41e6-11eb-af9a-4b03f0a582d6">내 주변 산책로 데이터 URL</a></p>
<h3 id="1-prisma-관련-패키지-다운로드하기">1) prisma 관련 패키지 다운로드하기</h3>
<pre><code class="language-bash">  npm i @prisma/client
npm i prisma --save-dev- </code></pre>
<h3 id="2-schemaprisma-파일-작성">2) <code>schema.prisma</code> 파일 작성</h3>
<p>프로젝트 가장 상위에 <code>prisma</code> 폴더 생성 후 <code>schema.prisma</code>라는 이름의 파일이 추가되어 있음을 가정한다.
    <img src="https://velog.velcdn.com/images/day_1226/post/2566e47e-c27c-4876-8f7f-274ead5d0424/image.png" alt="">
    이 곳에 DB schema 정의가 되어 있을텐데, 업로드하고자 하는 csv 파일의 컬럼명을 참고해 모델을 추가 작성한다.
    만약 이렇게 데이터에 컬럼명 정의서가 있다면 이를 참고해 작성하면 된다.
<img src="https://velog.velcdn.com/images/day_1226/post/ffc4083e-68b6-4e19-860c-9784f31fa1f8/image.png" alt=""></p>
<pre><code class="language-sql">    // schema.prisma

    generator client {
      provider = &quot;prisma-client-js&quot;
    }

    datasource db {
      provider = &quot;postgresql&quot;
      url = env(&quot;POSTGRES_PRISMA_URL&quot;)
      directUrl = env(&quot;POSTGRES_URL_NON_POOLING&quot;) 
    }

    // 다른 model...

    model Trail{
      ESNTL_ID           String @id @unique
      WLK_COURS_FLAG_NM  String
      WLK_COURS_NM       String
      COURS_DC           String
      SIGNGU_NM          String
      COURS_LEVEL_NM     String
      COURS_LT_CN        String
      COURS_DETAIL_LT_CN String
      ADIT_DC            String
      COURS_TIME_CN      String
      OPTN_DC            String
      TOILET_DC          String
      CVNTL_NM           String
      LNM_ADDR           String
      COURS_SPOT_LA      String
      COURS_SPOT_LO      String
    }</code></pre>
<h3 id="3-db-업데이트">3) DB 업데이트</h3>
<p>정의한 스키마를 db에 업데이트 해주면 세팅 끝!</p>
<pre><code class="language-bash">npx prisma generate // db 업데이트 (실행 후 migrations 폴더에 이전 기록이 만들어진다.)
npx prisma migrate dev --name init // 기존 db 초기화</code></pre>
<hr>
<h2 id="2-csv-파일로-db에-적용하기">2. csv 파일로 DB에 적용하기</h2>
<h3 id="1-csv-parser-설치">1) csv-parser 설치</h3>
<p> csv 파일을 파싱하기 위한 <code>csv-parser</code> 패키지를 설치한다.
 (패키지 설치 없이 csv 파일을 불러올 수 있는 방법도 있다. 아래에 이 방법도 함께 언급할 예정)</p>
<pre><code> npm i csv-parser</code></pre><h3 id="2-tsconfigjson-수정">2) tsconfig.json 수정</h3>
<pre><code class="language-json">{
  &quot;compilerOptions&quot;: {
    //...다른 설정
    &quot;module&quot;: &quot;commonjs&quot;,
    &quot;esModuleInterop&quot;: true,
    &quot;outDir&quot;: &quot;./dist&quot;,
  },
  &quot;include&quot;: [&quot;**/*.ts&quot;, ...],
}</code></pre>
<p>위 속성을 꼭 작성해 주어야 한다.</p>
<h3 id="3-csv-import-파일-작성csv-parser-사용ver">3) csv import 파일 작성(csv-parser 사용.ver)</h3>
<p><code>dist</code>폴더 생성 후 ts파일을 만들어 준다.
<img src="https://velog.velcdn.com/images/day_1226/post/b44657cd-309d-4a79-9136-88ccc0d6f0ac/image.png" alt=""></p>
<pre><code class="language-ts">// dist\importTrails.ts

&#39;use server&#39;; // prisma를 사용하기때문에 &quot;use server&quot; 선언

import { PrismaClient } from &#39;@prisma/client&#39;;
import * as fs from &#39;fs&#39;;
const csvParser = require(&#39;csv-parser&#39;);

const prisma = new PrismaClient();

type Trail = {
  ESNTL_ID: string;
  WLK_COURS_FLAG_NM: string;
  WLK_COURS_NM: string;
  COURS_DC: string;
  SIGNGU_NM: string;
  COURS_LEVEL_NM: string;
  COURS_LT_CN: string;
  COURS_DETAIL_LT_CN: string;
  ADIT_DC: string;
  COURS_TIME_CN: string;
  OPTN_DC: string;
  TOILET_DC: string;
  CVNTL_NM: string;
  LNM_ADDR: string;
  COURS_SPOT_LA: string;
  COURS_SPOT_LO: string;
};

async function main() {
  const trails: Trail[] = [];

  fs.createReadStream(&#39;dist/trails.csv&#39;)
    .pipe(
      csvParser({
        mapHeaders: ({ header }: { header: string }) =&gt; header.trim(),
      }),
    )
    .on(&#39;data&#39;, (row: Trail) =&gt; {
      trails.push(row);
    })
    .on(&#39;end&#39;, async () =&gt; {
      console.log(&#39;CSV 파일이 성공적으로 처리되었습니다.&#39;);
      for (const trail of trails) {
        await prisma.trail.create({ data: trail });
      }
      console.log(&#39;모든 데이터가 삽입되었습니다.&#39;);
      await prisma.$disconnect();
    });
}

main().catch((e) =&gt; {
  console.error(e);
  prisma.$disconnect();
});
</code></pre>
<ul>
<li><p><code>use server</code> : Prisma를 사용하기 위해 서버 측 코드임을 명시</p>
</li>
<li><p>PrismaClient 및 fs 모듈 가져오기: Prisma 클라이언트(<code>PrismaClient</code>)와 파일 시스템 모듈(<code>fs</code>)을 사용</p>
</li>
<li><p><code>require(&#39;csv-parser&#39;)</code> : csv-parser 모듈 불러오기 </p>
<blockquote>
<p>⚠️ 에러 : <code>import csv from &#39;csv-parser’</code> 사용 시 아래와 같은 에러가 발생할 수 있다.
&quot;This module is declared with &#39;export =&#39;, and can only be used with a default import when using the &#39;esModuleInterop&#39; flag.&quot;</p>
</blockquote>
<ul>
<li><code>csv-parser</code> 모듈은 &#39;export =&#39;를 사용하여 내보내기가 선언되어 있기 때문 </li>
<li><code>tsconfig.json</code> 파일에서 <code>esModuleInterop</code> 플래그가 활성화되어 있지 않을 때에도 발생하나, <code>esModuleInterop : true</code>로 설정하더라도 <code>import</code>형식으로 불러올 때 동일하게 에러가 발생하였다.</li>
</ul>
</li>
<li><p><code>type Trail</code> - csv 타입 정의 : CSV 파일에서 읽어온 데이터를 저장하기 위한 타입 정의하기</p>
</li>
<li><p><code>main()</code> 함수 정의 및 실행</p>
</li>
</ul>
<ol>
<li><code>fs.createReadStream()</code>을 사용하여 <code>csv</code> 파일 읽어오기<blockquote>
<p>⚠️ 에러 : 첫번째 데이터만 undefined로 출력되는 문제 
키값에 따옴표가 붙어서 파싱되는 문제 때문에 첫번째 데이터만 undefined로 불러오지 못하는 문제가 있었다.</p>
</blockquote>
<pre><code class="language-ts">{
   &#39;ESNTL_ID&#39;: &#39;KCCWSPO20N000001623&#39;,
   WLK_COURS_FLAG_NM: &#39;관동별곡 8백리길&#39;,
   WLK_COURS_NM: &#39;총 13코스&#39;,
   …,
}</code></pre>
<code>mapHeaders: ({ header }: { header: string }) =&gt; header.trim()</code> 을 사용해 열 이름을 불러와 trim() 메소드를 실행해주니 해결되었다.</li>
</ol>
<ol start="2">
<li><code>csvParser()</code>를 사용하여 CSV 파일을 파싱하고, 각 행을 Trail 타입의 객체로 변환</li>
<li>파싱된 데이터를 선언한 배열에 저장</li>
<li>모든 데이터 처리가 완료되면, <code>prisma.(schema에 저장한 모델명).create()</code>를 사용하여 각 객체를 데이터베이스에 저장</li>
<li>예외 처리와 함께 <code>main()</code> 함수 호출</li>
</ol>
<h3 id="-csv-import-파일-작성csv-parser-사용-없이ver">+) csv import 파일 작성(csv-parser 사용 없이.ver)</h3>
<p><code>csv-parser</code> 모듈을 사용 안할 경우 기본으로 내장되어 있는<code>readline</code> 모듈로도 csv 파일을 읽어올 수 있다.</p>
<pre><code class="language-ts">&#39;use server&#39;; // prisma를 사용하기때문에 &quot;use server&quot; 선언
import { PrismaClient } from &#39;@prisma/client&#39;;
import * as fs from &#39;fs&#39;;
import * as readline from &#39;readline&#39;;

const prisma = new PrismaClient();

type Trail = {
  ESNTL_ID: string;
  WLK_COURS_FLAG_NM: string;
  WLK_COURS_NM: string;
  COURS_DC: string;
  SIGNGU_NM: string;
  COURS_LEVEL_NM: string;
  COURS_LT_CN: string;
  COURS_DETAIL_LT_CN: string;
  ADIT_DC: string;
  COURS_TIME_CN: string;
  OPTN_DC: string;
  TOILET_DC: string;
  CVNTL_NM: string;
  LNM_ADDR: string;
  COURS_SPOT_LA: string;
  COURS_SPOT_LO: string;
};

async function main() {
  const trails: Trail[] = [];

  const fileStream = fs.createReadStream(&#39;dist/trails.csv&#39;);
  const rl = readline.createInterface({
    input: fileStream,
    crlfDelay: Infinity,
  });

  let isFirstLine = true; // 첫 번째 줄인지 여부를 확인하기 위한 플래그

  for await (const line of rl) {
    if (isFirstLine) {
      isFirstLine = false;
      continue; // 첫 번째 줄인 경우 스킵하고 다음 줄로 이동
    }

    const row = line.split(&#39;,&#39;);

    trails.push({
      ESNTL_ID: row[0],
      WLK_COURS_FLAG_NM: row[1],
      WLK_COURS_NM: row[2],
      COURS_DC: row[3],
      SIGNGU_NM: row[4],
      COURS_LEVEL_NM: row[5],
      COURS_LT_CN: row[6],
      COURS_DETAIL_LT_CN: row[7],
      ADIT_DC: row[8],
      COURS_TIME_CN: row[9],
      OPTN_DC: row[10],
      TOILET_DC: row[11],
      CVNTL_NM: row[12],
      LNM_ADDR: row[13],
      COURS_SPOT_LA: row[14],
      COURS_SPOT_LO: row[15],
    });
  }

  console.log(&#39;CSV 파일이 성공적으로 처리되었습니다.&#39;);

  for (const trail of trails) {
    await prisma.trail.create({ data: trail });
  }

  console.log(&#39;모든 데이터가 삽입되었습니다.&#39;);
  await prisma.$disconnect();
}

main().catch((e) =&gt; {
  console.error(e);
  prisma.$disconnect();
});
</code></pre>
<h2 id="3-db에-업로드">3. DB에 업로드</h2>
<p>이제 작성한 파일을 실행시켜주면 DB에 업로드할 수 있게 된다!</p>
<p>나는 초기 데이터를 db에 미리 저장하려고 하기 때문에 node 환경에서 <code>importTrail</code> 파일을 실행해주었다.
(그렇지 않을 경우 원하는 컴포넌트에 위 파일을 불러아 async await으로 <code>main()</code>함수를 실행하면 된다)</p>
<h3 id="1-js-로-변환">1) js 로 변환</h3>
<pre><code class="language-bash">npx tsc dist/importTrails.ts ts // js로 변환</code></pre>
<p>명령어 실행 후 dist 폴더에 js 파일이 생겨난다.
<img src="https://velog.velcdn.com/images/day_1226/post/6cf804e8-038d-42ab-baf3-7660bbdb739c/image.png" alt=""></p>
<h3 id="2-import-파일-실행하기">2) import 파일 실행하기</h3>
<p>변환한 js 파일을 node 환경에서 실행한다.</p>
<pre><code class="language-bash">node dist\importTrails.js // 실행</code></pre>
<blockquote>
<p>💡 DB에 들어갈 데이터가 많을 경우 업로드 시간이 좀 걸리기 때문에 위 코드처럼 console.log를 찍어 진행 상태와 완료 여부를 확인하는 것이 좋다.</p>
</blockquote>
<p><code>모든 데이터가 삽입되었습니다.</code> 출력 후 <code>npx prisma studio</code> 실행 시 db에 아래와 같이 업로드된 것을 확인할 수 있다. 
(캡처한 이미지는 업로드 진행 중일 때 확인한 것으로, 업로드 진행 중에 studio를 들어가면 새로고침할 때마다 실시간으로 데이터가 들어오고 있는 것을 볼 수 있다.)
<img src="https://velog.velcdn.com/images/day_1226/post/1b9a23b0-8319-43b3-a7a3-f2a210000012/image.png" alt=""></p>
<h2 id="참고">참고</h2>
<p><a href="https://velog.io/@bellboy/CSV%ED%8C%8C%EC%9D%BC-prisma-%EC%9D%B4%EC%9A%A9%ED%95%B4%EC%84%9C-DB%EC%97%90-%EC%A0%80%EC%9E%A5%ED%95%98%EA%B8%B0">Velog - CSV파일 prisma 이용해서 DB에 저장하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React | Chromatic으로 Storybook 배포하기]]></title>
            <link>https://velog.io/@day_1226/React-Chromatic%EC%9C%BC%EB%A1%9C-Storybook-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/React-Chromatic%EC%9C%BC%EB%A1%9C-Storybook-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 21 Apr 2024 12:08:27 GMT</pubDate>
            <description><![CDATA[<h2 id="chromatic">chromatic?</h2>
<p>chromatic은 스토리북 관리자들이 만든 무료 퍼블리싱 서비스이다. 
Netlify, Vercel과 같이 스토리북 사이트를 배포할 수 있을뿐만 아니라, 크로마틱 사이트 자체에서 컴포넌트를 미리 확인할 수 있고, 빌드 내역이 기록되어 컴포넌트 단위로 변경 사항을 확인할 수 있어 스토리북에 최적화 되어 있다.</p>
<p>실무에서 스토리북으로 제작한 디자인 시스템을 Chromatic으로 배포를 진행했는데, 선택하게 된 이유는 다음과 같았다.</p>
<p>1) 사내에서 사용 중인 bitBucket으로 배포를 진행할 수 있고, Private 리포지토리도 가능해야 함 
2) 스토리북으로 제작한 디자인 시스템을 여러명이 접근하고 협업할 수 있어야 함 
3) 단 chromatic에서 지원되지 않는 부분인 배포 자동화가 필요해서 고민을 했는데, 이는 파이프라인 설치를 통해 리포지토리 자체에서 배포 자동화를 하기로 함 (생각보다는 어렵지 않은 부분이었음)</p>
<hr>
<h2 id="1-chromatic-가입하기">1) chromatic 가입하기</h2>
<p><a href="https://www.chromatic.com/">https://www.chromatic.com/</a></p>
<p>Githuyb, BitBucket, GitLab 연동으로 가입이 가능하다.
블로그 글을 쓰면서 깃허브로 가입 및 Github Action으로 배포 자동화를 진행해 보고 싶어서 나는 깃허브로 가입해 보기로!</p>
<p>가입하면 바로 프로젝트를 생성할 수 있다.
<img src="https://velog.velcdn.com/images/day_1226/post/39d8e536-26ff-4964-bc07-ef255014c80b/image.png" alt="">
Choose from Github 클릭
<img src="https://velog.velcdn.com/images/day_1226/post/358e898f-45d5-469a-96b2-24845ac7f934/image.png" alt="">
스토리북 프로젝트 리포지토리 선택! 
<img src="https://velog.velcdn.com/images/day_1226/post/b2792938-51bf-402a-9454-73a9b4090ab8/image.png" alt="">
그리고 StoryBook 선택하면
<img src="https://velog.velcdn.com/images/day_1226/post/1f161ef9-8b9f-4f45-a4eb-6bcfc89cba8c/image.png" alt="">
리포지토리 동기화가 완료되었고, chromatic을 프로젝트에 설치하고 배포하라는 명령어가 나온다. 
뒷 부분은 토큰인데, 배포 이후 setting에서 다시 확인할 수 있으니 꼭 적어두지 않아도 괜찮을 것 같다.</p>
<hr>
<h2 id="2-chromatic-배포하기">2) chromatic 배포하기</h2>
<pre><code>npm install --save-dev chromatic</code></pre><pre><code>npx chromatic --project-token={토큰 값}</code></pre><p>본래 다른 곳에서 배포 시 npm run build-storybook과 같이 build해야 하는데, 위 명령어는 build와 배포를 자동 진행해준다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/a2766b32-4990-427d-a1a7-2e9336d5e1a6/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/58627148-2566-40e5-9615-d2e7725a2551/image.png" alt=""></p>
<p>설치 마지막에 &quot;<code>package.json</code>에 chromatic script가 없어 추가해 주겠다&quot;고 하는데, 
수락 시 package.json의 scripts에 chromatic이 추가되고, <code>npm run chromatic</code> 명령어로 간단하게 빌드할 수 있게 한다.</p>
<p>해도 좋고 안해도 좋고 선택!</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/bf0cda2a-2c67-4c94-b09a-a86cbe52b9ba/image.png" alt=""></p>
<p>설치가 완료 되면 배포 사이트와 chromatic 프로젝트 사이트 url을 알려준다. </p>
<p>들어가보면?</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/a9dfcc91-336e-40ed-be6c-dfb739485b0f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/b5a8343e-030a-4b5c-a363-bfcfbcea739b/image.png" alt=""></p>
<p>npm run storybook으로 열어 보았던 스토리북 페이지와 스토리북 프로젝트 페이지를 볼 수 있다.</p>
<hr>
<h2 id="3-스토리북-프로젝트-살펴보기">3) 스토리북 프로젝트 살펴보기</h2>
<h3 id="builds">Builds</h3>
<p>프로젝트 초기 화면이다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/e422b5f5-9d30-4649-8c02-cee6facfabb3/image.png" alt=""></p>
<p>컴포넌트 변경사항을 만들고 한번 더 build를 재 진행해야 관리 페이지에 접근할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/de48dc2b-bb33-4f51-ae64-8f3824e8ca24/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/7476b934-0c93-4761-a75d-70bd2ec6ea4b/image.png" alt=""></p>
<p>이런 내용이 나오고, 계속 클릭하면 </p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/5b36e464-ba09-4266-b750-331f55565fc0/image.png" alt=""></p>
<p>짠!</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/f1c34c60-7890-44b0-beca-2060036f00a0/image.png" alt=""></p>
<p>변경사항 확인 및 코멘트까지 남길 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/092fb8ac-616b-44e3-9248-9ce5f2f6410a/image.png" alt=""></p>
<p>그리고 우측 상단 View Storybook 버튼을 클릭하면 변경사항이 반영된 사이트를 확인할 수 있다.
이 때 이동한 사이트는 build마다 그 때의 변경사항이 적용된 부분까지만 보여지게 된다.</p>
<h3 id="library">Library</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/dc9dcf8f-27fc-4181-bba2-27732a169ba7/image.png" alt="">
<img src="https://velog.velcdn.com/images/day_1226/post/9f9ad685-f1dc-472d-91b3-61f5d64b55bd/image.png" alt=""></p>
<p>컴포넌트들을 모아볼 수 있는 곳.
각 컴포넌트 들의 canvas와 snapshot(실제 브라우저가 스토리를 렌더링할 당시 &#39;본&#39; 이미지)을 살펴볼 수 있다.</p>
<h3 id="manage---automate">Manage - Automate</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/ff51e985-9bcd-463c-9d82-fa3ab5a77b15/image.png" alt=""></p>
<p>visual test를 지원하는데, 기본적으로 Chrome을 테스트할 수 있고 유료 플랜 시 Firefox, Safari, Edge까지 볼 수 있는 듯.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/e5100b06-c301-49f0-8fc9-9339b3c9a1f9/image.png" alt=""></p>
<ul>
<li>그리고 무료플랜의 경우 snapshot을 5,000개까지 지원해 준다고 한다.</li>
<li>Slack 알림 받기 기능이 있다. 아직 안사용해 봤지만 실무에서 Slack을 쓰고 있는데, 디자인 시스템 구축이 더 필요하게 될 때 도입하기 유용할 것 같다.</li>
</ul>
<h3 id="manage---collaborate">Manage - Collaborate</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/80a1625e-4aca-4a07-8942-a2fad39eff54/image.png" alt=""></p>
<ul>
<li><strong>Collaborators</strong> : 링크 공유 또는 email 초대를 통해 다른 사용자가 해당 프로젝트를 열람(Viewer)하고, 리뷰(Reviewer)하고, 개발(Developer), Owner까지 권한을 줄 수 있다.</li>
<li><strong>Permalinks</strong> : 아까 Builds 탭을 살펴보면서 go to srtorybook으로 접근한 사이트는 각 build에서 적용된 변경사항까지만을 보여준다고 했는데(=이후 build에서 변경된 사항은 적용 안된다), 두 링크에서 <code>&lt;branch&gt;</code> 부분에 브랜치를 입력해 접속하면 해당 링크는 브랜치를 가리켜 그 브랜치에 적용된 최신 사항을 항상 보여준다. </li>
<li><strong>Visiblity</strong> : 보통 사이트를 배포하면 접근 시 모두가 볼 수 있는 게 기본인데, 크로마틱에서는 공개 여부를 <code>private</code>로 해놓으면 배포된 스토리북 사이트를 나를 포함한 초대한 사용자만 볼 수 있게 해준다. 좋은 기능인 것 같다b</li>
</ul>
<h3 id="manage---configure">Manage - Configure</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/83c69ab0-6eea-49ad-a051-c81f30971852/image.png" alt=""></p>
<ul>
<li><strong>Project</strong> : 프로젝트 이름을 변경하고, 토큰을 확인할 수 있다.</li>
<li><strong>Connected Applications</strong> : 리포지토리 동기화. 깃허브로 로그인 후 프로젝트 생성 시 리포지토리를 이미 연결했기 때문에 자동으로 연결되어 있다. 만약 연결 없이 새로 프로젝트를 생성했을 때 여기서 Github, bitBucket, Gitlab의 리포지토리와 연결할 수 있다. 
또 Figma 연결도 가능해 Figma의 디자인을 storybook 에서 볼 수 있다고...b (자세한 사항은 링크 참고 <a href="https://www.chromatic.com/docs/figma-in-chromatic/">https://www.chromatic.com/docs/figma-in-chromatic/</a>)</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React | Storybook 기반 컴포넌트 문서화 도입하기 (React + Vite + TS) ]]></title>
            <link>https://velog.io/@day_1226/React-React-Vite-TS-%ED%99%98%EA%B2%BD-Storybook-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/React-React-Vite-TS-%ED%99%98%EA%B2%BD-Storybook-%EB%8F%84%EC%9E%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 14 Apr 2024 12:21:36 GMT</pubDate>
            <description><![CDATA[<p>근황 : 실무에서 Storybook 개발 도구 공부해 2주 만에 컴포넌트 문서화 도입 + 라이브러리 배포한 이야기...</p>
<p>React + Vite + TS 환경에서의 Storybook 세팅 방법과 Storybook 컴포넌트 구현 정보가 생각보다 많지 않았어서 여기서 찾고 저기서 찾고 많이 헤맸었다. 그래서 Storybook 적용하면서 틈틈이 기록해 두었던 것을 블로그 글로 남겨보려 한다.</p>
<p>이번 글에서는 React + Vite + TS 내에서의 스토리북 설치 방법과, 나중에라도 보기만 하면 따라할 수 있도록 story 컴포넌트를 만드는 방법을 정리해 보고, 이후에 docs 세팅 방법, actions 사용 방법, mdx 적용 방법...잘하면 npm 라이브러리 배포까지 이야기 해보고 싶다.</p>
<hr>
<h2 id="1-프로젝트-환경-세팅">1. 프로젝트 환경 세팅</h2>
<h3 id="react--vite--ts-설치">React + Vite + TS 설치</h3>
<pre><code>// 기본
npm create vite@latest
// npm 6.x
npm create vite@latest [프로젝트 명] --template react-ts
// npm 7+, extra double-dash is needed:
npm create vite@latest [프로젝트 명] -- --template react-ts</code></pre><p>세 방법 중 적당한 방법으로 설치
<img src="https://velog.velcdn.com/images/day_1226/post/932e583f-2c5e-4fc1-837c-38e34922fa1a/image.png" alt=""></p>
<h3 id="emotion-설치">emotion 설치</h3>
<pre><code>npm i @emotion/styled @emotion/react emotion-reset</code></pre><hr>
<h2 id="2-storybook-설치">2. Storybook 설치</h2>
<pre><code>npx storybook@latest init</code></pre><p><img src="https://velog.velcdn.com/images/day_1226/post/42701a5b-803d-47be-a07c-0eb97a093f89/image.png" alt=""></p>
<p>설치 중 npm 최신 버전 설치를 추천해 주는데, 이 부분도 같이 설치해 주었다.</p>
<p>설치를 마치면 storybook 페이지가 자동으로 실행되고, 이후 실행 시 아래 명령어로 storybook을 열어줄 수 있다.</p>
<pre><code>npm run storybook</code></pre><p><img src="https://velog.velcdn.com/images/day_1226/post/83f905cc-fc89-452a-866c-bcea3d92fad4/image.png" alt=""></p>
<p>프로젝트 디렉토리를 보게 되면 프로젝트 루트에 <code>.storybook</code>이라는 폴더와 <code>src</code> 폴더 내 <code>stories</code> 폴더가 생성되어 있는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/81d0d014-1730-4a63-981c-3a49f0654213/image.png" alt=""></p>
<h3 id="storybook-vite-builder-설치">Storybook Vite builder 설치</h3>
<p>Storybook은 Webpack 기반의 프레임워크이므로 Vite 환경에서 storybook을 작업하기 위해 vite builder를 따로 설치해준다.</p>
<pre><code>npm install @storybook/builder-vite --save-dev</code></pre><blockquote>
<p>참고 : <a href="https://storybook.js.org/docs/6.5/react/builders/vite">https://storybook.js.org/docs/6.5/react/builders/vite</a></p>
</blockquote>
<h3 id="viteconfigts">vite.config.ts,</h3>
<p>story 컴포넌트에서도 import 시 편하게 <code>@/~~~</code>형태로 가져올 수 있는 설정을 추가해 준다.</p>
<ul>
<li><p><code>vite.config.ts</code></p>
<pre><code class="language-ts">import { defineConfig } from &#39;vite&#39;;
import react from &#39;@vitejs/plugin-react&#39;;
import * as path from &#39;path&#39;;

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: [{ find: &#39;@&#39;, replacement: path.resolve(__dirname, &#39;src&#39;) }],
  },
});</code></pre>
</li>
<li><p><code>tsconfig.json</code></p>
<pre><code class="language-ts">{
  &quot;compilerOptions&quot;: {

    //...다른 설정

    &quot;paths&quot;: {
      &quot;@/*&quot;: [&quot;./src/*&quot;]
    }
  },
  &quot;include&quot;: [&quot;src&quot;],
  &quot;references&quot;: [{ &quot;path&quot;: &quot;./tsconfig.node.json&quot; }]
}</code></pre>
</li>
</ul>
<hr>
<h2 id="3-storybook-세팅">3. Storybook 세팅</h2>
<h3 id="maints"><code>main.ts</code></h3>
<p>Vite builder 설치한 내용을 <code>.storybook/main.js</code>에 세팅해 준다.</p>
<pre><code class="language-ts">  import type { StorybookConfig } from &#39;@storybook/react-vite&#39;;

  const config: StorybookConfig = {
    core: {
      builder: &#39;@storybook/builder-vite&#39;, // 👈 The builder enabled here.
    },
    stories: [&#39;../src/**/*.mdx&#39;, &#39;../src/**/*.stories.@(js|jsx|mjs|ts|tsx)&#39;],
    addons: [
      &#39;@storybook/addon-onboarding&#39;,
      &#39;@storybook/addon-links&#39;,
      &#39;@storybook/addon-essentials&#39;,
      &#39;@chromatic-com/storybook&#39;,
      &#39;@storybook/addon-interactions&#39;,
    ],
    framework: {
      name: &#39;@storybook/react-vite&#39;,
      options: {},
    },
    docs: {
      autodocs: &#39;tag&#39;,
    },
  };
  export default config;</code></pre>
<h3 id="previewts---previewtsx">preview.ts -&gt; <code>preview.tsx</code></h3>
<p>storybook 컴포넌트에서 다음을 사용한다면 preview.ts의 세팅이 필요하다
<code>preview.ts 파일 명을</code>preview.tsx`로 변경 후 아래와 같이 세팅해 준다.</p>
<h3 id="폰트-설정-preview-headhtml">폰트 설정 <code>preview-head.html</code></h3>
<p>사용하는 폰트가 있다면 <code>preview-head.html</code>을 만들어 해당 폰트 link 태그를 가져와 아래와 같이 세팅한다.</p>
<pre><code>  &lt;!-- Pull in static files served from your Static directory or the internet --&gt;
  &lt;!-- Example: `main.js|ts` is configured with staticDirs: [&#39;../public&#39;] and your font is located in the `fonts` directory inside your `public` directory --&gt;

  // 사용 중인 폰트 link 태그 삽입
  &lt;link rel=&quot;preconnect&quot; href=&quot;https://fonts.googleapis.com&quot; /&gt;
  &lt;link rel=&quot;preconnect&quot; href=&quot;https://fonts.gstatic.com&quot; crossorigin /&gt;
  &lt;link
    href=&quot;https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@100..900&amp;display=swap&quot;
    rel=&quot;stylesheet&quot;
  /&gt;

  &lt;!-- Or you can load custom head-tag JavaScript: --&gt;
  &lt;!-- &lt;script src=&quot;https://use.typekit.net/xxxyyy.js&quot;&gt;&lt;/script&gt;
  &lt;script&gt;
    try {
      Typekit.load();
    } catch (e) {}
  &lt;/script&gt; --&gt;</code></pre><blockquote>
<p>참고 : <a href="https://storybook.js.org/docs/configure/story-rendering#adding-to-head">https://storybook.js.org/docs/configure/story-rendering#adding-to-head</a></p>
</blockquote>
<hr>
<h2 id="4-story-컴포넌트storiestsx-만들기">4. Story 컴포넌트(.stories.tsx) 만들기</h2>
<p>스토리 컴포넌트 파일 생성 시 디렉토리 구조를 story컴포넌트만 모아놓는 방법도 있는데, 
실제 작업하면서 이렇게 컴포넌트와 스타일, 그리고 story 컴포넌트를 함께 넣어놓는 방식이 컴포넌트에서 Story 컴포넌트로 Props를 받아올 수도 있고 관리하기에 수월했다. 
<img src="https://velog.velcdn.com/images/day_1226/post/5be19f3c-78f3-46a7-9e78-f2f063a2b205/image.png" alt=""></p>
<h3 id="기본-세팅-방식">기본 세팅 방식</h3>
<p>아래는 storybook 공식 문서에 나와 있는 기본 story 컴포넌트 세팅 방식이다. 먼저 각 meta 내 props에 대해 알아보고, 아래 방식 말고도 다양하게 story 컴포넌트를 세팅할 수 있는 방법을 소개하려 한다.</p>
<pre><code class="language-tsx">// 기본 import
import type { Meta, StoryObj } from &#39;@storybook/react&#39;;
// Story로 만들 컴포넌트 불러오기
import { Button } from &#39;./Button&#39;;

const meta = {
    title: &quot;Components/Buttons/Button&quot;,
  component: Button,
  parameters: {
    layout: &quot;centered&quot;,
  },
  tags: [&quot;autodocs&quot;],
  args: {}
  argTypes: {}
} satisfies Meta&lt;typeof Button&gt;;

export default meta;
type Story = StoryObj&lt;typeof meta&gt;;

export const Primary: Story = {
  args: {
    primary: true,
    label: &#39;Button&#39;,
  },
};</code></pre>
<ul>
<li><p><code>title</code> - 폴더 구조 만들기 (depth 1 - 카테고리, depth 2 - 폴더, depth 3 - 컴포넌트 폴더)
<img src="https://velog.velcdn.com/images/day_1226/post/9bf4afe9-7239-469f-a6ef-528e5c8dab16/image.png" alt=""></p>
</li>
<li><p><code>component</code> - import한 컴포넌트 삽입</p>
</li>
<li><p><code>parameters - layout</code> - 컴포넌트가 보여지는 위치</p>
<ul>
<li><p><code>centerd</code> 가로 세로 중앙</p>
<p>  <img src="https://velog.velcdn.com/images/day_1226/post/24ee00e0-b346-4075-a0cd-a670d08d72bb/image.png" alt=""></p>
</li>
</ul>
</li>
</ul>
<pre><code>- `fullscreen`여백 없이

    ![](https://velog.velcdn.com/images/day_1226/post/075112e4-0c24-47e6-9b0f-6561b4a41a04/image.png)


- `padded` (기본값) - 임의의 패딩 여백이 주어짐

    ![](https://velog.velcdn.com/images/day_1226/post/fed3f599-e7bb-4ebd-aff7-31278c2f5b28/image.png)</code></pre><ul>
<li><code>tags: [&quot;autodocs&quot;]</code> - 자동 Docs 생성
  <img src="https://velog.velcdn.com/images/day_1226/post/43fcc246-67bb-47b0-b73f-7885f67d39e2/image.png" alt=""></li>
<li><code>args</code> : 스토리 컴포넌트에 넣어줄 props default 값 지정</li>
</ul>
<h3 id="다른-story-컴포넌트-형식">다른 Story 컴포넌트 형식</h3>
<blockquote>
<p>참고 : <a href="https://storybook.js.org/docs/api/csf">https://storybook.js.org/docs/api/csf</a></p>
</blockquote>
<ul>
<li><p>미리 <code>type Story = StoryObj&lt;Props&gt;</code>타입 지정 후 <code>args</code> 에 props, <code>decorators</code> 
(또는 <code>render</code>)에 컴포넌트를 렌더링하는 방법</p>
<pre><code class="language-tsx">import { Button } from &#39;./Button&#39;;

// 기존과 동일
  const meta = {
      title: &quot;Components/Buttons/Button&quot;,
    component: Button,
    parameters: {
      layout: &quot;centered&quot;,
    },
    tags: [&quot;autodocs&quot;],
    args: {}
    argTypes: {}
  } satisfies Meta&lt;typeof Button&gt;;
  type Story = StoryObj&lt;Props&gt;;

  export const Default: Story = {
    args: {
      main: &quot;Main&quot;,
      mainLink: &quot;mainLink&quot;,
    },
    decorators: [
      (Story) =&gt; (
        &lt;div style={{ width: &quot;300px&quot; }}&gt;
          &lt;h2&gt;Menu Button&lt;/h2&gt;
          &lt;Story /&gt;
        &lt;/div&gt;
      ),
    ],
  };</code></pre>
<pre><code class="language-tsx">export const Default: Story = {
args: {
  main: &quot;Main&quot;,
  mainLink: &quot;mainLink&quot;,
},
render: ({ main, mainLink }) =&gt; (
  &lt;div style={{ width: &quot;300px&quot; }}&gt;
    &lt;h2&gt;Menu Button&lt;/h2&gt;
    &lt;MenuItem main={main} mainLink={mainLink} /&gt;
  &lt;/div&gt;
),
};</code></pre>
</li>
<li><p>리액트 컴포넌트 스타일 형식으로 만들기</p>
<pre><code class="language-tsx">  export const DropDown = (args: Props) =&gt; (
    &lt;div style={{ width: &quot;300px&quot; }}&gt;
      &lt;h2&gt;Menu Dropdown&lt;/h2&gt;
      &lt;DropDown
        main=&quot;Main&quot;
        sub={[
          { link: &quot;link1&quot;, name: &quot;text1&quot; },
          { link: &quot;link2&quot;, name: &quot;text2&quot; },
        ]}
      /&gt;
    &lt;/div&gt;
  );</code></pre>
<pre><code class="language-tsx">  import type { Meta } from &quot;@storybook/react&quot;;

  import Component, { Props } from &quot;.&quot;;

  const meta = {
    title: &quot;Components/Component&quot;,
    component: Component,
    parameters: {
      layout: &quot;centered&quot;,
    },
    tags: [&quot;autodocs&quot;],
    argTypes: {},
    args: {},
  } satisfies Meta;

  export default meta;

  export const Default = (args: Props) =&gt; (
    &lt;&gt;
      &lt;Component {...args}&gt;&lt;/Component&gt;
    &lt;/&gt;
  );
</code></pre>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[⚛️React | 리액트 파일 input 다루기 2탄 - API 요청 시 formData  다루기 (json Data 함께 넣기, 여러개 파일 넣기)]]></title>
            <link>https://velog.io/@day_1226/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%8C%8C%EC%9D%BC-input-%EB%8B%A4%EB%A3%A8%EA%B8%B0-2%ED%83%84-API-%EC%9A%94%EC%B2%AD-%EC%8B%9C-formData-%EB%8B%A4%EB%A3%A8%EA%B8%B0-json-Data-%ED%95%A8%EA%BB%98-%EB%84%A3%EA%B8%B0-%EC%97%AC%EB%9F%AC%EA%B0%9C-%ED%8C%8C%EC%9D%BC-%EB%84%A3%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%8C%8C%EC%9D%BC-input-%EB%8B%A4%EB%A3%A8%EA%B8%B0-2%ED%83%84-API-%EC%9A%94%EC%B2%AD-%EC%8B%9C-formData-%EB%8B%A4%EB%A3%A8%EA%B8%B0-json-Data-%ED%95%A8%EA%BB%98-%EB%84%A3%EA%B8%B0-%EC%97%AC%EB%9F%AC%EA%B0%9C-%ED%8C%8C%EC%9D%BC-%EB%84%A3%EA%B8%B0</guid>
            <pubDate>Sun, 07 Apr 2024 13:54:23 GMT</pubDate>
            <description><![CDATA[<h2 id="1-formdata-안에-json-data-넣기">1. formData 안에 json Data 넣기</h2>
<p>보통 이미지/오디오 업로드 시 formData를 사용해 API 요청을 하게 되는데, 
백엔드에서 formData 안에 <code>application/json</code> 형식의 json data와 파일을 함께 넣어서 요청하는 방식으로 구현해 주셨다. 
처음에 보고 읭 json data를 formData에 넣을 수 있다고? 싶었는데 알고 보니 방법이 있었다.</p>
<pre><code class="language-tsx">const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const file = event.target.files[]

    if(file){
       const data = { fileType : &quot;abcde&quot; } // 백엔드에서 요청한 json 예시
       const formData = new FormData();

       formData.append(
          &quot;data&quot;,
          new Blob([JSON.stringify(data)], {
            type: &quot;application/json&quot;,
          }) // application/json 형식으로 넣어 주기
       );
       // 하나의 파일
       formData.append(&quot;image&quot;, file) // multipart/form-data 형식, 업로드할 파일 집어넣기

       // ...API 요청
     }

    // ...
}</code></pre>
<p>Blob을 사용해 첫번째 인수에 <code>JSON.stringify()</code>로 변환해 준 데이터, 그리고 type을 <code>application/json</code>로 지정하면 application/json 형식으로 json data를 보낼 수 있다.</p>
<hr>
<h2 id="2-formdata-안에-여러-개의-파일-집어넣기">2. formData 안에 여러 개의 파일 집어넣기</h2>
<p>파일 업로드 API가 여러 개의 파일 업로드가 가능하도록 구현되어 있는 경우이다. 어떻게 여러 개의 파일을 동시에 보낼 수 있는지 싶었는데 이것도 아주 간단한 방법이!</p>
<pre><code class="language-tsx">const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const files = event.target.files;

  if (files) {
    const formData = new FormData();
    // 여러 개의 파일
    Object.values(files).forEach((file) =&gt;
       if(type){
          formData.append(&quot;files&quot;, file, file.name)
       }
    );
    // ...API 요청
  }
  //...
}
</code></pre>
<p>Object 형식인 files의 values(file)만 가져와서 각 file마다 key name, file, file 이름을 formData에 담아 주면 끝!</p>
<hr>
<h2 id="3-filereader로-보여주는-방식에서-api-응답-데이터를-가져와-보여주기-feat-usemutation">3. FileReader로 보여주는 방식에서 API 응답 데이터를 가져와 보여주기 (feat. useMutation)</h2>
<p>이대로 마무리하기 아쉬워서...
백엔드 API가 만들어 지기 전 FileReader 객체로 화면에서 파일 관련 정보(이미지 미리보기, 오디오 정보 등) 확인해 볼 수 있도록 로직을 먼저 구현해 놓고 API를 나중에 적용하게 될 때의 경우이다.</p>
<p>setState로 값을 변경하는 방식은 동일하게 적용하되, 
아예 업로드 API 요청 후 응답 데이터 값을 가져와 뿌려주도록 &#39;file 이벤트 함수에서 <code>useMutation</code>을 통한 API 요청 + setState를 통한 값 변경&#39; 방식의 적용은 다음과 같이 구현할 수 있다. 
(이전의 json Data 넣기, 여러 개의 파일 집어넣기까지 모두 포함해 보았다!)</p>
<ul>
<li><p>변경 전</p>
<pre><code class="language-tsx">// ** 1) Recoil atom의 useSetRecoilState / 또는 useState 의 setState props 가져오기 
// ** 2) POST API 요청을 위한 useMutate 훅 가져오기
const { mutate:audioUpload } = usePostAudioUpload();

const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const files = event.target.files;

  const reader = new FileReader();
    reader.readAsDataURL(file);
    reader.onloadend = (e) =&gt; {
      const newAudio = new Audio(e.target?.result as string);

      newAudio.onloadedmetadata = () =&gt; {
        setData((prev) =&gt; {
          // audio file과 재생시간 값 가져오기
          const updatedData = prev.map((prevData, i) =&gt; {
            if (index === i) {
              return {
                ...prevData,
                audio: file,
                duration: newAudio.duration,
              };
            }
            return data;
          });
          return updatedData;
        });
      };
  };
}</code></pre>
<ul>
<li>변경 후<pre><code class="language-tsx">// ** 1) Recoil atom의 useSetRecoilState / 또는 useState 의 setState props 가져오기 
// ** 2) POST API 요청을 위한 useMutate 훅 가져오기
const { mutate:audioUpload } = usePostAudioUpload();
</code></pre>
</li>
</ul>
<p>const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) =&gt; {
  const files = event.target.files;</p>
<p>  if (files) {</p>
<pre><code>const data = { fileType : &quot;abcde&quot; };
const formData = new FormData();

formData.append(
  &quot;data&quot;,
  new Blob(data)], {
  type: &quot;application/json&quot;,
  })
);
// 여러 개의
Object.values(files).forEach((file) =&gt;
   formData.append(&quot;files&quot;, file, file.name)
);

// 오디오 업로드 API 요청
audioUpload(formData, {
  onSuccess: (data) =&gt; {
    setData((prev) =&gt; {
      const updatedData = prev.map((prevData, i) =&gt; {
        if (index === i) { // 기존 data의 index 값과 비교
          return {
            ...prevData,
            audio: data[i].url,
            duration: data[i].duration,
          };
        }
        return audio;
      });
      return updatedData;
    });
  },
});</code></pre><p>  }
}</p>
<pre><code></code></pre></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[React |  리액트 파일 input 다루기 1탄 - 버튼 클릭으로 파일 선택창 열기, FileLeader로 이미지&오디오 데이터 가져오기]]></title>
            <link>https://velog.io/@day_1226/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%8C%8C%EC%9D%BC-Input-%EB%8B%A4%EB%A3%A8%EA%B8%B0-1%ED%83%84</link>
            <guid>https://velog.io/@day_1226/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%8C%8C%EC%9D%BC-Input-%EB%8B%A4%EB%A3%A8%EA%B8%B0-1%ED%83%84</guid>
            <pubDate>Sun, 31 Mar 2024 13:01:08 GMT</pubDate>
            <description><![CDATA[<p>1탄 2탄에 나누어 리액트에서 첨부파일을 가져와 화면에 보여주고, 가져온 첨부파일을 API로 요청하는 방법에 대한 글을 써보려 한다.</p>
<p>이번 글에서는 <code>버튼</code> 클릭으로 <code>input</code> 파일 선택창을 실행하는 방법과, <code>fileChangeHandler</code>에서 FileLeader를 사용해서 이미지, 오디오 데이터 정보를 가져오는 방법에 대한 내용을 먼저 다루고,</p>
<p>그 다음글에서 react query의 <code>useMutaion</code>으로 이미지/오디오 파일 업로드 API 구현 방법과 실무에서 formData로 API 요청 시의 다양한 경우(json Data 함께 넣기, 여러개 파일 formData에 넣기)대처 방법에 대해 다뤄볼 예정이다.</p>
<hr>
<h2 id="1-버튼-클릭으로-input-파일-선택창-실행하기">1) 버튼 클릭으로 input 파일 선택창 실행하기</h2>
<p>사용자가 버튼을 클릭하면 파일 선택 창을 열어 이미지 파일을 선택할 수 있게 하는 기능은 다음과 같이 구현할 수 있다.</p>
<pre><code class="language-tsx">const ref = useRef&lt;HTMLInputElement&gt;(null);

const fileHandler = () =&gt; {
  if (ref.current) {
    ref.current!.click();
  }
};

const handleFileChange = () =&gt;{
 //...
}

 &lt;button onClick={fileHandler}&gt;
   &lt;input
     name={name}
     type=&quot;file&quot;
     accept=&quot;image/*&quot;
     ref={ref}
     onChange={handleFileChange}
     className=&quot;a11y-hidden&quot;
   /&gt;
&lt;button&gt;</code></pre>
<blockquote>
<h4 id="💡--단언-연산자를-사용한-이유">💡 &lt;<code>!</code>&gt; 단언 연산자를 사용한 이유?</h4>
<p>체이닝에서 ref.current!.click()과 같이 <code>!</code>를 사용해 주면 ref.current가 존재한다, 즉  <strong>Nullish</strong>(null이나 undefined)한 값이 아니라는 확신을 심어줄 수 있다. 
그렇지 않을 시 타입 에러가 발생할 수 있다</p>
</blockquote>
<h3 id="1-useref-hook-사용">1. useRef Hook 사용</h3>
<pre><code class="language-tsx">const ref = useRef&lt;HTMLInputElement&gt;(null);</code></pre>
<p><code>useRef</code>는 React의 Hook 중 하나로, DOM 요소에 직접적으로 접근할 수 있게 해주는 친구로, 여기서는 <code>&lt;input type=&quot;file&quot;&gt;</code> 엘리먼트에 접근하기 위해 사용된다.
타입 스크립트에서는 <code>useRef&lt;HTMLInputElement&gt;(null)</code>과 같이 초기값은 <code>null</code>로 지정해줄 것.</p>
<h3 id="2-filehandler-함수">2. fileHandler 함수</h3>
<p>버튼 클릭 이벤트 함수 안에서 <code>ref.current</code>가 존재할 경우, 즉 input 엘리먼트가 마운트된 상태라면 해당 엘리먼트의 click() 메서드를 호출해 준다.
그래서 사용자가 직접 input을 클릭한 것처럼 버튼 클릭 시 파일 선택창을 열게 한다.</p>
<pre><code class="language-tsx">const fileHandler = () =&gt; {
  if (ref.current) {
    ref.current.click();
  }
};</code></pre>
<h3 id="3-button과-input-요소">3. button과 input 요소</h3>
<pre><code class="language-tsx">&lt;button onClick={fileHandler}&gt;
  &lt;input
    name={name}
    type=&quot;file&quot;
    accept=&quot;image/*&quot;
    ref={ref}
    onChange={handleImageChange}
    className=&quot;a11y-hidden&quot;
    multiple
  /&gt;
&lt;/ button&gt;</code></pre>
<h4 id="button">button</h4>
<ul>
<li><code>onClick={fileHandler}</code> : 버튼 클릭 시 <code>fileHandler</code> 함수를 실행하여 파일 선택 창을 연다.<h4 id="input">input</h4>
</li>
<li><code>ref</code> : <code>ref={ref}</code>를 통해 이 input에 대한 참조를 저장 </li>
<li><code>onChange={handleImageChange}</code> : 파일이 선택되었을 때 실행될 콜백 함수</li>
<li><code>className=&quot;a11y-hidden&quot;</code> : input을 화면상에서 숨기기 위해 미리 css에서 설정한 클래스 가져오기</li>
<li><code>multiple</code> : 한 번에 여러 개의 파일을 가져올 때 사용, 그렇지 않을 때에는 생략</li>
</ul>
<hr>
<h2 id="2-fileleader로-데이터-정보-가져오기---이미지-오디오">2) FileLeader로 데이터 정보 가져오기 - 이미지, 오디오</h2>
<p>이미지 데이터를 미리 화면에 띄워 보여주거나, 오디오 데이터의 이름, 실행 시간 등의 값을 가져올 때 <code>FileReader</code>를 활용할 수 있다.
먼저 input 파일 선택창 실행 후 받아온 파일을 다루기 위해 위 코드에서 <code>handleFileChange</code>에 해당하는 onChange 이벤트 함수를 세팅해 주자. </p>
<ul>
<li>한 개의 파일<pre><code class="language-tsx">const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const file = event.target.files?.[0];
  if (file) {
    console.log(&quot;선택된 파일:&quot;, file);
}</code></pre>
</li>
<li>여러 개의 파일<pre><code class="language-tsx">const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
  const files = event.target.files;
  if (files) {
    for(const file of files){
      console.log(&quot;선택된 파일:&quot;, file);
    }
}</code></pre>
</li>
</ul>
<blockquote>
<h4 id="잠깐-💡-트러블-슈팅---동일-파일-연속으로-안가져와지는-오류">잠깐 💡 트러블 슈팅! - 동일 파일 연속으로 안가져와지는 오류</h4>
<p>파일 선택창에서 한 번 가져온 파일이 연속적으로 불러와지지 않는 오류가 있다.
구글링하다 발견한 아주 간단한 방법은,</p>
</blockquote>
<pre><code class="language-tsx">const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const file = event.target.files?.[0];
    event.target.value = &quot;&quot;; // 동일 파일 업로드 오류 방지
    if (file) {
      console.log(&quot;선택된 파일:&quot;, file);
}</code></pre>
<p>onChange 시 <code>e.target.value</code>에 담긴 값이 유지되어서 같은 파일 재업로드가 막히는 것 같다.
<code>event.target.value</code>값을 초기화해 주면 해결!</p>
<h3 id="이미지-데이터">이미지 데이터</h3>
<p>이미지를 미리 보여줄 수 있는 로직은 아래와 같이 구현해 준다.</p>
<pre><code class="language-tsx">  const [selectedImage, setSelectedImage] = useState(&quot;&quot;);
  const [imageFile, setImageFile] = useState&lt;File&gt;()

  const handleImageChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
      const file = event.target.files?.[0];
      if (file) {
        // FileReader를 사용해 파일 읽기(JS의 내장기능)
        const fileReader = new FileReader();
        // blob 타입의 file을 url 형태로 만들기
        fileReader.readAsDataURL(file);
        // 읽기 작업이 완료되면, 결과물이 data로 들어오고 아래 함수가 실행된다
        fileReader.onload = (e) =&gt; {
            if (typeof data.target?.result === &quot;string&quot;) {
              // useState 초기값으로 string(&quot;&quot;)을 넣었기 때문에 꼭 타입을 지정해서 변경해 줄 것
                setSelectedImage(e.target?.result as string) // 미리보기를 위한 임시 url 
                  setImageFile(file) // API로 보내기 위한 File 저장
            }    
        }
     }
}

//...

return(
  &lt;button onClick={fileHandler}&gt;
    &lt;input
      name={name}
      type=&quot;file&quot;
      accept=&quot;image/*&quot;// image 파일만 받아올 수 있도록 설정
      ref={ref}
      onChange={handleImageChange}
      className=&quot;a11y-hidden&quot;
    /&gt;
    &lt;img src={selectedImage} alt=&quot;이미지&quot; /&gt;
  &lt;/ button&gt;
)
</code></pre>
<p>핵심은 딱 이 세 단계를 기억할 것!</p>
<ol>
<li><code>const fileReader = new FileReader();</code></li>
<li><code>fileReader.readAsDataURL(file);</code></li>
<li><code>fileReader.onload = (e) =&gt; {}</code></li>
</ol>
<h3 id="오디오-데이터">오디오 데이터</h3>
<p>오디오 파일 이름과 재생 시간을 가져오는 로직이다.
파일 이름은 그냥 file.name으로 가져올 수 있지만, 재생 시간과 같은 오디오 정보를 가져올 때에는 <strong>오디오 객체</strong>가 필요하다.</p>
<pre><code class="language-tsx">  const [fileName, setFileName] = useState(&quot;&quot;);
  const [duration, setDuration] = useState(0)

  const handleFileChange = (event: React.ChangeEvent&lt;HTMLInputElement&gt;) =&gt; {
    const file = event.target.files?.[0];
    if (file) {
      console.log(&quot;선택된 파일:&quot;, file);

      const reader = new FileReader();
      reader.readAsDataURL(file);
      reader.onload = (e) =&gt; {
        // 읽기 작업이 완료되면, 오디오 객체를 생성하고 메타데이터를 로드
        const audio = new Audio(e.target?.result as string);
        audio.onloadedmetadata = () =&gt; {
          // 메타데이터 로드가 완료되면, 파일 이름과 재생 시간을 가져오기
          // file 이름
          setFileName(file.name);
          // 추출된 오디오 재생 시간
          setDuration(audio.duration) // (2분 30초=)150와 같이 초 단위로 가져와 짐

        };
      };
    }

 //...

 return(
   &lt;&gt;
     &lt;button onClick={fileHandler}&gt;
        &lt;input
          name={name}
          type=&quot;file&quot;
          accept=&quot;audio/*&quot; // audio 파일만 받아올 수 있도록 설정
          ref={ref}
          onChange={handleImageChange}
          className=&quot;a11y-hidden&quot;
        /&gt;
     &lt;/ button&gt;
     &lt;p&gt;{fileName}&lt;/p&gt;
     &lt;p&gt;{duration}s&lt;/p&gt;
   &lt;/&gt;
 )
</code></pre>
<p>여기서의 포인트는 </p>
<p>1) <code>const audio = new Audio(e.target?.result as string);</code> - 오디오 객체 생성
2) <code>audio.onloadedmetadata = () =&gt; {}</code> 안에서 오디오 데이터 가져오기!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React + TS | Emotion Theme 세팅하기]]></title>
            <link>https://velog.io/@day_1226/React-TS-emotion-Theme-%EC%84%B8%ED%8C%85%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/React-TS-emotion-Theme-%EC%84%B8%ED%8C%85%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 24 Mar 2024 12:30:56 GMT</pubDate>
            <description><![CDATA[<p>현재 실무 프로젝트에서 <code>emotion</code>을 사용하고 있는데,
기존의 styled components와 비슷해서 어려울 건 없지만 <code>theme</code> 세팅 방법에 대해 정리해두려 한다. (설치부터의 사용법은 다음에!)</p>
<h2 id="emotion-설치">Emotion 설치</h2>
<pre><code>npm install @emotion/react @emotion/styled</code></pre><p>React 프로젝트에서 emotion을 사용할 수 있는 <code>@emotion/react</code> 패키지와 styled-components처럼 스타일링 컴포넌트 생성을 위한 <code>@emotion/styled</code>를 설치해 준다.</p>
<h2 id="theme-객체-생성하기">Theme 객체 생성하기</h2>
<p>emotion에서는 아래와 같이 테마에 포함될 스타일 속성들을 객체 형태로 정의할 수 있다.</p>
<pre><code class="language-ts">// theme.ts
export const theme = {
  fontSizes: {
    sm: &quot;13px&quot;,
    base: &quot;14px&quot;,
    md: &quot;18px&quot;,
  },
  lineHeight: {
    sm: &quot;13px&quot;,
    base: &quot;14px&quot;,
    md: &quot;18px &quot;,
  },
  letterSpacing: &quot;-2%&quot;,
  colors: {
    main: &quot;#2F3438&quot;,
    red: &quot;#FF0000&quot;,
    white: &quot;#FFFFFF&quot;,
    gray: &quot;#999999&quot;,
    blueGray2: &quot;#505B6D&quot;,
    blueGray3: &quot;#505B6D&quot;,
    blueGray4: &quot;#9DA8B6&quot;,
    border01: &quot;#E7EDF6&quot;,
    border02: &quot;#8899B6&quot;,
    bg01: &quot;#F9FAFC&quot;,
  },
};</code></pre>
<p><code>theme.js</code> 파일을 생성해서, fontSizes, lineHeight, letterSpacing, colors 등 필요에 따라 테마를 설정해 준다.</p>
<ul>
<li>아래는 이전까지 프로젝트에서의 <code>GlobalStyle css -&gt; :root</code> 세팅 방식인데, 비교해서 보니 emotion의 <code>theme</code> 설정이 조금 더 스타일을 세분화해서 사용할 수 있는 장점이 있는 것 같다. <pre><code class="language-ts">:root{
 --main-color:#724FFF;
 --font-color:#191919; 
 --sub-font-color:#767676;
 --extra-font-color: #909090;
 --tertiary-font-color: #575757;
 --border-color:#DBDBDB;
 --modal-border-color: #EDEDED;
 --input-background-color:#F1F1F5;
 --btn-border-color:#724FFF;
 --btn-background-color:#724FFF;
 --btn-point-color: #7D4FFF;
 --playlist-info-bg-color: #8969FF;
 --playlist-info-sub-color: #DBDBDB;
 --error-color: #FF003E;
 --font-xl : 22px;
 --font-l: 18px;
 --font-lg: 16px;
 --font-md: 14px; 
 --font-sm : 12px;
 letter-spacing: -0.02em;
   }</code></pre>
</li>
</ul>
<h2 id="typescript에서-theme-인터페이스-확장하기">TypeScript에서 Theme 인터페이스 확장하기</h2>
<p>이건 TypeScript를 사용한다면 emotion을 사용하기 좋은 큰 이유이자 이점이라고 생각한다.
TypeScript를 사용하는 경우, <code>@emotion/react</code> 모듈의 <code>Theme</code> 인터페이스를 확장하여 테마 객체에 정의된 속성들에 대한 타입을 선언 및 지정해 줄 수 있다(!)</p>
<pre><code class="language-ts">/// theme.ts
import &quot;@emotion/react&quot;;

declare module &quot;@emotion/react&quot; {
  export interface Theme {
    fontSizes: {
      sm: string;
      base: string;
      md: string;
    };
    lineHeight: {
      sm: string;
      base: string;
      md: string;
    };
    letterSpacing: string;
    colors: {
      wb500: string;
      red: string;
      white: string;
      gray: string;
      blueGray2: string;
      blueGray3: string;
      blueGray4: string;
      border01: string;
      border02: string;
      bg0: string;
    };
  }
}</code></pre>
<p>이렇게 테마를 정의한 <code>theme.ts</code> 파일 내에 Theme에 대한 타입을 함께 지정해 두면, 
테마 내 스타일 속성을 사용할 때의 타입 체킹까지 가능하게 된다.
(물론 타입 선언이 theme 세팅에서의 필수가 아니라 세팅하지 않아도 작동에 문제는 없다.)</p>
<blockquote>
<p><strong>theme 타입 세팅 시 주의⚠️</strong>
<code>declare</code>로 정의해 준다고 해서 타입 선언을 다른 파일에 분리해 정의를 하게 될 경우 Typescript compiler가 type을 감지하지 못하는 버그가 발생할 수 있다고 한다.</p>
</blockquote>
<pre><code>Property &#39;colors&#39; does not exist on type &#39;Theme&#39;.</code></pre><blockquote>
<p>다음과 같은 에러를 만날 수 있으니 꼭 타입을 한 파일 같이 내에 넣어 줄 것! 
(나도 작업 중 만난 에러인데, 해당 원인 외에도 theme 내에 설정한 스타일이 타입 내에 누락 될 경우에도 에러 문구를 만날 수 있으니 꼼꼼한 타입체킹하기)</p>
</blockquote>
<h2 id="themeprovider로-애플리케이션-감싸기">ThemeProvider로 애플리케이션 감싸기</h2>
<pre><code class="language-tsx">// index.ts
import React from &quot;react&quot;;
import ReactDOM from &quot;react-dom/client&quot;;
import { ThemeProvider } from &quot;@emotion/react&quot;;
import App from &quot;./App&quot;;

import &quot;./index.css&quot;;
import &quot;./fonts/Font.css&quot;;
import { theme } from &quot;./styles/theme&quot;;

ReactDOM.createRoot(document.getElementById(&quot;root&quot;)!).render(
      &lt;React.StrictMode&gt;
        &lt;ThemeProvider theme={theme}&gt;
          &lt;App /&gt;
        &lt;/ThemeProvider&gt;
      &lt;/React.StrictMode&gt;
);</code></pre>
<p>index.ts 파일에 theme.ts 파일을 불러온 다음, ThemeProvider로 App 컴포넌트를 감싸주면 세팅이 끝난다.</p>
<h2 id="세팅-끝-theme-사용하기">세팅 끝, Theme 사용하기</h2>
<pre><code class="language-ts">import styled from &quot;@emotion/styled&quot;;

export const Wrapper = styled.div`
  color: ${({ theme }) =&gt; theme.colors.main};
  font-size : ${({ theme }) =&gt; theme.fontSizes.sm};
  background-color : ${({ theme }) =&gt; theme.colors.bg};
`;</code></pre>
<p>작업하게 될 컴포넌트 스타일 적용 시 다음과 같이 theme을 가져와 사용하면 된다.
ThemeProvider 덕분에 <code>theme</code>을 import하는 등의 과정은 불필요하다!</p>
<h2 id="마무리하며">마무리하며</h2>
<p>emtion의 theme 세팅 및 사용 방법은 스타일 컴포넌트와 비슷한 방법으로, 
emotion 하면 또 다른 가장 유명한 방법은 태그에서 css props로 스타일을 지정하고 접근할 수 있는 방법이 있다. 이 부분은 다음에 한 번더 포스팅으로 정리해 보고 싶다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSS | 테이블 요소 모바일 반응형으로 만들기📊]]></title>
            <link>https://velog.io/@day_1226/CSS-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%9A%94%EC%86%8C-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EB%B0%98%EC%9D%91%ED%98%95%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@day_1226/CSS-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%9A%94%EC%86%8C-%EB%AA%A8%EB%B0%94%EC%9D%BC-%EB%B0%98%EC%9D%91%ED%98%95%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Sun, 17 Mar 2024 13:05:28 GMT</pubDate>
            <description><![CDATA[<h2 id="테이블-요소-모바일-반응형-디자인-만들기">테이블 요소 모바일 반응형 디자인 만들기</h2>
<table>
<thead>
<tr>
<th>웹</th>
<th>모바일</th>
</tr>
</thead>
<tbody><tr>
<td><img src="https://velog.velcdn.com/images/day_1226/post/39c19076-b5a3-43b3-a416-646e9bf2686c/image.png" alt=""></td>
<td><img src="https://velog.velcdn.com/images/day_1226/post/515c319e-f19b-44fe-9ce0-f75b2cfbb38a/image.png" alt=""></td>
</tr>
</tbody></table>
<p>프로젝트에서 급하게 테이블 요소를 모바일 디바이스에서 오른쪽과 예시와 같이 커스텀한 디자인으로 적용해야 했다.
<code>table</code> 관련 태그 요소들은 기본 스타일이 설정되어 있기 때문에 가끔씩 스타일이 적용되지 않는 부분이 있어 까다로운 편이라, 
알고 보니 간단했는데 헤맸던 테이블 요소 모바일 반응형 스타일 팁을 정리해 보려 한다.</p>
<p>우선 위 이미지에서 모바일 반응형 디자인에 반영되여야 할 사항은 다음과 같다.</p>
<blockquote>
</blockquote>
<ul>
<li>컬럼 제목 삭제</li>
<li>&#39;번호&#39; 컬럼에 해당하는 데이터 삭제</li>
<li>테이블 데이터 세로로 정렬해 카드 형태로 변경</li>
<li>데이터마다 위 아래 여백 주기</li>
<li>일부 요소 한 줄에 나란히 배치하기</li>
</ul>
<p>그리고 아래는 <code>@media screen and (max-width: 768px)</code>기준으로 모바일 반응형 디자인을 적용한 결과이다.</p>
<p>!codepen[Da-Youn/embed/JjVbLxe?default-tab=html%2Cresult]</p>
<h3 id="1-displaynone으로-필요하지-않은-요소-없애기">1. <code>display:none</code>으로 필요하지 않은 요소 없애기</h3>
<pre><code class="language-css">thead, td.index {  
    display: none;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/day_1226/post/e4e3a07d-bd70-4fd1-a881-de152d3603d5/image.png" alt=""></p>
<p>반대로 모바일 반응형에서만 추가로 표시해야 할 요소가 있다면 기본으로 <code>display:none</code> 설정 후 모바일 반응형 크기에서 <code>display:block</code>로 표시해 준다.</p>
<h3 id="2-displayblock-적절히-활용하기">2. <code>display:block</code> 적절히 활용하기</h3>
<p><img src="https://velog.velcdn.com/images/day_1226/post/af22a157-3a97-464d-be04-59eddd280c58/image.png" alt=""></p>
<pre><code class="language-css">td.name {
  display: flex;
  flex-direction: row;
  align-items:center;
  gap: 20px;
}

@media screen and (max-width: 768px){

  tbody tr {
      display:block;
      padding-top: 24px;
      padding-bottom: 14px;
      border-bottom: 1px solid #d6d8e1;
  }

}</code></pre>
<p><code>tr</code> 태그에 <code>display:block</code> 적용 시 
<code>td.name</code>와 같이 <code>display:flex</code>가 설정된 요소를 제외한 나머지 <code>td</code>요소들은 한 줄에 <strong>inline</strong>으로 정렬된 상태가 된다.
그리고 추가로 원래는 <code>tr</code> 태그에 적용되지 않았던 padding 스타일 설정도 적용되는 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/day_1226/post/8e9cc0f3-c1fa-45c7-8977-dd7c4730e7c2/image.png" alt=""></p>
<pre><code class="language-css">td.desc{
    display:block;
} </code></pre>
<p>그리고 나란히 배치가 필요한 요소를 제외하고 <code>display:block</code> 스타일을 주어 적절히 배치할 수 있다.</p>
<h3 id="3-data--props를-이용해-요소-옆에-제목-표시하기">3. &quot;<code>data-*</code>&quot; props를 이용해 요소 옆에 제목 표시하기</h3>
<p>&quot;정보 : A의 정보&quot;와 같이 모바일 반응형 디자인 시 요소의 옆으로 제목이 자연스럽게 배치되어야 했는데, 이때 <code>data-label</code>과 같이 td 태그에 쓰이는 <code>data-*</code> props를 활용했다.</p>
<blockquote>
<p>*<em>`data-</em>` 속성이란? **
 표준이 아닌 속성이나 추가적인 DOM 속성과 같은 다른 조작을 하지 않고도, 의미론적 표준 HTML 요소에 추가 정보를 저장할 수 있게 도와준다.
<a href="https://developer.mozilla.org/ko/docs/Learn/HTML/Howto/Use_data_attributes">https://developer.mozilla.org/ko/docs/Learn/HTML/Howto/Use_data_attributes</a></p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/day_1226/post/515b9230-6107-4659-a0d1-51dd7dc23b18/image.png" alt=""></p>
<pre><code class="language-html">  &lt;tbody&gt;
    &lt;tr&gt;
      &lt;td class=&quot;index&quot;&gt;1&lt;/td&gt;
      &lt;td class=&quot;name&quot;&gt;&lt;img src=&quot;https://picsum.photos/200&quot; alt=&quot;A 이미지&quot;/&gt;&lt;p&gt;A&lt;/p&gt;&lt;/td&gt;
      &lt;td data-label=&quot;내용&quot; class=&quot;desc&quot;&gt;A입니다 줄 잠깐 문득 준 재우쳤다. 5년 언땅에 못해서 싹싹하였다. 듯한 약을 발로 말인가?&lt;/td&gt;
      &lt;td data-label=&quot;정보&quot; class=&quot;info&quot;&gt;A의 정보&lt;/td&gt;
      &lt;td data-label=&quot;날짜&quot; class=&quot;date&quot;&gt;2024.01.01&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td class=&quot;index&quot;&gt;2&lt;/td&gt;
      &lt;td class=&quot;name&quot;&gt;&lt;img src=&quot;https://picsum.photos/200&quot; alt=&quot;B 이미지&quot;/&gt;&lt;p&gt;B&lt;/p&gt;&lt;/td&gt;
      &lt;td data-label=&quot;내용&quot; class=&quot;desc&quot;&gt;B입니다 줄 잠깐 문득 준 재우쳤다. 5년 언땅에 못해서 싹싹하였다. 듯한 약을 발로 말인가?&lt;/td&gt;
      &lt;td data-label=&quot;정보&quot; class=&quot;info&quot;&gt;B의 정보&lt;/td&gt;
      &lt;td data-label=&quot;날짜&quot; class=&quot;date&quot;&gt;2024.01.02&lt;/td&gt;
    &lt;/tr&gt;
    &lt;tr&gt;
      &lt;td class=&quot;index&quot;&gt;3&lt;/td&gt;
      &lt;td class=&quot;name&quot;&gt;&lt;img src=&quot;https://picsum.photos/200&quot; alt=&quot;C 이미지&quot;/&gt;&lt;p&gt;C&lt;/p&gt;&lt;/td&gt;
      &lt;td data-label=&quot;내용&quot; class=&quot;desc&quot;&gt;C입니다 줄 잠깐 문득 준 재우쳤다. 5년 언땅에 못해서 싹싹하였다. 듯한 약을 발로 말인가?&lt;/td&gt;
      &lt;td data-label=&quot;정보&quot; class=&quot;info&quot;&gt;C의 정보&lt;/td&gt;
      &lt;td data-label=&quot;날짜&quot; class=&quot;date&quot;&gt;2024.01.03&lt;/td&gt;
    &lt;/tr&gt;
  &lt;/tbody&gt;</code></pre>
<pre><code class="language-css">tbody tr td:not(.index, .name, .desc):before {
    font-weight: 700;
    content: attr(data-label) &quot; : &quot;;
}</code></pre>
<p><code>before</code> 선택자를 사용, <code>attr(data-label)</code>로 <code>data-label</code>에 접근해 데이터 앞에 &quot;데이터 : &quot;와 같이 표시해 줄 수 있다.</p>
<hr>
<h2 id="기타-모바일-반응형-스타일-방법-참고">기타 모바일 반응형 스타일 방법 참고</h2>
<p>참고했던 다른 테이블 반응형 방법 링크도 올려두며 글을 마무리해본다.</p>
<ul>
<li>제목-데이터-제목-데이터 순으로 배치하기, 스크롤, 리스트형
<a href="https://gaenarinari.tistory.com/143">테이블 반응형으로 만드는 방법 3가지</a></li>
<li><code>data-label</code>로 행 제목을 열 제목으로 만들기
<a href="https://openuri.net/entry/Responsive-Table-and-CSS-%ED%85%8C%EC%9D%B4%EB%B8%94-%EC%BD%94%EB%93%9C%EB%A5%BC-%EB%B0%98%EC%9D%91%ED%98%95%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0">테이블 코드를 반응형으로 만들기</a></li>
</ul>
]]></description>
        </item>
    </channel>
</rss>