<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>purple11_11.log</title>
        <link>https://velog.io/</link>
        <description>틀리더라도 🌸🌈🌷예쁘게 지적해주세요💕❣️</description>
        <lastBuildDate>Fri, 06 Mar 2026 02:52:39 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>purple11_11.log</title>
            <url>https://velog.velcdn.com/images/purple11_11/profile/cec38ad4-57e6-4cfe-908d-2d2ba31fd792/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. purple11_11.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/purple11_11" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[IntersectionObserver (Web API) - 스크롤 영역 벗어나면 자동으로 닫히는 툴팁]]></title>
            <link>https://velog.io/@purple11_11/IntersectionObserver-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-API-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%98%81%EC%97%AD-%EB%B2%97%EC%96%B4%EB%82%98%EB%A9%B4-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%8B%AB%ED%9E%88%EB%8A%94-%ED%88%B4%ED%8C%81</link>
            <guid>https://velog.io/@purple11_11/IntersectionObserver-%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-API-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%98%81%EC%97%AD-%EB%B2%97%EC%96%B4%EB%82%98%EB%A9%B4-%EC%9E%90%EB%8F%99%EC%9C%BC%EB%A1%9C-%EB%8B%AB%ED%9E%88%EB%8A%94-%ED%88%B4%ED%8C%81</guid>
            <pubDate>Fri, 06 Mar 2026 02:52:39 GMT</pubDate>
            <description><![CDATA[<p>툴팁 버튼 컴포넌트를 만들었는데
테이블에서 스크롤 시 툴팁이 닫히지 않는 문제가 있었다.
TableBody 영역을 벗어났을 때 툴팁이 닫히도록 <code>IntersectionObserver</code>를 사용해 해결했다.</p>
<blockquote>
</blockquote>
<pre><code class="language-ts"> useEffect(() =&gt; {
    if (!isOpen || !buttonRef.current) {
      return;
    }
&gt;
    const observer = new IntersectionObserver( // 1. 감시자 생성
      ([entry]) =&gt; { // 버튼 상태 감지
        if (!entry.isIntersecting) { // 스크롤로 버튼이 안보이면
          setIsOpen(false); // 툴팁 닫기
        }
      },
      {
        root: buttonRef.current?.closest(&#39;.overflow-y-auto&#39;), // 기준 영역 = 스크롤 컨테이너
        threshold: 0, // 1px이라도 벗어나면 감지
      }
    );
&gt;
    observer.observe(buttonRef.current); // 2. 감시 대상 등록 (버튼 감시 시작)
    return () =&gt; observer.disconnect(); // 3. 감시 중단 (isOpen 바뀌거나 언마운트 시 감시 중단)
  }, [isOpen]);</code></pre>
<h2 id="intersectionobserver란">IntersectionObserver란?</h2>
<p>특정 요소가 _<strong>지정한 영역(root)에 보이는지 안보이는지를 감시</strong>_하는 브라우저 API</p>
<blockquote>
</blockquote>
<pre><code>root (스크롤 컨테이너)
┌─────────────────┐
│                 │
│  [버튼] ← 보임       │  → isIntersecting: true
│                 │
└─────────────────┘
   [버튼] ← 스크롤로 밖으로 나감 → isIntersecting: false</code></pre><h3 id="entry란">entry란?</h3>
<p>observer가 감시하는 요소 하나하나의 상태 정보</p>
<blockquote>
</blockquote>
<pre><code class="language-ts">const observer = new IntersectionObserver(
  (entries) =&gt; {
    // entries = 감시 중인 요소들의 상태 배열
    // observer.observe(buttonRef.current) 하나만 등록했으니
    // entries[0] = 버튼 상태
    const entry = entries[0]; // = ([entry]) 구조분해
&gt;
    entry.isIntersecting  // 보이면 true, 안보이면 false
    entry.target          // 감시 대상 DOM 요소 (버튼)
    entry.intersectionRatio // 얼마나 보이는지 비율 (0.0 ~ 1.0)
  }
);</code></pre>
<p>참고) <a href="https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver">공식문서 - IntersectionObserver</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 화살표 함수 뒤 괄호 정리]]></title>
            <link>https://velog.io/@purple11_11/TIL-%ED%99%94%EC%82%B4%ED%91%9C-%ED%95%A8%EC%88%98-%EB%92%A4-%EA%B4%84%ED%98%B8-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@purple11_11/TIL-%ED%99%94%EC%82%B4%ED%91%9C-%ED%95%A8%EC%88%98-%EB%92%A4-%EA%B4%84%ED%98%B8-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Tue, 19 Aug 2025 01:10:42 GMT</pubDate>
            <description><![CDATA[<p>항상 화살표 함수를 쓸 때마다 뒤 괄호 쓰는게 헷갈려서 
정리하고 기억하기 위해 기록!</p>
<hr>
<h3 id="1-중괄호--→-코드-블록">1. 중괄호 {} → 코드 블록</h3>
<blockquote>
</blockquote>
<pre><code>.map((x) =&gt; {
  // 여러 줄 코드 가능
  const double = x * 2;
  return double;  // 반드시 return 필요
});</code></pre><p>{} 는 “코드 블록”</p>
<p>return 없으면 무조건 undefined</p>
<p>여러 줄 쓸 때 주로 사용</p>
<br />

<h3 id="2-소괄호---중괄호--→-객체-리터럴-반환">2. 소괄호 () + 중괄호 {} → 객체 리터럴 반환</h3>
<blockquote>
</blockquote>
<pre><code>.map((x) =&gt; ({ value: x * 2 }))</code></pre><p>그냥 {} 쓰면 블록으로 인식 → undefined</p>
<p>객체를 반환하려면 반드시 ()로 감싸야 함</p>
<p><strong>❌ 잘못된 예시</strong></p>
<blockquote>
</blockquote>
<pre><code>.map((x) =&gt; { value: x * 2 }) // 블록으로 해석 → undefined</code></pre><br />

<h3 id="3-아무-괄호-없음-→-한-줄-표현식">3. 아무 괄호 없음 → 한 줄 표현식</h3>
<blockquote>
</blockquote>
<pre><code>.map((x) =&gt; x * 2)</code></pre><p>한 줄 계산식은 자동으로 반환(return 생략 가능)</p>
<p>단순한 값 변환할 때 편리</p>
<br />

<hr>
<h3 id="🧠-기억-꿀팁">🧠 기억 꿀팁</h3>
<p><strong>{ } = 블록</strong> → return 꼭 써야 함</p>
<p><strong>( { } ) = 객체</strong> → return 없이도 객체 반환</p>
<p><strong>그냥 값</strong> → return 생략 가능</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] xlsx-js-style 사용해서 엑셀(Excel) 다운로드 기능 구현 및 스타일 적용]]></title>
            <link>https://velog.io/@purple11_11/Next.js-xlsx-js-style-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%97%91%EC%85%80Excel-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84-%EB%B0%8F-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@purple11_11/Next.js-xlsx-js-style-%EC%82%AC%EC%9A%A9%ED%95%B4%EC%84%9C-%EC%97%91%EC%85%80Excel-%EB%8B%A4%EC%9A%B4%EB%A1%9C%EB%93%9C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84-%EB%B0%8F-%EC%8A%A4%ED%83%80%EC%9D%BC-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Wed, 18 Jun 2025 07:27:20 GMT</pubDate>
            <description><![CDATA[<h2 id="⚒️-설치">⚒️ 설치</h2>
<blockquote>
</blockquote>
<pre><code>yarn add xlsx-js-style</code></pre><p>스타일 없이 엑셀 다운로드만 하려고 하면 간단히 구현 가능하다
실제로 몇 분 안걸렸다!</p>
<p>그때는 스타일 줄 생각을 안하고 있어서 xlsx로 진행했는데 xlsx-js-style이 xlsx 포크해서 개발된거라 api가 거의 동일하다</p>
<p>만약 스타일 적용 안하고 간단하게만 사용할 예정이라면 <a href="https://docs.sheetjs.com/docs/#export-an-html-table-to-excel-xlsx">xlsx 공식문서</a></p>
<blockquote>
</blockquote>
<pre><code>npm i --save https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz
&gt;
or
&gt;
yarn add https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz</code></pre><p>아래는 추출 할 데이터이다.</p>
<blockquote>
</blockquote>
<pre><code class="language-js">const header = [
  &#39;배송지 구분 번호&#39;,
  &#39;배송지명&#39;,
  &#39;배송지 주소&#39;,
  &#39;수령인&#39;,
  &#39;택배사&#39;,
  &#39;송장번호&#39;,
  &#39;비고&#39;,
];
&gt;
const body = [
  {
    deliveryTypeNumber: &#39;1&#39;,
    deliveryName: &#39;서울&#39;,
    deliveryAddress: &#39;서울시 강남구&#39;,
    recipient: &#39;홍길동&#39;,
    courierCompany: &#39;CJ대한통운&#39;,
    trackingNumber: &#39;1234567890&#39;,
    notes: &#39;특별 요청 없음&#39;,
  },
  {
    deliveryTypeNumber: &#39;2&#39;,
    deliveryName: &#39;부산&#39;,
    deliveryAddress: &#39;부산시 해운대구&#39;,
    recipient: &#39;김철수&#39;,
    courierCompany: &#39;한진택배&#39;,
    trackingNumber: &#39;0987654321&#39;,
    notes: &#39;빠른 배송 요청&#39;,
  },
  {
    deliveryTypeNumber: &#39;3&#39;,
    deliveryName: &#39;대구&#39;,
    deliveryAddress: &#39;대구시 수성구&#39;,
    recipient: &#39;이영희&#39;,
    courierCompany: &#39;롯데택배&#39;,
    trackingNumber: &#39;1122334455&#39;,
    notes: &#39;문 앞에 놓아주세요&#39;,
  },
];</code></pre>
<hr>
<h2 id="💡-사용법">💡 사용법</h2>
<h3 id="기본-엑셀-추출">기본 엑셀 추출</h3>
<blockquote>
</blockquote>
<pre><code class="language-js">import { utils, WorkBook, writeFileXLSX } from &#39;xlsx&#39;; // xlsx-js-style 동일
&gt;
const ExportExcel = () =&gt; {
  const handleExport = useCallback(() =&gt; {
    const wb:WorkBook  = utils.table_to_book([...header,...body]);
    writeFileXLSX(wb, &#39;파일명.xlsx&#39;);
  }, []);
  &gt;
    return (
     &lt;div className=&quot;w-120pxr&quot;&gt;
       &lt;Button onClick={handleExport}&gt;Export&lt;/Button&gt;
     &lt;/div&gt;
  );
};
&gt;
export default ExportExcel;</code></pre>
<p>간단히는 이렇게 가능하다</p>
<h3 id="시트명-커스텀-추출">시트명 커스텀 추출</h3>
<p>만약 시트를 생성해서 시트명과 함께 파일로 추출하고 싶다면</p>
<blockquote>
</blockquote>
<pre><code class="language-js">import { utils, WorkBook, writeFileXLSX } from &#39;xlsx&#39;; // xlsx-js-style 동일
&gt;
const ExportExcel = () =&gt; {
  const handleExport = useCallback(() =&gt; {
    const ws = utils.aoa_to_sheet([...header,...body]);
    &gt;
    const wb: WorkBook = utils.book_new();
    utils.book_append_sheet(wb, ws, &#39;원하는 시트명&#39;);
    writeFileXLSX(wb, &#39;파일명.xlsx&#39;);
  }, []);
  &gt;
    return (
     &lt;div className=&quot;w-120pxr&quot;&gt;
       &lt;Button onClick={handleExport}&gt;Export&lt;/Button&gt;
     &lt;/div&gt;
  );
};
&gt;
export default ExportExcel;</code></pre>
<p>이렇게 시트 생성 후 파일 추출할 수도 있다.</p>
<h3 id="스타일-적용">스타일 적용</h3>
<p>근데 스타일을 주려고 하니까 xlsx에서는 지원하지 않는게 많아서(아마 pro를 사용해야 하는 듯;)</p>
<p><code>xlsx-js-style</code>라이브러리를 사용했다</p>
<p>설치방법은 최상단에 적어뒀으니까 생략하고</p>
<blockquote>
</blockquote>
<pre><code class="language-ts">import { utils, WorkBook , writeFile } from &#39;xlsx-js-style&#39;;
&gt;
const ExportExcel = () =&gt; {
  const handleExport = useCallback(() =&gt; {
    const ws = utils.aoa_to_sheet([...header,...body]);
    &gt;
    const borderStyle = {
      top: { style: &#39;thin&#39;, color: { rgb: &#39;000000&#39; } },
      bottom: { style: &#39;thin&#39;, color: { rgb: &#39;000000&#39; } },
      left: { style: &#39;thin&#39;, color: { rgb: &#39;000000&#39; } },
      right: { style: &#39;thin&#39;, color: { rgb: &#39;000000&#39; } },
    };
    &gt;
     // 헤더 스타일
       header.forEach((_, colIdx) =&gt; {
      const addr = utils.encode_cell({ r: 0, c: colIdx });
      if (ws[addr]) {
        ws[addr].s = {
          fill: { fgColor: { rgb: &#39;D9D9D9&#39; } },
          font: { bold: true },
          border: borderStyle,
          alignment: { horizontal: &#39;center&#39;, vertical: &#39;center&#39; },
        };
      }
    });
&gt;
   // body 스타일
    for (let r = 1; r &lt; wsData.length; r++) {
      for (let c = 0; c &lt; header.length; c++) {
        const addr = utils.encode_cell({ r, c });
        if (ws[addr]) {
          ws[addr].s = {
            border: borderStyle,
            alignment: { vertical: &#39;center&#39;, horizontal: &#39;left&#39; },
          };
        }
      }
    }
&gt;
  // 열 너비
    const colWidths = [100, 80, 300, 80, 100, 80, 120, 150];
    ws[&#39;!cols&#39;] = header.map((_, idx) =&gt; ({
      wpx: colWidths[idx] ?? 80,
    }));
      &gt;
    const wb: WorkBook = utils.book_new();
    utils.book_append_sheet(wb, ws, &#39;원하는 시트명&#39;);
    writeFile(wb, &#39;파일명.xlsx&#39;);
  }, []);
      &gt;
    return (
     &lt;div className=&quot;w-120pxr&quot;&gt;
       &lt;Button onClick={handleExport}&gt;Export&lt;/Button&gt;
     &lt;/div&gt;
  );
};
&gt;
export default ExportExcel;</code></pre>
<hr>
<h2 id="⚠️-주의-사항">⚠️ 주의 사항</h2>
<p>여기서 스타일 적용을 위해 <strong>가<del>~</del>장 중요한건</strong> 
위의 <code>xlsx</code> 라이브러리 사용할때와 차이점이 있다.</p>
<p>그 전에는 스타일을 신경쓰지 않아서 공식문서(<a href="https://docs.sheetjs.com/docs/#export-an-html-table-to-excel-xlsx)%EC%97%90">https://docs.sheetjs.com/docs/#export-an-html-table-to-excel-xlsx)에</a> 나온대로 <code>writeFileXLSX</code>를 사용했다
<img src="https://velog.velcdn.com/images/purple11_11/post/5f242e87-c3ca-42e0-8f45-df14e103a616/image.png" alt=""></p>
<p>그런데 <code>xlsx-js-style</code>를 사용해도 스타일이 적용 안되고, 간단한 예제를 적용해봐도 안돼서 엄청 삽질했다..</p>
<p>그러던중 공식문서(<a href="https://github.com/gitbrent/xlsx-js-style/?tab=readme-ov-file#cell-style-example">https://github.com/gitbrent/xlsx-js-style/?tab=readme-ov-file#cell-style-example</a>) 예제를 따라하는데 <strong><code>writeFile</code> 를 사용</strong>하는거였다..</p>
<p>이렇게 간단히 해결되는 문제였다니.. 문서를 좀 더 꼼꼼히 봐야할 필요가 있다..</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js v14, Typescript] ReferenceError: File is not defined (feat. node v18)]]></title>
            <link>https://velog.io/@purple11_11/Next.js-v14-Typescript-ReferenceError-File-is-not-defined-feat.-node-v18</link>
            <guid>https://velog.io/@purple11_11/Next.js-v14-Typescript-ReferenceError-File-is-not-defined-feat.-node-v18</guid>
            <pubDate>Mon, 21 Apr 2025 08:27:50 GMT</pubDate>
            <description><![CDATA[<p>node 18 버전에서는 File 타입을 사용할 경우 빌드 시 
<code>ReferenceError: File is not defined</code> 오류가 발생한다.</p>
<p>노드 버전을 20 이상으로 바꾸면 해결되긴 한다.</p>
<p>근데 당장 바꿀 수 없어 다른 방식으로 해결해야 했다.</p>
<blockquote>
</blockquote>
<h3 id="문제-코드">문제 코드</h3>
<pre><code class="language-js">import { z } from &#39;zod&#39;;
&gt;
const FileWithType = z.object({
  file: z.instanceof(File),
  type: z.enum([&#39;PC&#39;, &#39;MOBILE&#39;]),
});</code></pre>
<p> zod로 타입 검증을 하는 도중, 브라우저 전용 객체인 File이 서버 환경(node v18)에서는 정의되지 않아 빌드 시 발생한 오류</p>
<blockquote>
</blockquote>
<h3 id="해결한-코드">해결한 코드</h3>
<pre><code class="language-js">import { z } from &#39;zod&#39;;
&gt;
const FileWithType = z.object({
  file: typeof File !== &#39;undefined&#39; ? z.instanceof(File) : z.any(),
  type: z.enum([&#39;PC&#39;, &#39;MOBILE&#39;]),
});</code></pre>
<p>=&gt; 브라우저 환경에서만 File 검증</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[UI] Safari에서 calc(%-px) 안되는 경우 (tailwind)]]></title>
            <link>https://velog.io/@purple11_11/UI-Safari%EC%97%90%EC%84%9C-calc-px-%EC%95%88%EB%90%98%EB%8A%94-%EA%B2%BD%EC%9A%B0-tailwind</link>
            <guid>https://velog.io/@purple11_11/UI-Safari%EC%97%90%EC%84%9C-calc-px-%EC%95%88%EB%90%98%EB%8A%94-%EA%B2%BD%EC%9A%B0-tailwind</guid>
            <pubDate>Tue, 15 Apr 2025 03:08:20 GMT</pubDate>
            <description><![CDATA[<h3 id="문제상황">문제상황</h3>
<div style="display: flex; flex-direction: row; gap: 10px;">
<img src="https://velog.velcdn.com/images/purple11_11/post/52af255f-c47c-423f-b02c-49eef9c28ce5/image.png" width="350" alt="이미지 설명">
<img src="https://velog.velcdn.com/images/purple11_11/post/069981c6-1387-40cb-8ad5-43e0278ec062/image.jpeg" width="300" alt="이미지 설명">
</div>

<p>&quot;인증번호 받기&quot; 버튼 클릭 후 &quot;재전송&quot;, &quot;확인&quot; 버튼이 있는 input(InputWithButton 컴포넌트)의 width가 깨지는 현상이 발생</p>
<h3 id="해결방법">해결방법</h3>
<blockquote>
</blockquote>
<p>기존 코드</p>
<pre><code> &lt;div className=&quot;flex w-full gap-10pxr&quot;&gt;
        &lt;div className=&quot;w-[calc(100% - 7.125rem)]&quot;&gt;
          &lt;Input/&gt;
        &lt;/div&gt;
        &lt;div className=&quot;w-114pxr&quot;&gt;
          &lt;SquareButton&gt;
            {buttonText}
          &lt;/SquareButton&gt;
        &lt;/div&gt;
      &lt;/div&gt;</code></pre><p>safari에서 calc가 안되는 것 같아 수정이 필요했다.</p>
<blockquote>
</blockquote>
<p>수정한 코드</p>
<pre><code> &lt;div className=&quot;flex w-full gap-10pxr&quot;&gt;
        &lt;div className=&quot;min-w-0 flex-1&quot;&gt;
          &lt;Input/&gt;
        &lt;/div&gt;
        &lt;div className=&quot;w-114pxr flex-shrink-0&quot;&gt;
          &lt;SquareButton&gt;
            {buttonText}
          &lt;/SquareButton&gt;
        &lt;/div&gt;
      &lt;/div&gt;</code></pre><p>&quot;flex-1&quot;로 채워주니 UI가 깨지지 않고 잘 나왔다!
<img src="https://velog.velcdn.com/images/purple11_11/post/41065b0e-8713-4671-baae-6e82c7337502/image.jpeg" width="300" alt="이미지 설명"></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[safari 스크롤 바운스로 인한 이슈]]></title>
            <link>https://velog.io/@purple11_11/safari-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EB%B0%94%EC%9A%B4%EC%8A%A4%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@purple11_11/safari-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EB%B0%94%EC%9A%B4%EC%8A%A4%EB%A1%9C-%EC%9D%B8%ED%95%9C-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Wed, 09 Apr 2025 07:37:56 GMT</pubDate>
            <description><![CDATA[<h3 id="구현하려한-화면">구현하려한 화면</h3>
<p>리스트 페이지에 두 가지 탭이 있는데
탭은 헤더 아래에 sticky로 붙이고,
스크롤 시 h1 태그 사라지고 모바일 헤더에 centerTitle 들어가게 하기</p>
<blockquote>
</blockquote>
<pre><code>      &lt;MoHeader
          centerTitle={isFixed ? &#39;헤더 타이틀&#39; : undefined}
        /&gt;</code></pre><h3 id="문제-상황">문제 상황</h3>
<p>safari에서 스크롤 했을 때 헤더 타이틀이 깜빡거리는 문제가 발생</p>
<p>❌ 문제 코드 </p>
<blockquote>
</blockquote>
<pre><code class="language-js">  const tabRef = useRef&lt;HTMLDivElement&gt;(null);
&gt;
  useEffect(() =&gt; {
    const handleScroll = () =&gt; {
      if (tabRef.current) {
        const { top } = tabRef.current.getBoundingClientRect();
        setIsFixed(top === 56);
      }
    };
&gt;
    window.addEventListener(&#39;scroll&#39;, handleScroll);
    return () =&gt; {
      window.removeEventListener(&#39;scroll&#39;, handleScroll);
    };
  }, [setIsFixed, tabRef]);</code></pre>
<p>원인은 사파리 브라우저에서 스크롤 시 바운스 현상이 나타나서
<code>.getBoundingClientRect()</code> 메서드로 잡은 위치가 계속 변해서 발생한 문제였다</p>
<p>sticky로 고정한 탭의 위치를 알면 <code>.getBoundingClientRect()</code> 메서드를 사용하지 않아도 되는데 더 간단한 방법을 떠올리지 못해서 계속 삽질만하다가 몇시간을 보냈다..</p>
<p>🆗 수정한 코드</p>
<blockquote>
</blockquote>
<pre><code class="language-js">  useEffect(() =&gt; {
    const handleScroll = () =&gt; {
      const top = window.scrollY;
      setIsFixed(top &gt;= 73);
    };
    &gt;
    window.addEventListener(&#39;scroll&#39;, handleScroll);
    return () =&gt; {
      window.removeEventListener(&#39;scroll&#39;, handleScroll);
    };
  }, [setIsFixed]);</code></pre>
<p>useRef 사용하지 않고 <code>window.scrollY</code>로 위치를 찾아 고정해주니 바운스 현상에 영향을 받지 않고 타이틀도 깜빡이지 않았다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[next 14, typescript] TinyMCE 웹 에디터 라이브러리 self-hosted로 무료 사용하기 (+ 다중 이미지 업로드)]]></title>
            <link>https://velog.io/@purple11_11/Next.js-14-Typescript-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-%EC%A0%81%EC%9A%A9%EA%B8%B0-TinyMCE-%EB%8B%A4%EC%A4%91-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</link>
            <guid>https://velog.io/@purple11_11/Next.js-14-Typescript-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-%EC%A0%81%EC%9A%A9%EA%B8%B0-TinyMCE-%EB%8B%A4%EC%A4%91-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C</guid>
            <pubDate>Sat, 26 Oct 2024 09:15:01 GMT</pubDate>
            <description><![CDATA[<blockquote>
</blockquote>
<p> 프로젝트의 개발 환경 </p>
<ul>
<li>Next.js v14</li>
<li>Typescript v5</li>
<li>react v18</li>
<li>ES 모듈</li>
</ul>
<p>TinyMCE를 적용하는 방법은</p>
<ol>
<li>클라우드 호스팅 (월 1000회 로드 무료, 이후 유료)</li>
<li>패키지 매니저로 설치하는 셀프 호스팅 (무료)</li>
<li>zip 파일에서 추출하는 셀프 호스팅 (무료)</li>
</ol>
<p>이렇게 세 가지 방법이 있고, 나는 <code>패키지 매니저</code>를 사용했다.</p>
<p><a href="https://www.tiny.cloud/docs/tinymce/latest/react-pm-host/">공식 문서 - 패키지 매니저를 통한 셀프 호스팅</a></p>
<p><code>TinyMCE</code>의 경우 에디터 사용하는 것 보단 나중에 환경 셋팅하는 데 더 시간이 많이 들었다.
참고할 공식 문서의 자료가 여기저기 있는 느낌?이라 <code>React</code> 기반 기술문서를 제대로 활용하지 못했다. 
그러다 보니 js 기반으로 된 내용을 typescript에 맞게 환경을 수정하는 과정이 필요했다.</p>
<hr>
<h3 id="1-tinymce-tinymcetinymce-react-fs-extra-패키지-설치">1. <code>tinymce</code>, <code>@tinymce/tinymce-react</code>, <code>fs-extra</code> 패키지 설치</h3>
<blockquote>
</blockquote>
<pre><code>yarn add tinymce @tinymce/tinymce-react fs-extra</code></pre><h3 id="2-프로젝트postinstallmjs-생성">2. {프로젝트}/postinstall.mjs 생성</h3>
<p>TinyMCE 디렉토리를 생성하기 위해 프로젝트 root 경로에 postinstall 스크립트 설정</p>
<blockquote>
</blockquote>
<pre><code class="language-js">import fse from &#39;fs-extra&#39;;
import path from &#39;path&#39;;
import { fileURLToPath } from &#39;url&#39;;
import { dirname } from &#39;path&#39;;
&gt;
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename); 
&gt;
fse.emptyDirSync(path.join(__dirname, &#39;public&#39;, &#39;tinymce&#39;));
fse.copySync(
  path.join(__dirname, &#39;node_modules&#39;, &#39;tinymce&#39;),
  path.join(__dirname, &#39;public&#39;, &#39;tinymce&#39;),
  { overwrite: true }
);
&gt;
console.log(&#39;✅ TinyMCE files copied successfully!&#39;);</code></pre>
<p><strong>ES 모듈을 사용한다면 이 코드를 참고하는 걸 추천한다.</strong>
공식문서의 예시를 따랐는데, CI/CD 환경에서 <code>import.meta.dirname</code>을 사용하는 빌드 스크립트가 실패하는 현상이 발생했다.
Node.js에서 ES Module(ESM)을 사용할 때는 현재 디렉토리 경로를 얻기 위해 <code>import.meta.url</code>을 사용해야 한다.</p>
<h3 id="3-packagejson에-postinstall-스크립트와-gitignore에-publictinymce-디렉토리를-추가한다">3. package.json에 <code>postinstall</code> 스크립트와 .gitignore에 /public/tinymce 디렉토리를 추가한다.</h3>
<blockquote>
</blockquote>
<p><code>yarn install</code> 실행 시 자동으로 <code>postinstall</code> 실행된다. </p>
<pre><code class="language-json">// package.json
&gt;
{
  &quot;scripts&quot;: {
    &quot;postinstall&quot;: &quot;node ./postinstall.mjs&quot;
  }
}</code></pre>
<blockquote>
</blockquote>
<pre><code>// .gitignore
&gt;
/public/tinymce/</code></pre><h3 id="4-터미널에서-yarn-postinstall-실행">4. 터미널에서 yarn postinstall 실행</h3>
<h3 id="5-에디터-컴포넌트-생성-및-적용">5. 에디터 컴포넌트 생성 및 적용</h3>
<p><a href="https://www.tiny.cloud/docs/tinymce/latest/react-ref/">공식 문서 - React 기반 통합 기술 문서</a> 를 참고하면 에디터 컴포넌트 props를 사용하기 수월할 것이다.</p>
<h4 id="내가-수정-및-커스텀-한-목록">[내가 수정 및 커스텀 한 목록]</h4>
<ol>
<li><p>라이선스키 <code>GPL</code> 사용 (대소문자 구분 ❌)
공식문서에 관련 자료가 있으니 본인 상황에 맞게 사용하면 된다.
<a href="https://www.tiny.cloud/docs/tinymce/latest/license-key/">공식 문서 - 라이선스키</a></p>
</li>
<li><p><code>initialValue</code> 대신 <code>value</code>, <code>onEditorChange</code> 사용
에디터에 내용을 작성하거나 엔터 입력했을 때 커서가 제대로 동작하지 않아서 controlled-component 방식을 사용했다.
하지만 TinyMCE는 uncontrolled-component 기반으로 설계되었기 때문에 나중에 리팩토링 해도 좋을 것 같다.
<a href="https://www.tiny.cloud/docs/tinymce/latest/react-ref/#using-the-tinymce-react-component-as-a-uncontrolled-component">공식 문서 - uncontrolled-component</a></p>
</li>
<li><p>init props에 사용할 menubar 추가
다양한 기능을 사용하고자 <code>menubar: false</code> 대신 원하는 메뉴바 탭 이름을 작성했다.
메뉴바 탭 이름 클릭시 드롭다운으로 나오는 메뉴들 또한 커스텀이 가능하다.
메뉴와 툴바에서 중복될 필요 없는 부분도 제거했다.</p>
</li>
<li><p>다중 이미지 업로드 핸들러
 기본 이미지 업로드 기능은 한 개의 이미지 파일만 업로드 가능한데
 여러 이미지를 업로드 하기 위해 <code>handleMultipleImages</code> 함수를 구현했다.</p>
<p> 그냥 이미지를 첨부하면 base64로 인코딩 돼 엄<del>~</del>청 긴 문자열로 반환된다.
 이 문자열을 서버에 저장하면 DB 용량을 많이 차지할 뿐만 아니라, 
 페이지 로딩 시 불필요하게 긴 문자열을 파싱해야 해서 성능에도 좋지 않다.</p>
<p> 이 함수는 
 파일 정보가 담긴 formData를 서버에 보내면,
 서버에서 AWS S3에 이미지를 저장하고 생성된 URL을 반환해 준다.
 이 반환된 url로 이미지 태그를 생성해서 에디터에 추가한다.</p>
</li>
</ol>
<p>init props에 setup 키워드를 사용해 다중 이미지 업로드, 드래그 앤 드랍 이미지 업로드 기능을 커스텀해줬다.</p>
<blockquote>
</blockquote>
<p>공식 문서 예시를 참고해 커스텀 한 코드이다. </p>
<pre><code class="language-tsx">import { useRef } from &#39;react&#39;;
&gt;
declare global {
  interface Window {
    tinymce: any;
  }
}
import { Editor } from &#39;@tinymce/tinymce-react&#39;;
import { Editor as TinyMCEEditor } from &#39;tinymce&#39;;
import ProductAPI from &#39;@/apis/product&#39;;
&gt;
interface Props {
  value: string;
  onChange: (value: string) =&gt; void;
}
&gt;
export default function WebEditor({ value, onChange }: Props) {
  const editorRef = useRef&lt;TinyMCEEditor | null&gt;(null);
  &gt;
    // 다중 이미지 업로드 핸들러
  const handleMultipleImages = async (files: FileList) =&gt; {
    try {
      const formData = new FormData();
      &gt;
      Array.from(files).forEach((file, index) =&gt; {
        formData.append(`descriptionImages[${index}]`, file); // req 객체에맞게 formData 작성
      });
&gt;
   // 서버에 이미지 전송하고, 반환된 S3 URL 배열을 통해 이미지 태그 생성
      const { data, status } = await /*API 요청 경로*/; 
&gt;
      if (status === 200 &amp;&amp; editorRef.current) {
        const imageHtml = data
          .map((url: string) =&gt; `&lt;img src=&quot;${url}&quot; alt=&quot;uploaded image&quot; /&gt;`)
          .join(&#39;&lt;br /&gt;&#39;);
&gt;
        editorRef.current.insertContent(imageHtml);
      }
    } catch (error) {
      console.error(&#39;Image upload failed:&#39;, error);
    }
  };
&gt;
  return (
    &lt;&gt;
      &lt;Editor
        tinymceScriptSrc=&quot;/tinymce/tinymce.min.js&quot;
        licenseKey=&quot;gpl&quot;
        onInit={(_evt, editor) =&gt; (editorRef.current = editor)}
        value={value ?? &#39;&#39;}
        onEditorChange={(content) =&gt; onChange(content)}
        init={{
          height: 500,
          menubar: &#39;file edit view insert format tools help&#39;, // except: table
          menu: {
            file: {
              title: &#39;File&#39;,
              items: &#39;newdocument restoredraft | preview&#39;,
            },
            edit: {
              title: &#39;Edit&#39;,
              items: &#39;undo redo | cut copy paste | selectall | searchreplace&#39;,
            },
            view: {
              title: &#39;View&#39;,
              items:
                &#39;code | visualaid visualchars visualblocks | spellchecker | preview fullscreen&#39;,
            },
            insert: {
              title: &#39;Insert&#39;,
              items:
                &#39;image link addcomment pageembed codesample inserttable | math | charmap emoticons hr | pagebreak nonbreaking anchor | insertdatetime&#39;,
            },
            format: {
              title: &#39;Format&#39;,
              items:
                &#39;bold italic underline strikethrough superscript subscript codeformat | styles blocks fontfamily fontsize align lineheight | forecolor backcolor | language | removeformat&#39;,
            },
            tools: {
              title: &#39;Tools&#39;,
              items:
                &#39;spellchecker spellcheckerlanguage | a11ycheck code wordcount&#39;,
            },
            help: { title: &#39;Help&#39;, items: &#39;help&#39; },
          },
          plugins: [
            &#39;advlist&#39;,
            &#39;autolink&#39;,
            &#39;lists&#39;,
            &#39;link&#39;,
            &#39;image&#39;,
            &#39;charmap&#39;,
            &#39;anchor&#39;,
            &#39;searchreplace&#39;,
            &#39;visualblocks&#39;,
            &#39;code&#39;,
            &#39;fullscreen&#39;,
            &#39;insertdatetime&#39;,
            &#39;preview&#39;,
            &#39;help&#39;,
            &#39;wordcount&#39;,
          ],
          toolbar:
            &#39;undo redo | blocks | &#39; +
            &#39;bold italic forecolor removeformat | alignleft aligncenter &#39; +
            &#39;alignright alignjustify | bullist numlist outdent indent | &#39; +
            &#39;multipleimages  |  | preview |help&#39;,
          content_style:
            &#39;body { font-family:Helvetica,Arial,sans-serif; font-size:14px }&#39;,
          setup: (editor) =&gt; {
            // `image` 아이콘과 `multipleimages` 이름으로 다중 이미지 업로드 기능 커스텀
            editor.ui.registry.addButton(&#39;multipleimages&#39;, {
              icon: &#39;image&#39;,
              onAction: () =&gt; {
                const input = document.createElement(&#39;input&#39;);
                input.setAttribute(&#39;type&#39;, &#39;file&#39;);
                input.setAttribute(&#39;multiple&#39;, &#39;true&#39;);
                input.setAttribute(&#39;accept&#39;, &#39;image/*&#39;);
&gt;
                input.onchange = async (e) =&gt; {
                  const files = (e.target as HTMLInputElement).files;
                  if (files &amp;&amp; files.length &gt; 0) {
                    await handleMultipleImages(files);
                  }
                };
&gt;
                input.click();
              },
            });
            // 드래그 앤 드랍 다중 이미지 업로드
            editor.on(&#39;drop&#39;, async (e) =&gt; {
              const dataTransfer = e.dataTransfer;
              if (dataTransfer &amp;&amp; dataTransfer.files.length &gt; 0) {
                e.preventDefault();
&gt;
                // 이미지 파일만 필터링
                const imageFiles = Array.from(dataTransfer.files).filter(
                  (file) =&gt; file.type.startsWith(&#39;image/&#39;)
                );
&gt;
                if (imageFiles.length &gt; 0) {
                  await handleMultipleImages(
                    Object.assign(imageFiles, {
                      item: (i: number) =&gt; imageFiles[i],
                    })
                  );
                }
              }
            });
          },
        }}
      /&gt;
    &lt;/&gt;
  );
}</code></pre>
<p>이렇게 하면 에디터가 생성이 된다!
<img src="https://velog.velcdn.com/images/purple11_11/post/8c7dcfa2-1424-4402-85f4-5a4e9559687c/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js 14, Typescript] 웹 에디터 적용기 (React-Quill, Toast-ui, TinyMCE)]]></title>
            <link>https://velog.io/@purple11_11/Next.js-14-Typescript-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-%EC%A0%81%EC%9A%A9%EA%B8%B0-React-Quill-Toast-ui-TinyMCE</link>
            <guid>https://velog.io/@purple11_11/Next.js-14-Typescript-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-%EC%A0%81%EC%9A%A9%EA%B8%B0-React-Quill-Toast-ui-TinyMCE</guid>
            <pubDate>Fri, 25 Oct 2024 13:18:57 GMT</pubDate>
            <description><![CDATA[<p>상품 상세 페이지를 작성하기 위한 에디터가 필요했고, 험난했던 과정에 대해 순차적으로 정리하고자 한다.</p>
<h2 id="react-quill---공식-문서">React-Quill - <a href="https://quilljs.com/docs/quickstart">공식 문서</a></h2>
<p>어떤 에디터를 사용할 지 찾아본 후 나름대로 <code>React-Quill</code>로 첫 선택을 했다.</p>
<p>그 이유는</p>
<h4 id="장점">장점</h4>
<ul>
<li>react와 호환성이 좋다.</li>
<li>이미지 사이즈 조절 가능</li>
<li>무료 라이브러리</li>
</ul>
<h4 id="단점">단점</h4>
<ul>
<li>이미지 핸들러가 없다.</li>
</ul>
<p>그 외에도 자유도가 높아 커스텀이 용이하다는 장점이 있는 것 같다.
하지만 나는 신입 개발자이고 웹 에디터를 사용해본 경험이 없기 때문에
어떤걸 커스텀 해야하는지 모르는 상태였지만
참고 자료가 많은 것 같다는 이유로 용감하게도 첫 선택을 하게 되었다.</p>
<h4 id="사용하지-않은-이유">사용하지 않은 이유</h4>
<p>라이브러리를 설치하고 호기롭게 시작했는데, 얼마 안가서</p>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/47320703-4a3d-4fc8-8764-552bd7d63dd6/image.png" alt=""></p>
<p>개발자 도구에 이런 오류가 뜨면서 잘 동작하지 않았다.
찾아보니 지원되지 않는 DOM 이벤트인 &#39;DOMNodeInserted&#39;를 사용하고 있기 때문에 발생하는 오류인 것 같았다. 
다들 어떻게 적용하고 잘 쓰는지.. 대단하다</p>
<p>어떻게 해결해야할 지 잘 파악이 되지 않아서 그 다음 대안이었던 <code>Toast-ui/editor</code>로 빠르게 갈아탔다.</p>
<hr>
<h2 id="toast-uieditor---공식-문서">Toast-ui/editor - <a href="https://nhn.github.io/tui.editor/latest/">공식 문서</a></h2>
<p>사실 <code>react-quill</code> 보단 이 에디터를 더 사용하고 싶었다.
UI가 귀엽고 자료 조사 할 때 장점이 많아 보였다. 그리고 v3가 출시되고 사용법이 직관적이면서 많이 개선된 것 같아 자신감이 뿜뿜했다.</p>
<h4 id="장점-1">장점</h4>
<ul>
<li>한국어 가이드 제공 및 한글 지원</li>
<li>다크모드 지원 (안쓸거같긴했음)</li>
<li>Markdown, wysiwyg 둘 다 지원</li>
<li>무료 라이브러리</li>
</ul>
<h4 id="단점-1">단점</h4>
<ul>
<li>이미지 사이즈 조절 불가능</li>
<li>SSR 미지원 (React v18도 지원 안한다고 하는데 확실하지 않음)</li>
</ul>
<p>이미지 사이즈 조절이 불가능하고, Next.js 사용중인데 SSR 미지원 한다고하니 우선 순위에서 내려갔었다.
근데 이미지 사이즈 조절 기능이 필수는 아니었고, 에디터는 CSR 환경에서만 사용하면 될 것 같아서 <code>React-Quill</code>의 대안으로 선택했다.</p>
<h4 id="사용하지-않은-이유-1">사용하지 않은 이유</h4>
<p>상품 상세 페이지를 작성하기 위한 에디터라서 정렬과 폰트 사이즈 조절 기능이 필요했는데
마크다운 기반의 에디터라 이를 제공하지 않았다.
마크다운 에디터로 제한하고, inline style을 적용하게끔 직접 구현하려고 시도했는데,
미리보기 뷰어에서는 style 적용이 안돼서 또 다른 대안을 찾아 나서게 되었다...</p>
<p>근데 간단한 기능이 필요한 사람일 경우 적용하는 게 어렵지 않아서 <code>추천</code> 한다.
<img src="https://velog.velcdn.com/images/purple11_11/post/8be413d1-faaa-4891-aa4e-f82e11ba21e6/image.png" alt=""></p>
<hr>
<h2 id="tinymce---공식-문서">TinyMCE - <a href="https://www.tiny.cloud/docs/tinymce/latest/installation/">공식 문서</a></h2>
<p>가장 많이 알려진 두 에디터를 실패하고 유료 에디터까지 고려하던 중에 선택하게 되었는데
최종으로 이 에디터를 사용하게 되었다.</p>
<h4 id="장점-2">장점</h4>
<ul>
<li>기본 제공되는 기능이 엄청 다양하다.</li>
<li><code>Toast-ui/editor</code> 만큼 사용법이 간단하다.</li>
<li>클라우드 호스팅을 제공해서 언제나 최신 기술 사용이 가능하다.</li>
</ul>
<h4 id="단점-2">단점</h4>
<ul>
<li>클라우드 호스팅의 경우 에디터 로드 횟수에 따라 과금된다. - <a href="https://www.tiny.cloud/pricing/">요금 정책</a>
<img src="https://velog.velcdn.com/images/purple11_11/post/e6c353d6-4ca2-42c3-beec-7027ed445a77/image.png" alt=""></li>
</ul>
<p>과금에 대해 먼저 알고있었어서 에디터 사용하는 데 초반 고려대상이 아니었다.
하지만 이제서야 실제 유저가 사용해야 할 기능에 대해 파악하게 되어서
공식문서에 있는 수많은 기능을 보고 선택하지 않을 수 없었다.</p>
<p>아래는 사용 방법 및 커스텀 한 내용을 정리한 포스팅이다.</p>
<blockquote>
</blockquote>
<p><a href="https://velog.io/@purple11_11/Next.js-14-Typescript-%EC%9B%B9-%EC%97%90%EB%94%94%ED%84%B0-%EC%A0%81%EC%9A%A9%EA%B8%B0-TinyMCE-%EB%8B%A4%EC%A4%91-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%93%9C">[Next.js 14, Typescript] TinyMCE 웹 에디터 라이브러리 사용법 (+ 다중 이미지 업로드)</a></p>
<p>정렬, 폰트 사이즈 외에도 어지간한 기능은 다 제공하고 있었다. (물론 다 무료는 아님)</p>
<p>제공하는 무료 기능이 충분해서 선택하게 되었다.
그리고 이미 선택 한 후에 찾아봤지만 npm trends 가 제일 높다;;</p>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/c66a2f1b-8d9c-4e96-993b-ef03ed082051/image.png" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js] toast-ui editor v3 적용하기]]></title>
            <link>https://velog.io/@purple11_11/Next.js-toast-ui-editor-v3-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@purple11_11/Next.js-toast-ui-editor-v3-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 25 Oct 2024 02:56:28 GMT</pubDate>
            <description><![CDATA[<p>Next.js 14버전에 toast-ui의 editor 3버전을 적용해보려고 한다.</p>
<h2 id="install">install</h2>
<p>패키지 매니저는 yarn을 사용하고 있다</p>
<blockquote>
</blockquote>
<pre><code>yarn add @toast-ui/react-editor</code></pre><p>node_modules에 
<img src="https://velog.velcdn.com/images/purple11_11/post/6f4e9400-4fcc-4ab3-9d11-431112cca165/image.png" alt=""></p>
<p>추가됐는지 확인</p>
<h2 id="사용하기">사용하기</h2>
<blockquote>
</blockquote>
<p>WebEditor.tsx</p>
<pre><code class="language-tsx">import { Editor } from &#39;@toast-ui/react-editor&#39;;
import &#39;@toast-ui/editor/dist/toastui-editor.css&#39;;
&gt;
const WebEditor = () =&gt; {
  return (
    &lt;Editor
      previewStyle=&quot;tab&quot;
      height=&quot;300px&quot;
      initialEditType=&quot;wysiwyg&quot;
      initialValue=&quot;hello&quot;
      useCommandShortcut={true}
      hideModeSwitch={true}
      toolbarItems={[
        [&#39;heading&#39;, &#39;bold&#39;, &#39;italic&#39;, &#39;strike&#39;],
        [&#39;hr&#39;, &#39;quote&#39;],
        [&#39;ul&#39;, &#39;ol&#39;],
        [&#39;table&#39;, &#39;image&#39;, &#39;link&#39;],
        [&#39;scrollSync&#39;],
      ]}
    /&gt;
  );
};
&gt;
export default WebEditor;</code></pre>
<p>작성하고 원하는 페이지에 import해보면 아래처럼 적용이 된다.</p>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/6ac4fb37-053e-496b-943d-2354a4dec4bc/image.png" alt=""></p>
<p>근데 아래와 같이 500에러가 나서 찾아보니
<img src="https://velog.velcdn.com/images/purple11_11/post/24d8d6fd-a6b3-480f-a7df-061f7941e80e/image.png" alt=""></p>
<p>Toast UI Editor는 클라이언트 사이드에서만 실행되어야 하는 컴포넌트이기 때문에
dynamic import를 사용하여 클라이언트 사이드에서만 컴포넌트를 렌더링하도록 수정했다.
<a href="https://nextjs.org/docs/pages/building-your-application/optimizing/lazy-loading#with-no-ssr">Next.js 공식문서 - With no SSR</a> 참고</p>
<p>컴포넌트 import 한 페이지에 아래의 코드를 작성했더니 해결됐다.</p>
<blockquote>
</blockquote>
<pre><code>&#39;use client&#39;;
&gt;
import dynamic from &#39;next/dynamic&#39;;
&gt;
const WebEditor = dynamic(() =&gt; import(&#39;에디터 컴포넌트 위치&#39;), {
  ssr: false,
});</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[Husky, commitlint, lint-staged] commit convention 체크를 위한 ESlint 검사 자동화]]></title>
            <link>https://velog.io/@purple11_11/Husky-commitlint-lint-staged-commit-convention-%EC%B2%B4%ED%81%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-ESlint-%EA%B2%80%EC%82%AC-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@purple11_11/Husky-commitlint-lint-staged-commit-convention-%EC%B2%B4%ED%81%AC%EB%A5%BC-%EC%9C%84%ED%95%9C-ESlint-%EA%B2%80%EC%82%AC-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Fri, 11 Oct 2024 05:01:35 GMT</pubDate>
            <description><![CDATA[<p>팀원이 추가되면서 협업을 위해 commit convention을 체크해주는 라이브러리를 사용했다.
이에 대한 내용을 정리하고자 한다.</p>
<p>패키지 매니저는 <code>yarn</code>을 사용했는데 
공식문서에서 default로 제공하는 내용과 좀 차이가 있어 <code>npm</code>을 사용하는게 더 나을 것 같다.</p>
<p><a href="https://typicode.github.io/husky/get-started.html">Husky 공식문서 - Get Started</a></p>
<h2 id="get-started">Get Started</h2>
<h4 id="1-설치">1. 설치</h4>
<blockquote>
</blockquote>
<pre><code>yarn add --dev husky</code></pre><h4 id="2-init">2. init</h4>
<p>(yarn은 init 대신 <a href="https://typicode.github.io/husky/how-to.html#manual-setup">How to 섹션</a>에 있는 내용을 참고해야 함)</p>
<blockquote>
</blockquote>
<p>1) <code>package.son</code> script 작성</p>
<pre><code class="language-json">{
  &quot;scripts&quot;: {
    // Yarn doesn&#39;t support prepare script
    &quot;postinstall&quot;: &quot;husky&quot;,
    // Include this if publishing to npmjs.com
    &quot;prepack&quot;: &quot;pinst --disable&quot;,
    &quot;postpack&quot;: &quot;pinst --enable&quot;
  }
}</code></pre>
<p>2) Run</p>
<pre><code># Yarn doesn&#39;t support `prepare`
yarn run postinstall</code></pre><br />

<hr>
<br />

<h4 id="3-commitlint-라이브러리-추가">3. commitlint 라이브러리 추가</h4>
<p>커밋 컨벤션 체크를 위해 사용
<a href="https://commitlint.js.org/guides/getting-started.html">commitlint 공식문서 Get Started</a></p>
<p>1) 설치</p>
<blockquote>
</blockquote>
<pre><code>yarn add --dev @commitlint/{cli,config-conventional}</code></pre><p>타입스크립트 사용으로 @commitlint/types도 추가해줬다</p>
<blockquote>
</blockquote>
<pre><code>yarn add --dev @commitlint/types</code></pre><p>2) <code>commitlint.config.ts</code> 파일 추가</p>
<blockquote>
</blockquote>
<pre><code>echo &quot;export default { extends: [&#39;@commitlint/config-conventional&#39;] };&quot; &gt; commitlint.config.ts</code></pre><p>3) <a href="https://commitlint.js.org/reference/configuration.html#typescript-configuration">typescript-configuration</a> 설정
<a href="https://commitlint.js.org/reference/rules.html">configuration-rules</a> 참고해서 작성하면 된다.
나는 팀원분이 설정하신 파일을 그대로 사용했다 키득키득</p>
<blockquote>
</blockquote>
<pre><code class="language-ts">import type { UserConfig } from &#39;@commitlint/types&#39;;
import { RuleConfigSeverity } from &#39;@commitlint/types&#39;;
&gt;
// 상세 규칙 : https://commitlint.js.org/reference/rules.html
const Configuration: UserConfig = {
  extends: [&#39;@commitlint/config-conventional&#39;],
  rules: {
    &#39;type-enum&#39;: [
      RuleConfigSeverity.Error,
      &#39;always&#39;,
      [
        &#39;FEAT&#39;,
        &#39;FIX&#39;,
         ...
      ],
    ],
    &#39;type-case&#39;: [RuleConfigSeverity.Error, &#39;always&#39;, &#39;upper-case&#39;], // type-enum 대문자만 허용
    &#39;subject-max-length&#39;: [RuleConfigSeverity.Warning, &#39;always&#39;, 50], // 제목 50자 이내
    &#39;subject-full-stop&#39;: [RuleConfigSeverity.Error, &#39;never&#39;, &#39;.&#39;], // 제목 끝에 마침표 사용하지 않음
  },
};
&gt;
export default Configuration;</code></pre>
<br />

<hr>
<br />


<h4 id="4-huskycommit-msg-파일-생성">4. <code>./husky/commit-msg</code> 파일 생성</h4>
<p><strong><code>.husky/-</code> 폴더 하위의 파일명으로 파일 컨벤션(ex. commit-msg, pre-push 등) 작성되어 있음 참고!</strong></p>
<pre><code>yarn commitlint --edit &quot;$1&quot; || {
  echo &quot;사용하고 싶은 commit 에러 메시지 작성&quot;
  exit 1
}</code></pre><br />

<hr>
<br />


<h4 id="5-lint-staged-라이브러리-추가">5. <code>lint-staged</code> 라이브러리 추가</h4>
<p>1) 설치</p>
<blockquote>
</blockquote>
<pre><code>yarn add lint-staged --dev</code></pre><p>2) script 추가</p>
<blockquote>
</blockquote>
<pre><code>&quot;lint-staged&quot;: {
    &quot;src/**/*.{js,jsx,ts,tsx}&quot;: [
      &quot;eslint --cache&quot;
    ]
  },</code></pre><p>3) <code>./husky/pre-push</code> 파일 추가</p>
<blockquote>
</blockquote>
<pre><code>yarn lint-staged</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[MUI] <Select> 옵션 선택 시 화면의 스크롤 사라지는 문제 해결]]></title>
            <link>https://velog.io/@purple11_11/MUI-Select-%EC%98%B5%EC%85%98-%EC%84%A0%ED%83%9D-%EC%8B%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%82%AC%EB%9D%BC%EC%A7%80%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@purple11_11/MUI-Select-%EC%98%B5%EC%85%98-%EC%84%A0%ED%83%9D-%EC%8B%9C-%EC%8A%A4%ED%81%AC%EB%A1%A4-%EC%82%AC%EB%9D%BC%EC%A7%80%EB%8A%94-%EB%AC%B8%EC%A0%9C-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Mon, 30 Sep 2024 04:43:18 GMT</pubDate>
            <description><![CDATA[<p><code>&lt;Select&gt;</code> 태그를 이용하는데 옵션 선택 시 화면의 스크롤이 사라지면서
화면이 깨지는 문제가 생겼었다</p>
<p>검색 해도 다른 스크롤 이슈가 나오고 이 문제에 대한 글을 못찾겠어서
클로드에 물어보니 바로 몇가지 해결방법을 알려줬다..</p>
<p>그 중 간단하게 해결 가능한 방법은</p>
<pre><code>&lt;Select
     MenuProps={{
    disableScrollLock: true,
  }}
&gt;</code></pre><p>이렇게 MenuProps로 MUI의 disableScrollLock 프로퍼티 사용하는 방법으로 해결했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MUI] <TextField> disabled 일 때 글자색(color) 선명하게]]></title>
            <link>https://velog.io/@purple11_11/MUI-TextField-disabled-%EC%9D%BC-%EB%95%8C-%EC%83%89-%EC%84%A0%EB%AA%85%ED%95%98%EA%B2%8C</link>
            <guid>https://velog.io/@purple11_11/MUI-TextField-disabled-%EC%9D%BC-%EB%95%8C-%EC%83%89-%EC%84%A0%EB%AA%85%ED%95%98%EA%B2%8C</guid>
            <pubDate>Thu, 19 Sep 2024 04:59:02 GMT</pubDate>
            <description><![CDATA[<p>자세히는 못파봤지만
<code>&lt;TextField&gt;</code> 컴포넌트 input 어딘가에 opacity가 적용되어있어서
-webkit-text-fill-color를 설정해주니까 해결됐다.</p>
<blockquote>
</blockquote>
<pre><code class="language-ts">const theme = createTheme({
  components: {
    MuiTextField: {
      styleOverrides: {
        root: {
          &#39;&amp; .MuiOutlinedInput-root&#39;: commonInputStyles,
          &#39;&amp; .MuiOutlinedInput-root.Mui-disabled&#39;: {
            &#39;&amp; fieldset&#39;: {
              borderColor: &#39;#F2F4F6&#39;,
            },
            &#39;&amp; input&#39;: {
              borderRadius: &#39;6px&#39;,
              backgroundColor: &#39;#F2F4F6&#39;,
              WebkitTextFillColor: &#39;#404048&#39;, // 이 색상을 바꿔주면 됨
            },
          },
        },
      },
    },
  },
});</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[MUI] <Button> 컴포넌트 width 줄이기, hover 시 배경, borderColor 변경]]></title>
            <link>https://velog.io/@purple11_11/MUI-Button-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-width-%EC%A4%84%EC%9D%B4%EA%B8%B0-hover-%EC%8B%9C-%EB%B0%B0%EA%B2%BD-borderColor-%EB%B3%80%EA%B2%BD</link>
            <guid>https://velog.io/@purple11_11/MUI-Button-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8-width-%EC%A4%84%EC%9D%B4%EA%B8%B0-hover-%EC%8B%9C-%EB%B0%B0%EA%B2%BD-borderColor-%EB%B3%80%EA%B2%BD</guid>
            <pubDate>Thu, 12 Sep 2024 05:09:20 GMT</pubDate>
            <description><![CDATA[<p>정사각형 버튼을 만들고 싶은데 width 조절이 안돼서 찾아보니</p>
<p><code>sx={{ minWidth: &#39;unset&#39;}</code> 을 사용하여 버튼의 기본 최소 너비를 제거했더니 해결됐다.</p>
<h3 id="결과물">결과물</h3>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/b4cc88b2-6035-4472-be47-157e991119c2/image.png" alt=""></p>
<h3 id="코드">코드</h3>
<blockquote>
</blockquote>
<pre><code>   &lt;Button
     variant=&quot;outlined&quot;
     sx={{
       minWidth: &#39;unset&#39;,
       width: &#39;40px&#39;,
       height: &#39;40px&#39;,
       padding: 0,
       borderColor: &#39;#4E5968&#39;,
       &#39;&amp;:hover&#39;: {
         borderColor: &#39;#4E5968&#39;,
         backgroundColor: &#39;white&#39;,
        },
      }}
    &gt;
      &lt;RemoveIcon
        sx={{ fontSize: &#39;16px&#39;, color: &#39;#4E5968&#39; }}
      /&gt;
   &lt;/Button&gt;
&gt;
   &lt;Button
     variant=&quot;outlined&quot;
     sx={{
       minWidth: &#39;unset&#39;,
       width: &#39;40px&#39;,
       height: &#39;40px&#39;,
       padding: 0,
       borderColor: &#39;#4E5968&#39;,
       &#39;&amp;:hover&#39;: {
         borderColor: &#39;#4E5968&#39;,
         backgroundColor: &#39;white&#39;,
        },
      }}
     &gt;
       &lt;AddIcon sx={{ fontSize: &#39;16px&#39;, color: &#39;#4E5968&#39; }} /&gt;
     &lt;/Button&gt;</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[MUI] <Grid> 사용 방법]]></title>
            <link>https://velog.io/@purple11_11/TIL-MUI-Grid-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95</link>
            <guid>https://velog.io/@purple11_11/TIL-MUI-Grid-%EC%82%AC%EC%9A%A9-%EB%B0%A9%EB%B2%95</guid>
            <pubDate>Thu, 05 Sep 2024 02:01:02 GMT</pubDate>
            <description><![CDATA[<h2 id="grid-컴포넌트"><code>&lt;Grid&gt;</code> 컴포넌트</h2>
<ul>
<li>MUI에서 <code>반응형</code> 레이아웃을 제공하는 컴포넌트로 유연성이 높다.</li>
<li>레이아웃에는 <code>container</code>와 <code>item</code> 두 가지 유형이 있다.</li>
<li><code>&lt;Grid container&gt;</code>가 최대 12까지 너비라고 생각하고 <code>&lt;Grid item&gt;</code>의 너비를 정해주면 된다</li>
<li>breakpoint은 <code>xs</code>, <code>sm</code>, <code>md</code>, <code>lg</code>, <code>xl</code> 5가지가 있다.<blockquote>
</blockquote>
</li>
<li>*<a href="https://mui.com/material-ui/customization/breakpoints/#default-breakpoints">breakpoints (MUI 공식문서)</a>**<pre><code>`xs` : extra-small (스크린(브라우저) 너비가 0~600px일 때 사용)
`sm` : small (600 ~ 900px)
`md` : medium (900 ~ 1200px)
`lg` : large (1200 ~ 1536px)
`xl` : extra-large (1536px ~ )</code></pre></li>
</ul>
<p>이렇게 breakpoint를 지정하면 브라우저 크기 조정할 때 각 너비에 따라 컴포넌트의 크기가 변한다.
createThem을 이용해 breakpoint 별로 커스텀도 가능하다.</p>
<h3 id="grid-container"><code>&lt;Grid container&gt;</code></h3>
<p>옵션</p>
<ul>
<li><code>direction</code> : row, column 으로 지정할 수 있는 옵션
(참고로, <code>direction=colum</code>인 경우 breakpoint는 지원되지 않는다고 한다.)</li>
</ul>
<blockquote>
</blockquote>
<p>내용 계속 추가 예정</p>
<p><a href="https://mui.com/material-ui/react-grid/"><code>&lt;Grid&gt;</code> 공식문서</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[TIL] 상태 관리에서의 불변성]]></title>
            <link>https://velog.io/@purple11_11/TIL-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC%EC%97%90%EC%84%9C%EC%9D%98-%EB%B6%88%EB%B3%80%EC%84%B1</link>
            <guid>https://velog.io/@purple11_11/TIL-%EC%83%81%ED%83%9C-%EA%B4%80%EB%A6%AC%EC%97%90%EC%84%9C%EC%9D%98-%EB%B6%88%EB%B3%80%EC%84%B1</guid>
            <pubDate>Wed, 04 Sep 2024 02:57:30 GMT</pubDate>
            <description><![CDATA[<ol>
<li>axios 요청해서 받은 data를 바로 data에 조작하지 말 것!</li>
</ol>
<blockquote>
</blockquote>
<p>ex)</p>
<pre><code class="language-ts">...
        setRows(
&#39;이 data =&gt;&#39; data.resData.content.map((item: FaqRes) =&gt; ({
            ...item,
            isFrequency: item.isFrequency ? &#39;적용&#39; : &#39;미적용&#39;,
            createdAt: formatToKSTDateTime(item.createdAt as string),
            creator: `${item.creatorName}/${item.creatorEmail}`,
          }))
        );
...</code></pre>
<ol start="2">
<li>spread 문법을 사용하면서 똑같은 배열을 왜 만들어서 활용하는지 궁금함이 생겼다</li>
</ol>
<br />

<hr>
<br />


<p>React에서는 상태를 직접 변경하는 것이 아니라, 상태의 복사본을 만들어 수정한 후 그 복사본으로 상태를 업데이트하는 것이 중요한 원칙이라고 한다.</p>
<blockquote>
</blockquote>
<h3 id="왜-불변성을-유지해야-하나요">왜 불변성을 유지해야 하나요?</h3>
<p><strong>성능 최적화</strong>: React는 상태가 변경될 때마다 컴포넌트가 다시 렌더링됩니다. <strong>상태가 직접 변경되면 React는 변경된 부분을 감지하지 못할 수 있습니다.</strong> 하지만 불변성을 유지하면 React는 상태 변경을 정확하게 감지할 수 있습니다.</p>
<blockquote>
</blockquote>
<p><strong>예측 가능성</strong>: 상태를 직접 변경하면 예상치 못한 부작용이 발생할 수 있습니다. 불변성을 유지하면 상태 변경이 명확하게 관리되므로 코드의 예측 가능성이 높아집니다.</p>
<blockquote>
</blockquote>
<p><strong>디버깅 용이</strong>: 불변성을 유지하면 상태 변경 전후를 비교하기 쉬워지고, 상태 추적이나 디버깅이 더 쉬워집니다.</p>
<p>그럼 모든 경우에 복사본을 생성해서 불변성을 유지해야 할까?</p>
<p>그건 아니다.</p>
<p>대표적으로</p>
<blockquote>
</blockquote>
<ol>
<li>로컬 변수:
함수 내부에서 사용되는 로컬 변수나 객체는 불변성을 유지할 필요가 없습니다. 이들은 함수가 실행될 때 생성되고, 함수가 끝나면 메모리에서 해제되기 때문에 불변성을 강제할 필요가 없습니다.</li>
<li>성능이 중요한 경우:
큰 배열이나 객체를 반복적으로 처리해야 하는 상황에서는 불변성을 유지하려면 성능에 부담이 될 수 있습니다. 이럴 때는 불변성을 유지하지 않고, 직접적으로 변경하여 성능을 최적화할 수 있습니다. 하지만 이는 상황에 따라 신중하게 판단해야 합니다.</li>
</ol>
<p>이러한 경우에는 불변성을 유지하지 않아도 된다.</p>
<blockquote>
</blockquote>
<h3 id="요약-불변성을-유지하는-기준">요약: 불변성을 유지하는 기준</h3>
<ul>
<li><strong>상태 관리</strong>: React 상태나 Redux 상태와 같이 변경이 감지되어야 하는 경우, 불변성을 유지해야 합니다.</li>
<li><strong>로컬 작업</strong>: 함수 내부의 로컬 작업이나 불변성을 유지할 필요가 없는 곳에서는 불변성을 강제하지 않아도 됩니다.</li>
<li><strong>성능 고려</strong>: 성능이 중요한 경우 불변성을 포기할 수 있지만, 그에 따른 부작용을 충분히 고려해야 합니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js 14, MUI 5] font-family 변경하기]]></title>
            <link>https://velog.io/@purple11_11/Next.js-14-MUI-5-font-family-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@purple11_11/Next.js-14-MUI-5-font-family-%EB%B3%80%EA%B2%BD%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 09 Aug 2024 06:09:05 GMT</pubDate>
            <description><![CDATA[<p>MUI에서는 Roboto 글꼴을 기본으로 제공한다
Noto Sans KR 글꼴을 사용하기 위해 font face를 설정해줬다.</p>
<blockquote>
</blockquote>
<p>기본적으로 layout.tsx 에서 전역으로 font 설정을 적용하고 있어서</p>
<pre><code class="language-tsx">const notoSansKr = Noto_Sans_KR({
  subsets: [&#39;latin-ext&#39;],
  style: [&#39;normal&#39;],
  weight: [&#39;400&#39;, &#39;500&#39;, &#39;600&#39;, &#39;700&#39;],
  display: &#39;swap&#39;,
  fallback: [&#39;system-ui&#39;, &#39;arial&#39;],
});
&gt;
...
&gt;
  &lt;body className={notoSansKr.className}&gt;
    ...
  &lt;/body&gt;</code></pre>
<p>처음에는 MUI의 createTheme과 ThemeProvider를 <code>layout.tsx</code> 파일에 적용해줬다.
하지만 <strong>MUI는 CSR 환경에서 사용</strong>되기 때문에 오류가 발생해서 따로 Provider 파일을 생성해 넣어줬다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/2ed3680c-8241-48f2-a3a6-7ab2cbd97949/image.png" alt=""></p>
<p>우선 NotoSansKR의 ttf를 다운받아 <code>/public</code> 폴더에 넣어줬다. (<a href="https://fonts.google.com/selection">Google Font 다운로드</a>)</p>
<p>그 후, global.scss 파일에 font-face를 설정해줌 (<code>layout.tsx</code>에서 사용 중)</p>
<blockquote>
</blockquote>
<p>global.scss (사용할 굵기만 넣어줘도 됨)</p>
<pre><code class="language-css">@font-face {
  font-family: &#39;Noto Sans KR&#39;;
  src: url(../../public/fonts/NotoSansKR-Regular.ttf);
  font-weight: 400;
}
&gt;
@font-face {
  font-family: &#39;Noto Sans KR&#39;;
  src: url(../../public/fonts/NotoSansKR-Medium.ttf);
  font-weight: 500;
}
&gt;
@font-face {
  font-family: &#39;Noto Sans KR&#39;;
  src: url(../../public/fonts/NotoSansKR-SemiBold.ttf);
  font-weight: 600;
}
&gt;
@font-face {
  font-family: &#39;Noto Sans KR&#39;;
  src: url(../../public/fonts/NotoSansKR-Bold.ttf);
  font-weight: 700;
}</code></pre>
<blockquote>
</blockquote>
<p>ThemeRegistry.tsx</p>
<pre><code class="language-tsx">&#39;use client&#39;;
&gt;
import { createTheme, ThemeProvider } from &#39;@mui/material/styles&#39;;
import CssBaseline from &#39;@mui/material/CssBaseline&#39;;
&gt;
const theme = createTheme({
  typography: {
    fontFamily: [&#39;Noto Sans KR&#39;, &#39;Arial&#39;, &#39;sans-serif&#39;].join(&#39;, &#39;),
  },
&gt;
});
&gt;
export default function ThemeRegistry({
  children,
}: {
  children: React.ReactNode;
}) {
  return (
    &lt;ThemeProvider theme={theme}&gt;
      &lt;CssBaseline /&gt;
      {children}
    &lt;/ThemeProvider&gt;
  );
}</code></pre>
<p>이렇게 설정해준 후 <code>layout.tsx</code> 파일에서 <ThemeRegistry> 태그로 적용하고 싶은 범위를 지정해주면 된다!</p>
<blockquote>
</blockquote>
<pre><code class="language-tsx">  import &#39;@/styles/global.scss&#39;;
&gt;
  ...
  &gt;
      &lt;body&gt;
        &lt;ThemeRegistry&gt; // &lt;---- 요기부터
          &lt;Header /&gt;
          &lt;SideBar /&gt;
          &lt;main&gt;{children}&lt;/main&gt;
        &lt;/ThemeRegistry&gt; // &lt;---- 요기까지
      &lt;/body&gt;</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Next.js 14] next/font/google로 font-family 설정하기]]></title>
            <link>https://velog.io/@purple11_11/Next.js-14-nextfontgoogle%EB%A1%9C-font-family-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@purple11_11/Next.js-14-nextfontgoogle%EB%A1%9C-font-family-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 Aug 2024 08:43:38 GMT</pubDate>
            <description><![CDATA[<p>next.js 14버전에서 font-family 적용하는 방법 정리</p>
<blockquote>
</blockquote>
<p>app/layout.tsx</p>
<pre><code class="language-tsx">import type { Metadata } from &#39;next&#39;;
import { Noto_Sans_KR } from &#39;next/font/google&#39;;
import &#39;@/src/styles/global.scss&#39;;
&gt;
const notoSansKr = Noto_Sans_KR({
  subsets: [&#39;latin-ext&#39;], // 확장된 라틴 문자를 포함해 한글과 영문 모두 지원 가능
  style: [&#39;normal&#39;], 
  weight: [&#39;400&#39;, &#39;500&#39;, &#39;600&#39;, &#39;700&#39;], // 각 굵기에 따른 스타일 적용 가능
  display: &#39;swap&#39;, // 폰트가 로드되는 동안 시스템 폰트 사용하다, 로드가 완료되면 지정된 폰트로 교체
  fallback: [&#39;system-ui&#39;, &#39;arial&#39;], // 지정한 폰트 사용 불가 시 대체 폰트를 순서대로 나열
});
&gt;
export const metadata: Metadata = {
  title: &#39;title&#39;,
  description: &#39;description&#39;,
};
&gt;
export default function RootLayout({
  children,
}: Readonly&lt;{
  children: React.ReactNode;
}&gt;) {
  return (
    &lt;html lang=&quot;ko&quot;&gt;
      &lt;body className={notoSansKr.className}&gt; // body의 className에 변수명 써서 font 설정
        &lt;main&gt;{children}&lt;/main&gt;
      &lt;/body&gt;
    &lt;/html&gt;
  );
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[CSS] input[type="radio"] 체크 모양으로 커스텀하기]]></title>
            <link>https://velog.io/@purple11_11/CSS-inputtyperadio-%EC%B2%B4%ED%81%AC-%EB%AA%A8%EC%96%91%EC%9C%BC%EB%A1%9C-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@purple11_11/CSS-inputtyperadio-%EC%B2%B4%ED%81%AC-%EB%AA%A8%EC%96%91%EC%9C%BC%EB%A1%9C-%EC%BB%A4%EC%8A%A4%ED%85%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 Aug 2024 06:04:18 GMT</pubDate>
            <description><![CDATA[<p>마케팅 정보 수신 동의를 위해 라디오 버튼을 생성해야했다.
checked=true 일 때 체크 표시로 나타내는 게 요구사항이라 이를 위해 커스텀 했다. </p>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/080432b4-1f1e-4862-b18f-77520738fd48/image.png" alt=""></p>
<blockquote>
</blockquote>
<pre><code class="language-tsx">import styles from &#39;./index.module.scss&#39;;
import { UserInfoProps } from &#39;@/src/types/interface&#39;;
&gt;
interface Props {
  userInfo: UserInfoProps;
  onInputChange: (field: string, value: string | boolean) =&gt; void;
}
&gt;
const MarketingInfo = ({ userInfo, onInputChange }: Props) =&gt; {
  return (
    &lt;div className={styles.marketingWrapper}&gt;
      &lt;div className={styles.marketingTitle}&gt;
        &lt;p&gt;마케팅 정보 수신 동의&lt;/p&gt;
        &lt;span&gt;이벤트, 특가, 혜택 등 유용한 정보를 알려드립니다.&lt;/span&gt;
      &lt;/div&gt;
      &lt;div className={styles.marketingRadio}&gt;
        &lt;label className={styles.radioLabel}&gt;
          &lt;input
            type=&quot;radio&quot;
            name=&quot;marketing&quot;
            checked={userInfo.isAgreeMarketing}
            onChange={() =&gt; onInputChange(&#39;isAgreeMarketing&#39;, true)}
          /&gt;
          &lt;span className={styles.customRadio}&gt;&lt;/span&gt;
          수신
        &lt;/label&gt;
        &lt;label className={styles.radioLabel}&gt;
          &lt;input
            type=&quot;radio&quot;
            name=&quot;marketing&quot;
            checked={!userInfo.isAgreeMarketing}
            onChange={() =&gt; onInputChange(&#39;isAgreeMarketing&#39;, false)}
          /&gt;
          &lt;span className={styles.customRadio}&gt;&lt;/span&gt;
          비수신
        &lt;/label&gt;
      &lt;/div&gt;
    &lt;/div&gt;
  );
};
&gt;
export default MarketingInfo;</code></pre>
<blockquote>
</blockquote>
<pre><code class="language-css">.marketingWrapper {
  display: flex;
  flex-direction: column;
  margin-bottom: 30px;
}
&gt;
.marketingTitle {
  display: flex;
  flex-direction: column;
&gt;
  p,
  span {
    color: #4e5968;
  }
&gt;
  p {
    margin: 8px 0;
  }
  span {
    font-size: 12px;
    margin-bottom: 15px;
  }
}
&gt;
.marketingRadio {
  display: flex;
  gap: 40px;
}
&gt;
.radioLabel {
  display: flex;
  align-items: flex-start;
  font-size: 12px;
  color: #404048;
  cursor: pointer;
&gt;
  input {
    display: none;
  }
&gt;
  .customRadio {
    width: 18px;
    height: 18px;
    border: 2px solid #e5e7ea;
    border-radius: 50%;
    margin-right: 8px;
    display: inline-block;
    position: relative;
&gt;
    &amp;::after {
      content: &#39;\2714&#39;; // 유니코드에서 체크 표시
      font-size: 9px;
      color: white;
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -50%) scale(0);
      transition: transform 0.2s ease;
    }
  }
&gt;
  input:checked + .customRadio {
    background-color: #002fb4;
    border-color: #002fb4;
&gt;
    &amp;::after {
      transform: translate(-50%, -50%) scale(1);
    }
  }
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[[Error] local 저장소 bitbucket 연결 중 만난 [none-fast-forward]]]></title>
            <link>https://velog.io/@purple11_11/Error-local-%EC%A0%80%EC%9E%A5%EC%86%8C-bitbucket-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@purple11_11/Error-local-%EC%A0%80%EC%9E%A5%EC%86%8C-bitbucket-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Tue, 09 Jul 2024 05:09:55 GMT</pubDate>
            <description><![CDATA[<p>처음에 master 브랜치로 push 해버려서 bitbucket 저장소 삭제, .git 폴더 삭제 후 처음부터 다시 시작하려고 했다.</p>
<blockquote>
</blockquote>
<pre><code>$ git remote add origin https://계정@bitbucket.org/이하 주소(HTTPS URL)</code></pre><p>저장소 연결 후 <code>git push</code> 받았는데 아래와같은 오류가 났다.</p>
<p><img src="https://velog.velcdn.com/images/purple11_11/post/b7a36d99-4228-4c3e-b964-aa55e7e5d842/image.png" alt=""></p>
<p>local 저장소와 bitbucket 저장소가 관련 기록이 없어서 생기는 오류라고 한다..</p>
<blockquote>
</blockquote>
<pre><code>$ git pull origin main --allow-unrelated-histories</code></pre><p>입력했더니 해결됐다!</p>
<br />

<p>참고 자료</p>
<ul>
<li><a href="https://jobc.tistory.com/177">git push, pull (fatal: refusing to merge unrelated histories) 에러</a></li>
<li><a href="https://despiteallthat.tistory.com/84">https://despiteallthat.tistory.com/84</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[[React/Typescript] Zustand 사용기 (axios 요청 한 번으로 두 페이지에 데이터 보내기)]]></title>
            <link>https://velog.io/@purple11_11/ReactTypescript-Zustand-%EC%82%AC%EC%9A%A9%EA%B8%B0-axios-%EC%9A%94%EC%B2%AD-%ED%95%9C-%EB%B2%88%EC%9C%BC%EB%A1%9C-%EB%91%90-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%B4%EB%82%B4%EA%B8%B0</link>
            <guid>https://velog.io/@purple11_11/ReactTypescript-Zustand-%EC%82%AC%EC%9A%A9%EA%B8%B0-axios-%EC%9A%94%EC%B2%AD-%ED%95%9C-%EB%B2%88%EC%9C%BC%EB%A1%9C-%EB%91%90-%ED%8E%98%EC%9D%B4%EC%A7%80%EC%97%90-%EB%8D%B0%EC%9D%B4%ED%84%B0-%EB%B3%B4%EB%82%B4%EA%B8%B0</guid>
            <pubDate>Thu, 09 May 2024 09:03:50 GMT</pubDate>
            <description><![CDATA[<h2 id="사용-배경">사용 배경</h2>
<p>우리 팀은 상태관리 툴로 <code>Zustand</code>를 사용했다.
첫 리액트 프로젝트이다 보니 상태관리 툴을 왜 써야하는지가 와닿지 않아서 Redux 사용을 미루고 있었다.
근데 로그인 상태를 유지 시키기 위해서는 전역적으로 상태를 관리할 필요가 있다고 판단해 상태관리 툴의 도입을 고려하는데, 중간에 사용하려니까 Redux 설계를 할 생각에 좀 막막했다..</p>
<p>그러던 중 친구의 추천으로 zustand를 알게 되었는데, 사용법이 매우 간단하고 Redux와 같은 Flux 패턴을 사용하고 있어 개념을 따로 익히지 않아도 된다는 점이 팀원들에게도 부담이 덜 되겠다고 생각했다.</p>
<h2 id="zustand-사용법">Zustand 사용법</h2>
<h3 id="설치">설치</h3>
<p><code>npm install zustand</code> </p>
<h3 id="사용법">사용법</h3>
<p>일단 zustand는 create 함수를 통해 store를 생성 할 수 있다.
초기 설정이 이게 끝이다 so 간편
Provider로 감쌀 필요도 없다.
약간 커스텀 Hook을 만들어서 사용하는 느낌?! 그래서 일반적으로 store이름 앞에 use를 붙인다고 한다.</p>
<blockquote>
</blockquote>
<p>Typescript로 store 생성 후 바인딩 하여 사용하는 예제</p>
<pre><code class="language-ts">// store 생성
import { create } from &#39;zustand&#39;
&gt;
type Store = {
  count: number
  inc: () =&gt; void
}
&gt;
const useStore = create&lt;Store&gt;()((set) =&gt; ({
  count: 1,
  inc: () =&gt; set((state) =&gt; ({ count: state.count + 1 })),
}))
&gt;
// 전역 state 및 action 함수 사용
function Counter() {
  const { count, inc } = useStore()
  return (
    &lt;div&gt;
      &lt;span&gt;{count}&lt;/span&gt;
      &lt;button onClick={inc}&gt;one up&lt;/button&gt;
    &lt;/div&gt;
  )
}</code></pre>
<h2 id="문제-상황">문제 상황</h2>
<h3 id="1">1</h3>
<p>로그인을 하는데 새로 고침을 하니까 state가 초기화 되어 버렸다..</p>
<h3 id="2">2</h3>
<p>이전 글을 보면 react-router-dom의 loader를 통해 두 개의 페이지(개인학습, 퀴즈)에서 사용할 일상생활 수어 openAPI 데이터를 한 번에 받아오게끔 구현했었다.
근데 loader는 데이터를 다 불러와야지만 화면 전환이 돼서, 사용자 입장에서 멈춘 화면을 보게 되는데 실제 웹 사이트를 사용할 때 이러면 사용자들이 다 떠나가게 된다..</p>
<p>그래서 <a href="https://react-ko.dev/reference/react/Suspense#displaying-a-fallback-while-content-is-loading">React <code>&lt;Suspense&gt;</code></a>를 사용해 자식(개인학습, 퀴즈 페이지)이 로딩을 완료할 때까지 폴백을 표시하려고 했다.</p>
<p>근데.. Suspense로 두 페이지를 감싸도 제대로 동작하지 않았다 왜일까..
거의 하루 중 반을 잡고있었는데도 해결이 안돼서 zustand를 이용한 방법으로 해결했다.</p>
<h2 id="해결-방법">해결 방법</h2>
<h3 id="1-1">1</h3>
<p>새로 고침 시 초기화 되는 문제를 해결하기 위해 찾아보니 persist라는 메서드가 있어서 이를 활용해 상태를 유지시켜 줬다.</p>
<p><a href="https://docs.pmnd.rs/zustand/integrations/persisting-store-data">공식 문서의 Persisting store data</a></p>
<h3 id="2-1">2</h3>
<ol>
<li><p>zustand의 fetchData라는 action 함수로 axios 요청 보낸 후 data(배열)라는 state에 return값을 저장하고, loading 이라는 state를 true로 변경했다.</p>
</li>
<li><p>값을 넘겨 받을 페이지에서 data, loading state와 fetchData 함수를 호출해, 
<code>useEffect</code>에 data의 길이가 0이면 fetchData 함수를 실행하도록 작성했다.
그리고 loading state를 이용해 삼항 연산자로 로딩 화면과 로딩 후 보여줄 페이지를 return 부분에 작성해줬다.</p>
</li>
</ol>
<br />



<p>참고 자료
<a href="https://zustand-demo.pmnd.rs/">zustand 공식 문서</a></p>
]]></description>
        </item>
    </channel>
</rss>