<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>kim__0916.log</title>
        <link>https://velog.io/</link>
        <description>프론트엔드</description>
        <lastBuildDate>Tue, 14 Jul 2026 07:44:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>kim__0916.log</title>
            <url>https://velog.velcdn.com/images/gayeong__0916/profile/ea2d2048-4550-423a-ac9c-758544e0c2cc/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. kim__0916.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/gayeong__0916" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Recharts 차트 커스터마이징하기]]></title>
            <link>https://velog.io/@gayeong__0916/Recharts-%EC%B0%A8%ED%8A%B8-%EC%BB%A4%EC%8A%A4%ED%84%B0%EB%A7%88%EC%9D%B4%EC%A7%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/Recharts-%EC%B0%A8%ED%8A%B8-%EC%BB%A4%EC%8A%A4%ED%84%B0%EB%A7%88%EC%9D%B4%EC%A7%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 14 Jul 2026 07:44:14 GMT</pubDate>
            <description><![CDATA[<ul>
<li><p>시력(UDVA)은 Line Chart를 사용하고, 구면도수(SPH)와 난시도수(CYL)는 Bar Chart를 사용했지만 대부분의 설정은 공통으로 사용하였다.</p>
<pre><code>&lt;ResponsiveContainer width=&quot;100%&quot; height=&quot;100%&quot;&gt;
&lt;LineChart data={chartData}&gt;
  ...

  &lt;Line /&gt;
&lt;/LineChart&gt;
&lt;/ResponsiveContainer&gt;
</code></pre></li>
</ul>
<ResponsiveContainer width="100%" height="100%">
  <BarChart data={chartData}>
    ...

<pre><code>&lt;Bar /&gt;</code></pre>  </BarChart>
</ResponsiveContainer>
```
- 실제로 차트 종류만 다를 뿐,
    - ResponsiveContainer
    - CartesianGrid
    - XAxis
    - YAxis
- 설정은 동일하게 적용하였다.
### ResponsiveContainer
- 차트는 카드 크기에 따라 반응형으로 동작해야 했다.
- 그래서 ResponsiveContainer를 사용하여 부모 요소 크기에 맞게 자동으로 렌더링되도록 구성했다.
```
<ResponsiveContainer width="100%" height="100%">
```
### CartesianGrid
- 차트의 가독성을 높이기 위해 배경 격자선을 추가하였다.
```
<CartesianGrid
  vertical={false}
  stroke="#E2E4E8"
/>
```
- 세로선은 제거하고 가로선만 표시하였다.
- 가로선만 남겨두면 값의 위치를 쉽게 파악할 수 있으면서도 화면이 복잡해 보이지 않는다.
### XAxis
- X축은 예측 시점을 표시한다.
```
<XAxis
  dataKey="label"
  tick={renderXAxisTick}
  tickLine={false}
  axisLine={false}
  padding={{ left: 60, right: 60 }}
/>
```
- dataKey="label"을 통해(chartData의 label을 사용한다는 뜻)
    - 3일
    - 3주
    - 3개월
- 값을 표시하였다.
```
 const chartData = [
    {
      label: "3일",
      LASIK: ClampLineValue(data.LASIK?.["3days"]),
      LASEK: ClampLineValue(data.LASEK?.["3days"]),
      SMILE: ClampLineValue(data.SMILE?.["3days"]),
      ICL: ClampLineValue(data.ICL?.["3days"]),
    },
    {
      label: "3주",
      LASIK: ClampLineValue(data.LASIK?.["3weeks"]),
      LASEK: ClampLineValue(data.LASEK?.["3weeks"]),
      SMILE: ClampLineValue(data.SMILE?.["3weeks"]),
      ICL: ClampLineValue(data.ICL?.["3weeks"]),
    },
    {
      label: "3개월",
      LASIK: ClampLineValue(data.LASIK?.["3months"]),
      LASEK: ClampLineValue(data.LASEK?.["3months"]),
      SMILE: ClampLineValue(data.SMILE?.["3months"]),
      ICL: ClampLineValue(data.ICL?.["3months"]),
    },
  ];
```
- 기본 Tick 대신 커스텀 Tick을 사용하여 디자인 시스템에 맞는 폰트와 색상을 적용하였다.
```
  const renderXAxisTick = ({ x, y, payload }: TickProps) => (
    <text
      x={Number(x)}
      y={Number(y) + 24}
      textAnchor="middle"
      fill="#989DA8"
      fontSize={16}
      fontWeight={500}
      letterSpacing="-0.6px"
    >
      {payload?.value}
    </text>
  );
```
### YAxis
- Y축은 데이터 범위에 맞게 설정하였다.
- 시력 차트의 경우 0~1.2 범위를 사용하였다.
```
<YAxis
   width={50}
   domain={[0, 1.2]}
   ticks={[0, 0.4, 0.8, 1.2]}
   tick={renderYAxisTick}
   tickLine={false}
   axisLine={false}
/>
```
- 또한 모든 Tick Label(Y축 라벨)을 표시하는 대신 최소값과 최대값만 노출하였다.
- 0.4, 0.8은 라벨 안 보이게 함
```
  const renderYAxisTick = ({ x, y, payload }: TickProps) => {
    const value = Number(payload?.value);

<pre><code>if (value !== 0 &amp;&amp; value !== 1.2) {
  return null;
}</code></pre><pre><code>- 중간 값은 가이드 라인 역할만 수행하도록 구성하였다.
- 구면도수, 난시도수 차트의 경우 </code></pre><p>  const yAxisConfig = {
    sph: {
      domain: [-1.5, 1.5],
      ticks: [-1.5, 0, 1.5],
    },
    cyl: {
      domain: [-1.5, 1.5],
      ticks: [-1.5, 0, 1.5],
    },
  }[chartType];</p>
<p>  const renderYAxisTick = ({ x, y, payload }: TickProps) =&gt; {
    const value = Number(payload?.value);</p>
<pre><code>return (
  &lt;text
    x={Number(x) - 5}
    y={Number(y)}
    dy={value === -1.5 ? 2 : value === 0 ? 2 : -4}
    dx={value === -1.5 ? -1 : 0}
    textAnchor=&quot;end&quot;
    dominantBaseline=&quot;middle&quot;
    fill=&quot;#989DA8&quot;
    fontSize={16}
    fontWeight={500}
    letterSpacing=&quot;-0.6px&quot;
  &gt;
    {value.toFixed(2)}
  &lt;/text&gt;
);</code></pre><p>  };</p>
<pre><code>## 차트 종류에 따른 렌더링
- 공통 설정 이후 실제 데이터 표현 방식만 달라진다.

### Line Chart
- 시력(UDVA)은 시간에 따른 변화 추이를 확인하는 것이 중요했다.
- 그래서 Line Chart를 사용하였다.</code></pre><p><Line
  dataKey={type}
  stroke={chip.color}
  strokeWidth={3}
  dot={false}
  activeDot={false}
  isAnimationActive={false}
/></p>
<pre><code>- 수술 방식별 회복 추세 표현
- 점 제거
- Hover 제거
- PDF 출력을 위한 애니메이션 제거
- Bar Chart

### Bar Chart
- 구면도수(SPH)와 난시도수(CYL)는 변화 추이보다 수치 비교가 중요했다.
- 그래서 동일한 데이터 구조를 사용하면서 렌더링 방식만 Bar Chart로 변경하였다.</code></pre><p>&lt;Bar
    key={type}
    dataKey={type}
    fill={chip.color}
    radius={[2, 2, 0, 0]}
    isAnimationActive={false}
    barSize={12}
/&gt;
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[html2canvas와 jsPDF로 PDF 결과지 생성하기]]></title>
            <link>https://velog.io/@gayeong__0916/html2canvas%EC%99%80-jsPDF%EB%A1%9C-PDF-%EA%B2%B0%EA%B3%BC%EC%A7%80-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/html2canvas%EC%99%80-jsPDF%EB%A1%9C-PDF-%EA%B2%B0%EA%B3%BC%EC%A7%80-%EC%83%9D%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 12 Jul 2026 23:02:55 GMT</pubDate>
            <description><![CDATA[<ul>
<li>견적 결과지 화면을 PDF로 생성해 서버에 저장하고, 이후 카카오톡으로 전송하는 기능을 구현했다.</li>
<li>화면에 보이는 UI를 그대로 PDF로 만들어야 했기 때문에 <code>html2canvas-pro</code>로 DOM을 이미지처럼 캡처하고, <code>jsPDF</code>를 이용해 PDF 파일로 변환하는 방식을 사용했다.<h2 id="구현-흐름">구현 흐름</h2>
</li>
</ul>
<ol>
<li>결과지 영역 DOM 참조</li>
<li>PDF 캡처용 스타일 적용</li>
<li>html2canvas로 화면 캡처</li>
<li>캡처 이미지를 jsPDF에 추가</li>
<li>PDF Blob을 File 객체로 변환</li>
<li>백엔드 저장 API 호출</li>
</ol>
<pre><code>npm install html2canvas-pro jspdf</code></pre><pre><code>const canvas1 = await html2canvas(contentRef.current, {
  scale: 2,
  useCORS: true,
  backgroundColor: &quot;#ffffff&quot;,
  windowWidth: 1200,
});

const pdfBlob = pdf.output(&quot;blob&quot;);

const file = new File([pdfBlob], &quot;estimate.pdf&quot;, {
  type: &quot;application/pdf&quot;,
});</code></pre><h2 id="캡처-영역-분리">캡처 영역 분리</h2>
<ul>
<li>각 영역을 별도로 캡처한 뒤 PDF의 1페이지, 2페이지로 추가했다.</li>
</ul>
<pre><code>const canvas1 = await html2canvas(contentRef.current, ...);
const canvas2 = await html2canvas(commentRef.current, ...);</code></pre><h2 id="pdf-캡처용-스타일-적용">PDF 캡처용 스타일 적용</h2>
<ul>
<li>캡처 직전에 PDF 전용 클래스를 추가했다.</li>
</ul>
<pre><code>contentRef.current.classList.add(&quot;pdf-capture-mode&quot;);
commentRef.current.classList.add(&quot;pdf-capture-mode&quot;);</code></pre><pre><code class="language-css">.pdf-capture-mode,
.pdf-capture-mode * {
  font-family:
    -apple-system, BlinkMacSystemFont, &quot;Segoe UI&quot;, Roboto, &quot;Helvetica Neue&quot;,
    Arial, &quot;Apple SD Gothic Neo&quot;, &quot;Malgun Gothic&quot;, sans-serif !important;

  letter-spacing: 0px !important;
}</code></pre>
<ul>
<li>프로젝트의 기본 폰트는 <code>Pretendard Variable</code>이었다.</li>
<li>브라우저 화면에서는 문제가 없었지만, <code>html2canvas</code>로 캡처할 때는 Variable Font가 기대한 것과 다르게 렌더링될 수 있었다.</li>
<li>특히 PDF 캡처 과정에서는 다음과 같은 문제가 발생할 수 있다.<ul>
<li>글자 간격이 화면과 다르게 보임</li>
<li>일부 텍스트가 줄바꿈됨</li>
<li>한글 폰트가 깨지거나 두껍게 보임</li>
<li>PDF 이미지 캡처 결과가 브라우저 화면과 달라짐</li>
<li>숫자의 line height가 이상해짐</li>
</ul>
</li>
<li>그래서 PDF 캡처 시에는 Variable Font 대신 시스템 폰트 fallback을 사용하도록 변경했다.</li>
<li><code>letter-spacing:0px !important</code> : 이는 캡처 시 글자 폭이 달라지면서 레이아웃이 밀리는 문제를 줄이기 위한 처리였다.</li>
</ul>
<h2 id="캡처-전-레이아웃-고정">캡처 전 레이아웃 고정</h2>
<ul>
<li>PDF는 화면 크기에 따라 레이아웃이 달라지면 안 된다.</li>
<li>그래서 캡처 직전에 컨테이너 너비를 고정했다.<pre><code>containerRef.current.style.width = &quot;1600px&quot;;
containerRef.current.style.maxWidth = &quot;1600px&quot;;
containerRef.current.style.minWidth = &quot;1600px&quot;;</code></pre></li>
<li>캡처가 끝난 뒤에는 원래 스타일로 복구했다.</li>
</ul>
<pre><code>containerRef.current.style.width = &quot;&quot;;
containerRef.current.style.maxWidth = &quot;&quot;;
containerRef.current.style.minWidth = &quot;&quot;;</code></pre><h2 id="폰트-로딩-대기">폰트 로딩 대기</h2>
<ul>
<li>PDF를 생성하기 전에 폰트 로딩이 끝날 때까지 기다렸다.<pre><code>await document.fonts.ready;
await new Promise((resolve) =&gt; setTimeout(resolve, 100));</code></pre></li>
<li>폰트가 완전히 로드되기 전에 캡처하면 fallback 폰트로 먼저 캡처될 수 있기 때문에, 렌더링이 안정화된 뒤 캡처하도록 처리했다.</li>
</ul>
<h2 id="이미지-품질-설정">이미지 품질 설정</h2>
<ul>
<li><code>html2canvas</code> 캡처 시 <code>scale</code> 값을 2로 설정했다.<pre><code>scale: 2,</code></pre></li>
<li>scale이 낮으면 PDF에서 텍스트나 그래프가 흐릿하게 보일 수 있다.</li>
<li>그래서 캡처 해상도를 높여 PDF에서도 비교적 선명하게 보이도록 했다.<pre><code>useCORS: true,</code></pre></li>
<li>결과지에 외부 이미지가 포함될 가능성이 있어 true 옵션을 적용하였다.</li>
<li>이를 통해 CDN, S3 등 다른 도메인에서 제공되는 이미지도 html2canvas가 정상적으로 읽어 PDF에 포함할 수 있도록 구성하였다.<h2 id="pdf-생성">PDF 생성</h2>
</li>
<li>캡처한 canvas는 이미지 데이터로 변환한 뒤 PDF에 추가했다.<pre><code>const imgData1 = canvas1.toDataURL(&quot;image/jpeg&quot;, 1.0);
</code></pre></li>
</ul>
<p>pdf.addImage(
  imgData1,
  &quot;JPEG&quot;,
  0,
  0,
  pageWidth,
  calculatedHeight
);</p>
<pre><code>- 첫 번째 영역을 추가한 뒤 새로운 페이지를 생성하고, 두 번째 캡처 이미지를 추가했다.
</code></pre><p>pdf.addPage([pageWidth, calculatedHeight], &quot;p&quot;);</p>
<pre><code>
---

## 중복 저장 방지
- 저장 버튼을 여러 번 클릭하면 동일한 PDF가 중복 생성되거나 API가 중복 호출될 수 있다.
- 그래서 `useRef`를 이용해 저장 중 여부를 관리했다.
</code></pre><p>if (isSavingRef.current) return;</p>
<p>isSavingRef.current = true;
setIsSaving(true);</p>
<pre><code>- `useState`만 사용할 경우 상태 반영 타이밍 때문에 빠른 연속 클릭을 완전히 막기 어려울 수 있다.
- 그래서 즉시 값이 반영되는 `useRef`를 함께 사용했다.
## 네트워크 예외 처리
- PDF 저장 전 네트워크 상태도 확인했다.
</code></pre><p>if (!navigator.onLine) {
  setFailSaveNetworkModal(true);
  return;
}</p>
<pre><code>- 오프라인 상태에서는 PDF 생성 이후 서버 저장에 실패할 수 있기 때문에, 저장 전에 먼저 네트워크 상태를 먼저 확인한다.

## 마무리
- PDF 캡처용 폰트 적용
- letter-spacing 초기화
- 캡처 전 컨테이너 너비 고정
- 폰트 로딩 대기
- scale 설정으로 화질 개선
- 페이지 영역 분리
- 중복 저장 방지
- 등의 기능 적용</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[Recharts를 활용한 시력교정술 결과 비교 차트 구현하기]]></title>
            <link>https://velog.io/@gayeong__0916/Recharts%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8B%9C%EB%A0%A5%EA%B5%90%EC%A0%95%EC%88%A0-%EA%B2%B0%EA%B3%BC-%EB%B9%84%EA%B5%90-%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/Recharts%EB%A5%BC-%ED%99%9C%EC%9A%A9%ED%95%9C-%EC%8B%9C%EB%A0%A5%EA%B5%90%EC%A0%95%EC%88%A0-%EA%B2%B0%EA%B3%BC-%EB%B9%84%EA%B5%90-%EC%B0%A8%ED%8A%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 10 Jul 2026 05:39:03 GMT</pubDate>
            <description><![CDATA[<ul>
<li>시력교정술 예측 서비스에서는 단순히 하나의 수술 결과를 보여주는 것이 아니라 여러 수술 방식을 한 화면에서 비교할 수 있어야 한다.</li>
<li>사용자는 다음과 같은 수술 방식의 예측 결과를 확인할 수 있다.<ul>
<li>LASIK</li>
<li>LASEK</li>
<li>SMILE</li>
<li>ICL</li>
</ul>
</li>
<li>또한 각 수술 방식에 대해 다음 항목을 비교해야 했다.<ul>
<li>시력(UDVA)</li>
<li>구면도수(SPH)</li>
<li>난시도수(CYL)</li>
</ul>
</li>
</ul>
<h2 id="요구사항">요구사항</h2>
<ul>
<li>API 응답은 우안(OD), 좌안(OS) 기준으로 나뉘며 각 눈마다 다음 데이터를 포함하고 있었다.<ul>
<li>시력(UDVA)</li>
<li>구면도수(SPH)</li>
<li>난시도수(CYL)</li>
</ul>
</li>
<li>그리고 각 항목은 다시 수술 방식별 예측 결과를 가지고 있다.<pre><code>visionComparison: {
od: {
  udva: {...},
  sph: {...},
  cyl: {...}
},
os: {
  udva: {...},
  sph: {...},
  cyl: {...}
}
}</code></pre></li>
<li>차트는 다음 조건을 만족해야 함<ul>
<li>우안과 좌안을 동일한 UI로 표시</li>
<li>여러 수술 방식을 동시에 비교</li>
<li>3일, 3주, 3개월 시점의 데이터를 표시</li>
<li>수술 방식이 고정되어 있지 않음</li>
<li>UDVA는 변화 추이를 보여야 함(선 그래프)</li>
<li>SPH, CYL은 수치 비교가 가능해야 함(막대 그래프)<h2 id="공통-데이터-구조-설계">공통 데이터 구조 설계</h2>
</li>
</ul>
</li>
</ul>
<p>라인 차트와 바 차트가 서로 다른 데이터를 사용하면 컴포넌트 재사용이 어려워진다.</p>
<p>그래서 차트 종류와 관계없이 동일한 데이터 구조를 사용할 수 있도록 설계했다.</p>
<pre><code>export type VisionValue = {
  &quot;3days&quot;: number | null;
  &quot;3weeks&quot;: number | null;
  &quot;3months&quot;: number | null;
};

export type SurgeryVision = {
  LASIK?: VisionValue;
  SMILE?: VisionValue;
  LASEK?: VisionValue;
  ICL?: VisionValue;
};

export type VisionComparisonItem = {
  udva: SurgeryVision;
  sph: SurgeryVision;
  cyl: SurgeryVision;
};</code></pre><ul>
<li><p>실제 API 응답은 다음과 같은 형태이다.</p>
<pre><code>{
LASIK: {
  &quot;3days&quot;: 0.9,
  &quot;3weeks&quot;: 1.0,
  &quot;3months&quot;: 1.2
},

SMILE: {
  &quot;3days&quot;: 1.0,
  &quot;3weeks&quot;: 1.1,
  &quot;3months&quot;: 1.2
}
}</code></pre></li>
<li><p>수술 방식은 환자마다 달라질 수 있기 때문에 모든 속성을 Optional로 구성하였다.</p>
<h2 id="chartpanel을-활용한-공통-ui-구성">ChartPanel을 활용한 공통 UI 구성</h2>
</li>
<li><p>UDVA는 Line Chart, SPH와 CYL은 Bar Chart로 표현해야 했지만 카드 UI와 범례는 모두 동일했다.</p>
</li>
<li><p>그래서 공통 레이아웃을 담당하는 <code>ChartPanel</code> 컴포넌트를 만들고, 차트 종류만 props로 전달하도록 구성했다.</p>
<pre><code>&lt;ChartPanel
title=&quot;시력&quot;
data={data.udva}
mode=&quot;line&quot;
/&gt;
</code></pre></li>
</ul>
<p><ChartPanel
  title="구면도수"
  data={data.sph}
  mode="bar"
  chartType="sph"
/></p>
<p><ChartPanel
  title="난시도수"
  data={data.cyl}
  mode="bar"
  chartType="cyl"
/></p>
<pre><code>
## Recharts용 데이터 가공
- API 응답 구조를 그대로 사용할 수는 없었다.
- Recharts는 시계열 데이터를 배열 형태로 전달해야 하기 때문
- 기존 API 응답</code></pre><p>{
  &quot;LASIK&quot;: {
           &quot;3days&quot;: 1,
           &quot;3weeks&quot;: 1,
           &quot;3months&quot;: 1
          },
  &quot;SMILE&quot;: {
           &quot;3days&quot;: 1,
           &quot;3weeks&quot;: 1,
           &quot;3months&quot;: 1.2
     }
}</p>
<p>````</p>
<ul>
<li>이런 식으로 가공해야함<pre><code>[
{
  label: &quot;3일&quot;,
  LASIK: 0.9,
  SMILE: 1.0,
  LASEK: 0.8,
  ICL: 0.95
},
{
  label: &quot;3주&quot;,
  LASIK: 1.0,
  SMILE: 1.1,
  LASEK: 0.9,
  ICL: 1.0
},
{
  label: &quot;3개월&quot;,
  LASIK: 1.2,
  SMILE: 1.2,
  LASEK: 1.1,
  ICL: 1.1
}
]</code></pre></li>
<li>이를 위해 렌더링 전에 Recharts에서 사용할 수 있는 형태로 데이터를 가공함</li>
</ul>
<pre><code>const chartData = [
  {
    label: &quot;3일&quot;,
    LASIK: data.LASIK?.[&quot;3days&quot;],
    LASEK: data.LASEK?.[&quot;3days&quot;],
    SMILE: data.SMILE?.[&quot;3days&quot;],
    ICL: data.ICL?.[&quot;3days&quot;],
  },
  ...
];</code></pre><h2 id="수술-방식에-따른-동적-시리즈-생성">수술 방식에 따른 동적 시리즈 생성</h2>
<ul>
<li>어떤 환자는 LASIK과 SMILE만 추천될 수 있고,</li>
<li>다른 환자는 네 가지 수술 방식이 모두 제공될 수도 있다.
=&gt; 데이터에 존재하는 수술 방식만 추출하여 동적으로 생성하도록 구현하였다.</li>
</ul>
<pre><code>export type SurgeryType = &quot;LASIK&quot; | &quot;LASEK&quot; | &quot;SMILE&quot; | &quot;IC

const visibleTypes =
  Object.keys(data) as SurgeryType[];</code></pre><pre><code> {visibleTypes.map((type) =&gt; {
   const chip = chipList.find((item) =&gt; item.name === type);

   if (!chip) return null;

    return (
      &lt;Line
         key={type}
         type=&quot;linear&quot;
         dataKey={type}
         stroke={chip.color}
         strokeWidth={3}
         dot={false}
         activeDot={false}
         isAnimationActive={false}
     /&gt;
     );
    })}</code></pre><ul>
<li>이렇게 구현하면<ul>
<li>LASIK만 있는 경우</li>
<li>LASIK + SMILE인 경우</li>
<li>모든 수술 방식이 있는 경우</li>
</ul>
</li>
<li>모두 하나의 컴포넌트로 처리할 수 있다.</li>
</ul>
<h2 id="동일한-구조로-bar-chart-구현">동일한 구조로 Bar Chart 구현</h2>
<ul>
<li>시력(UDVA)은 시간에 따른 변화 추이를 확인하는 것이 중요했기 때문에 <code>LineChart</code>를 사용하였다.</li>
<li>반면 구면도수(SPH)와 난시도수(CYL)는 수술 방식별 수치를 비교하는 것이 목적이었기 때문에 <code>BarChart</code>를 사용하였다.</li>
<li>하지만 데이터 구조는 동일하게 유지하였다.<pre><code>&lt;ChartPanel
title=&quot;구면도수&quot;
data={data.sph}
mode=&quot;bar&quot;
/&gt;
</code></pre></li>
</ul>
<p><ChartPanel
  title="난시도수"
  data={data.cyl}
  mode="bar"
/>
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js에서 NICEPAY JS SDK 연동하기]]></title>
            <link>https://velog.io/@gayeong__0916/Next.js%EC%97%90%EC%84%9C-NICEPAY-JS-SDK-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/Next.js%EC%97%90%EC%84%9C-NICEPAY-JS-SDK-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 06 Jul 2026 07:42:25 GMT</pubDate>
            <description><![CDATA[<ul>
<li>NICEPAY는 별도의 React 라이브러리나 npm 패키지를 제공하지 않는다.</li>
<li>대신 브라우저에서 JS SDK를 로드한뒤 AUTHNICE.requestPay()를 호출하는 방식으로 결제 진행</li>
<li>공식 문서에서도 JS SDK URL(<a href="https://pay.nicepay.co.kr/v1/js/)%EC%9D%84">https://pay.nicepay.co.kr/v1/js/)을</a> 로드한 후 AUTHNICE.requestPay()를 사용하도록 안내하고 있다.</li>
</ul>
<h2 id="요구-사항">요구 사항</h2>
<ul>
<li>사용자가 충전 금액 선택</li>
<li>주문 생성 API 호출</li>
<li>NICEPAY 카드 결제 진행</li>
<li>결제 성공 시 백엔드 검증 후 결과 페이지 이동</li>
<li>결제 실패 시 실패 페이지로 이동</li>
</ul>
<h3 id="1-결제-금액-선택">1. 결제 금액 선택</h3>
<ul>
<li>먼저 사용자가 충전 금액을 선택할 수 있도록 구현했다.</li>
<li>백엔드에서 충전 상품 목록을 조회한 뒤 버튼 형태로 렌더링하였다.<pre><code>const [selected, setSelected] = useState&lt;number | null&gt;(null);
const [selectedIdx, setSelectedIdx] = useState&lt;number | null&gt;(null);</code></pre></li>
<li>선택된 상품의 가격과 상품 ID를 상태로 관리하였다.</li>
</ul>
<h3 id="2-주문-생성-api-호출">2. 주문 생성 API 호출</h3>
<ul>
<li>실제 결제 요청 전에 백엔드에서 주문 정보를 생성한다.<pre><code>const { data: orderInfo } = useQuery({
queryKey: [&quot;productOrder&quot;, selectedIdx],
enabled: open &amp;&amp; selectedIdx !== null,
queryFn: () =&gt;
  getAdjustmentOrder({
    productInfoIdx: selectedIdx!,
  }),
});
</code></pre></li>
</ul>
<pre><code>- 주문 생성 API를 통해 다음 정보를 받아온다.
    - orderId
    - amount
- NICEPAY는 결제 요청 시 orderId가 반드시 필요하기 때문에 사전에 생성해두어야 한다.
### 3. NICEPAY 스크립트 로드
- NICEPAY는 외부 스크립트를 통해 결제 창을 제공한다.
- Next.js에서는 Script 컴포넌트를 사용하여 로드했다.
- 스크립트가 로드되지 않은 상태에서 결제를 시도하면 오류가 발생할 수 있으므로 별도의 상태값으로 관리했다.</code></pre><Script
  src="https://pay.nicepay.co.kr/v1/js/"
  strategy="afterInteractive"
  onLoad={() => setScriptLoaded(true)}
/>

if (!scriptLoaded || !window.AUTHNICE) {
  alert("결제 모듈을 불러오는 중입니다.");
  return;
}
```
### 4. 결제 요청
- 실제 결제는 AUTHNICE.requestPay를 호출하여 진행한다.
- `returnUrl` : 결제 완료 후 이동할 URL => 백엔드로 전달할 api 요청
```
window.AUTHNICE.requestPay({
  clientId,
  method: "card",
  orderId: orderInfo.orderId,
  amount: orderInfo.amount,
  goodsName: `캐시 ${formatPrice(selected)} 충전`,
  returnUrl: `${process.env.NEXT_PUBLIC_API_BASE_URL}/adjustment?redirectBaseUrl=${encodeURIComponent(redirectBaseUrl)}`,

   fnError: (result) => {
       const params = new URLSearchParams({
         code: result.errorCode,
         message: result.errorMsg,
        });

      window.location.href = `/billing/payment/fail?${params.toString()}`;
      },
});
```
### 5. 결제 완료 후 처리
- 결제가 완료되면 NICEPAY는 returnUrl로 결과를 전달한다.
```
const redirectBaseUrl = window.location.origin;

returnUrl =
  `${API_BASE_URL}/adjustment?redirectBaseUrl=${encodeURIComponent(
    redirectBaseUrl
  )}`;
```
- 백엔드는 NICEPAY로부터 전달받은 결제 정보를 검증한 뒤 최종 결제 승인 처리를 수행한다.
- 이후 성공 여부에 따라 프론트의 성공 페이지 또는 실패 페이지로 리다이렉트한다.
- 또한 window.location.origin을 함께 전달하여 로컬, 테스트 서버, 운영 서버 환경에 따라 올바른 페이지로 이동할 수 있도록 처리하였다.
### 6. 결제 실패 처리
- 결제 실패도 별도로 처리했다.
```
fnError: (result) => {
  const params = new URLSearchParams({
    code: result.errorCode,
    message: result.errorMsg,
  });

  window.location.href =
    `/billing/payment/fail?${params.toString()}`;
}

```
- 실패 시 에러 코드, 에러 메시지를 전달하여 사용자에게 실패 사유를 표시할 수 있도록 구성했다.

## 토스페이먼츠와의 차이점
- 토스는 프론트 중심의 흐름이라면 NICEPAY는 백엔드 중심의 흐름에 가깝다.
### 토스페이먼츠
- 토스페이먼츠는 결제 완료 후 성공 URL 또는 실패 URL로 이동한다.
- 프론트엔드에서 결제 결과를 직접 받아 백엔드 승인 API를 호출하는 구조를 사용할 수 있다.

결제창
  ↓
성공 URL 이동
  ↓
프론트엔드
  ↓
백엔드 승인 API 호출
### NICEPAY
- 반면 NICEPAY는 결제 결과를 먼저 returnUrl로 전달한다.
- 일반적으로 returnUrl은 백엔드 API로 구성하며, 백엔드에서 결제 검증 및 승인 처리를 수행한 후 최종 페이지로 리다이렉트한다.

결제창
  ↓
returnUrl 호출
  ↓
백엔드
  ↓
결제 검증 및 승인
  ↓
성공/실패 페이지 이동]]></description>
        </item>
        <item>
            <title><![CDATA[VSCode와 gitlab의 ssh를 연동하기]]></title>
            <link>https://velog.io/@gayeong__0916/VSCode%EC%99%80-gitlab%EC%9D%98-ssh%EB%A5%BC-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/VSCode%EC%99%80-gitlab%EC%9D%98-ssh%EB%A5%BC-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 01 Jul 2026 23:32:40 GMT</pubDate>
            <description><![CDATA[<h3 id="1-gitlab-프로젝트의-ssh-주소-복사">1. gitlab 프로젝트의 ssh 주소 복사</h3>
<h3 id="2-원격-저장소-연결">2. 원격 저장소 연결</h3>
<pre><code>git remote add origin git@gitlab.com:그룹명/프로젝트명.git</code></pre><h4 id="2-1-이미-origin이-있다면-아래처럼-변경">2-1. 이미 origin이 있다면 아래처럼 변경</h4>
<pre><code>git remote set-url origin git@gitlab.com:그룹명/프로젝트명.git</code></pre><ul>
<li>origin = 내 프로젝트가 연결된 원격 저장소 이름</li>
<li>git remote -v<pre><code>origin  https://gitlab.com/idy_dev/2601.ref-web-front.git (fetch)
origin  https://gitlab.com/idy_dev/2601.ref-web-front.git (push)</code></pre></li>
<li>이런 게 나오면 이미 GitLab이랑 연결돼 있다 = origin이 있다</li>
<li>origin이 없으면</li>
<li>git remote -v -&gt; 아무것도 안 나옴 -&gt; 이때는 새로 추가해야 함:<pre><code>git remote add origin git@gitlab.com:...</code></pre></li>
<li>왜 set-url을 쓰냐?
이미 origin이 있는데 또 추가하려고 하면:<pre><code>git remote add origin ... -&gt; 에러남 remote origin already exists.</code></pre></li>
<li>그래서 기존 origin을 “수정”<pre><code>git remote set-url origin git@gitlab.com:...</code></pre></li>
</ul>
<h3 id="3-ssh-키가-있는지-확인">3. ssh 키가 있는지 확인</h3>
<pre><code>ls ~/.ssh</code></pre><p>아래 파일이 있으면 ssh 키가 이미 있는 상태</p>
<pre><code>id_ed25519
id_ed25519.pub</code></pre><h3 id="4-ssh-연결-테스트">4. ssh 연결 테스트</h3>
<pre><code>ssh -T git@gitlab.com</code></pre><p>성공하면 </p>
<pre><code>Welcome to GitLab, @아이디!</code></pre><p>이렇게 나옴 -&gt; gitlab ssh 인증 완료</p>
<h3 id="5-ssh-키가-없다면-생성">5. ssh 키가 없다면 생성</h3>
<pre><code>ssh-keygen -t ed25519 -C &quot;GitLab 이메일&quot;</code></pre><p>공개 키를 확인한다</p>
<pre><code>cat ~/.ssh/id_ed25519.pub</code></pre><p>출력된 내용을 복사해서 GitLab에 등록한다</p>
<h3 id="6-모든-과정이-끝나면">6. 모든 과정이 끝나면</h3>
<pre><code>git pull origin main</code></pre><h3 id="7-next-사용할거면">7. next 사용할거면</h3>
<pre><code>npx create-next-app@latest .</code></pre><h3 id="8-만약-gitlab은-빈-프로젝트-readme만-있음일-경우">8. 만약 GitLab은 빈 프로젝트 (README만 있음)일 경우</h3>
<pre><code>git pull origin main   # README 가져오기
rm README.md           # (선택)
npx create-next-app@latest .</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[deploy.sh를 이용한 Next.js 프로젝트 수동 배포]]></title>
            <link>https://velog.io/@gayeong__0916/deploy.sh%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%88%98%EB%8F%99-%EB%B0%B0%ED%8F%AC</link>
            <guid>https://velog.io/@gayeong__0916/deploy.sh%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-Next.js-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EC%88%98%EB%8F%99-%EB%B0%B0%ED%8F%AC</guid>
            <pubDate>Wed, 24 Jun 2026 04:35:59 GMT</pubDate>
            <description><![CDATA[<ul>
<li>파이프라인을 거치지 않고 직접 반영해야할 때 수동 배포를 사용함</li>
</ul>
<h2 id="1-서버-접속">1. 서버 접속</h2>
<pre><code>ssh ubuntu@서버IP</code></pre><h2 id="2-프로젝트-폴더로-이동">2. 프로젝트 폴더로 이동</h2>
<pre><code>cd /home/ubuntu/~</code></pre><h2 id="3-배포-파일-업로드">3. 배포 파일 업로드</h2>
<ul>
<li>파일 수정 후 npm run build 꼭 하기!!!</li>
<li>로컬에서 빌드 결과물을 압축
```</li>
<li>tar -czf deploy.tar.gz .next public package.json package-lock.json next.config.ts .env.production
```</li>
<li>생성된 압축 파일을 서버로 전송<pre><code>scp deploy.tar.gz ubuntu@서버IP:/home/ubuntu/경로</code></pre><h2 id="4-deploysh-파일-생성-또는-수정">4. deploy.sh 파일 생성 또는 수정</h2>
</li>
<li>vi deploy.sh : deploy.sh 파일 생성 또는 수정</li>
<li>아래 내용 작성<pre><code>#!/bin/bash
</code></pre></li>
</ul>
<p>set -e</p>
<p>echo &quot;===== ERP Front Deploy Start =====&quot;</p>
<p>cd /home/ubuntu/ERP/Front</p>
<p>echo &quot;기존 .next / public 삭제&quot;
rm -rf .next public</p>
<p>echo &quot;압축 해제&quot;
tar -xzf deploy.tar.gz</p>
<p>echo &quot;압축 파일 삭제&quot;
rm -f deploy.tar.gz</p>
<p>echo &quot;패키지 설치&quot;
npm ci</p>
<p>echo &quot;기존 3000 포트 종료&quot;
fuser -k 3000/tcp || true</p>
<p>echo &quot;Next 서버 실행&quot;
nohup npm run start -- -p 3000 &gt; erp-front.log 2&gt;&amp;1 &amp;</p>
<p>echo &quot;===== Deploy Complete =====&quot;</p>
<pre><code>- chomod +x deploy.sh : 실행 권한 부여
- .deploy.sh : 배포 스크립트 실행</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[GitLab CI/CD로 dev와 main 브랜치별 자동 배포 구성하기]]></title>
            <link>https://velog.io/@gayeong__0916/GitLab-CICD%EB%A1%9C-dev%EC%99%80-main-%EB%B8%8C%EB%9E%9C%EC%B9%98%EB%B3%84-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/GitLab-CICD%EB%A1%9C-dev%EC%99%80-main-%EB%B8%8C%EB%9E%9C%EC%B9%98%EB%B3%84-%EC%9E%90%EB%8F%99-%EB%B0%B0%ED%8F%AC-%EA%B5%AC%EC%84%B1%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 24 Jun 2026 04:34:32 GMT</pubDate>
            <description><![CDATA[<ul>
<li>dev 브랜치 -&gt; 테스트 서버 배포</li>
<li>main 브랜치 -&gt; 운영 서버 배포</li>
</ul>
<h2 id="전체-파이프라인-구조">전체 파이프라인 구조</h2>
<p>stages: </p>
<ul>
<li>setup : 패키지 설치</li>
<li>test : 코드 검사</li>
<li>build : Next.js 빌드</li>
<li>deploy : 서버 배포</li>
</ul>
<p>예시 파이프라인 구조</p>
<pre><code>image: node:20-alpine

stages:
  - setup
  - test
  - build
  - deploy

cache:
  key: ${CI_COMMIT_REF_SLUG}
  paths:
    - .npm/
    - node_modules/
    - .next/cache/

variables:
  SERVER_USER: &quot;ubuntu&quot;
  TEST_SERVER_HOST: &quot;111.111.111.111&quot;
  RELEASE_SERVER_HOST: &quot;111.111.111.111&quot;

install_job:
  stage: setup
  only:
    - dev
    - main
  script:
    - echo &quot;패키지 설치를 시작합니다...&quot;
    - npm ci --cache .npm --prefer-offline
    - echo &quot;패키지 설치 완료!&quot;

lint_test_job:
  stage: test
  only:
    - dev
    - main
  script:
    - echo &quot;코드 스타일 및 타입을 검사합니다...&quot;
    - npm run lint
    - echo &quot;검사 통과!&quot;

build_job:
  stage: build
  only:
    - dev
    - main
  script:
    - echo &quot;[Client] 빌드를 시작합니다...&quot;
    - rm -rf .next

    - echo &quot;빌드용 환경변수 파일 생성 중...&quot;
    - |
      if [ &quot;$CI_COMMIT_BRANCH&quot; = &quot;dev&quot; ]; then
        echo &quot;NEXT_PUBLIC_API_BASE_URL=https://www.abc~&quot; &gt; .env.production
      elif [ &quot;$CI_COMMIT_BRANCH&quot; = &quot;main&quot; ]; then
        echo &quot;NEXT_PUBLIC_API_BASE_URL=https://www.bcd~&quot; &gt; .env.production
      else
        echo &quot;지원하지 않는 브랜치입니다: $CI_COMMIT_BRANCH&quot;
        exit 1
      fi

    - echo &quot;NEXT_PUBLIC_NICE_CLIENT_KEY=${NEXT_PUBLIC_NICE_CLIENT_KEY}&quot; &gt;&gt; .env.production

    - echo &quot;생성된 .env.production 확인&quot;
    - cat .env.production

    - npm run build

    - echo &quot;파일 압축 중...&quot;
    - tar -czf deploy.tar.gz .next public package.json package-lock.json next.config.ts .env.production

  artifacts:
    paths:
      - deploy.tar.gz
    expire_in: 1 hour

deploy_test_job:
  stage: deploy
  only:
    - dev

  variables:
    TARGET_DIR: &quot;/home/ubuntu/&quot;

  dependencies:
    - build_job

  before_script:
    - apk add --no-cache openssh-client sshpass

  script:
    - echo &quot;테스트 서버로 전송 중 (${TEST_SERVER_HOST})...&quot;
    - sshpass -p &quot;$TEST_SERVER_PASSWORD&quot; scp -o StrictHostKeyChecking=no deploy.tar.gz ${SERVER_USER}@${TEST_SERVER_HOST}:${TARGET_DIR}

    - echo &quot;테스트 서버에서 압축 해제 및 배포 실행...&quot;
    - |
      sshpass -p &quot;$TEST_SERVER_PASSWORD&quot; ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${TEST_SERVER_HOST} &lt;&lt; EOF
        cd ${TARGET_DIR}

        echo &quot;기존 파일 삭제 중...&quot;
        rm -rf .next public

        echo &quot;압축 해제 중...&quot;
        tar -xzf deploy.tar.gz

        echo &quot;압축 파일 삭제...&quot;
        rm -f deploy.tar.gz

        echo &quot;현재 서버 파일 목록:&quot;
        ls -al

        echo &quot;deploy.sh 확인:&quot;
        ls -al deploy.sh || true

        echo &quot;배포 스크립트 실행...&quot;
        bash -l deploy.sh
      EOF

    - echo &quot;[Client] 테스트 서버 배포가 완료되었습니다!&quot;

deploy_release_job:
  stage: deploy
  only:
    - main

  variables:
    TARGET_DIR: &quot;/home/ubuntu/&quot;

  dependencies:
    - build_job

  before_script:
    - apk add --no-cache openssh-client
    - mkdir -p ~/.ssh
    - cp &quot;$AWS_SSH_KEY&quot; ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - ssh-keyscan -H &quot;${RELEASE_SERVER_HOST}&quot; &gt;&gt; ~/.ssh/known_hosts

  script:
    - echo &quot;릴리즈 서버로 전송 중 (${RELEASE_SERVER_HOST})...&quot;
    - scp -o StrictHostKeyChecking=no deploy.tar.gz ${SERVER_USER}@${RELEASE_SERVER_HOST}:${TARGET_DIR}

    - echo &quot;릴리즈 서버에서 압축 해제 및 배포 실행...&quot;
    - |
      ssh -o StrictHostKeyChecking=no ${SERVER_USER}@${RELEASE_SERVER_HOST} &lt;&lt; EOF
        cd ${TARGET_DIR}

        echo &quot;기존 파일 삭제 중...&quot;
        rm -rf .next public

        echo &quot;압축 해제 중...&quot;
        tar -xzf deploy.tar.gz

        echo &quot;압축 파일 삭제...&quot;
        rm -f deploy.tar.gz

        echo &quot;현재 서버 파일 목록:&quot;
        ls -al

        echo &quot;deploy.sh 확인:&quot;
        ls -al deploy.sh || true

        echo &quot;배포 스크립트 실행...&quot;
        bash -l deploy.sh
      EOF

    - echo &quot;[Client] 릴리즈 서버 배포가 완료되었습니다!&quot;</code></pre><h3 id="빌드-환경">빌드 환경</h3>
<ul>
<li>image: node:20-alpine</li>
<li>GitLab Runner가 사용할 Node.js 환경 지정</li>
<li>node:20-alpine을 사용한 이유는 Node.js 20 버전을 사용할 수 있으면서도 이미지 크기가 작아 파이프라인 실행 속도에 유리하기 때문</li>
</ul>
<h3 id="캐시-설정">캐시 설정</h3>
<ul>
<li>cache: key: ${CI_COMMIT_REF_SLUG} 
  paths: <pre><code>  - .npm/ 
  - node_modules/ 
  - .next/cache/</code></pre></li>
<li>CI 환경은 매번 새로운 컨테이너에서 실행되기 때문에 패키지 설치와 빌드 캐시가 없으면 시간이 오래 걸리기 때문에 위의 항목들을 캐싱함</li>
<li>CI_COMMIT_REF_SLUG를 캐시 키로 사용하여 브랜치별로 캐시가 분리되도록 함</li>
</ul>
<h3 id="공통-변수-관리">공통 변수 관리</h3>
<ul>
<li>서버 접속에 필요한 공통 값을 변수로 분리함</li>
</ul>
<h3 id="1단계--패키지-설치">1단계 : 패키지 설치</h3>
<ul>
<li>dev와 main 브랜치에서만 패키지 설치가 실행되도록 함</li>
<li>npm ci는 package-lock.json을 기준으로 정확하게 의존성을 설치하기 때문에 CI 환경에서 더 안정적</li>
</ul>
<h3 id="2단계--코드-검사">2단계 : 코드 검사</h3>
<ul>
<li>빌드 전에 코드 스타일과 타입 검사 수행</li>
<li>문제가 있는 코드가 서버에 배포되는 것을 막을 수 있음</li>
</ul>
<h3 id="3단계--브랜치별-환경변수-생성-후-빌드">3단계 : 브랜치별 환경변수 생성 후 빌드</h3>
<ul>
<li>dev와 main 브랜치에서만 빌드가 실행</li>
<li>rm -rf .next : 기존 빌드 파일 삭제</li>
<li>브랜치별 .env.production 생성</li>
<li>Next.js 빌드 실행</li>
<li>tar -czf~ : 배포 파일 압축<ul>
<li>압축 대상 :
.next
public
package.json
package-lock.json
next.config.ts
.env.production</li>
</ul>
</li>
<li>Artifacts로 배포 파일 전달</li>
</ul>
<h3 id="4단계--테스트-서버-배포">4단계 : 테스트 서버 배포</h3>
<ul>
<li>테스트 서버는 비밀번호 기반 SSH 접속 사용</li>
<li>sshpass를 설치한 뒤 TEST_SERVER_PASSWORD를 사용해 접속함</li>
<li>scp deploy.tar.gz~ : 빌드 단게에서 만든 압축 파일을 테스트 서버로 전송</li>
<li>cd ${TARGET_DIR} : 배포 경로로 이동</li>
<li>rm -rf .next public : 기존 .next, public 삭제</li>
<li>tar -xzf deploy.tar.gz : 압축 파일 해제</li>
<li>rm -f deploy.tar.gz  : 압축 파일 삭제</li>
<li>bash -l deploy.sh : deploy.sh 실행</li>
<li>deploy.sh에 삭제 해제 등의 명령어가 있다면 rm~ 따로 실행하지 않아도 됨</li>
</ul>
<h3 id="4단계--릴리즈-서버-배포">4단계 : 릴리즈 서버 배포</h3>
<ul>
<li>TARGET_DIR 따로 설정</li>
<li>SSH Key 방식으로 접속</li>
<li>GitLab Variables에 등록된 AWS_SSH_KEY를 Runner 내부의 ~/.ssh/id_rsa로 복사하고 권한을 설정</li>
<li>SSH 키 파일은 권한이 너무 열려 있으면 사용할 수 없기 때문에 600 권한을 지정해야 함</li>
<li>known_hosts 등록 : SSH 접속 시 호스트 확인 프롬프트가 뜨지 않도록 서버 정보를 미리 등록</li>
<li>릴리즈 서버로 파일 전송</li>
<li>테스트 서버와 동일하게 서버 내부에서 압축을 풀고 deploy.sh 실행</li>
</ul>
<h2 id="다른-프로젝트에서-base-path가-필요한-경우">다른 프로젝트에서 base path가 필요한 경우</h2>
<ul>
<li>테스트 서버는 하나의 도메인 아래 특정 경로로 프론트엔드를 서비스해야 했기 때문에 basePath가 필요했음</li>
<li>반면 로컬과 운영 서버는 루트 경로(/)에서 서비스되므로 basePath를 적용하지 않아야 함
```</li>
<li>|
if [ &quot;$CI_COMMIT_BRANCH&quot; = &quot;dev&quot; ]; then
  echo &quot;NEXT_PUBLIC_API_BASE_URL=<a href="https://www.abc~&quot;">https://www.abc~&quot;</a> &gt; .env.production
  echo &quot;NEXT_PUBLIC_BASE_PATH=/경로&quot; &gt;&gt; .env.production
elif [ &quot;$CI_COMMIT_BRANCH&quot; = &quot;main&quot; ]; then
  echo &quot;NEXT_PUBLIC_API_BASE_URL=<a href="https://www.bcd~&quot;">https://www.bcd~&quot;</a> &gt; .env.production
else
  echo &quot;지원하지 않는 브랜치입니다: $CI_COMMIT_BRANCH&quot;
  exit 1
fi
```
=&gt; dev 브랜치에서만 .env.production에 NEXT_PUBLIC_BASE_PATH=/경로 를 추가함</li>
<li>또한 next.config.ts 설정<pre><code>import type { NextConfig } from &quot;next&quot;;
</code></pre></li>
</ul>
<p>const basePath = process.env.NEXT_PUBLIC_BASE_PATH || &quot;&quot;;</p>
<p>const nextConfig: NextConfig = {
  basePath,
};</p>
<p>export default nextConfig;</p>
<pre><code>- 테스트 서버에서만 사용할거라면 .env.local에도 적지 않아야함</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[ExcelJS를 활용한 엑셀 다운로드 기능 구현하기]]></title>
            <link>https://velog.io/@gayeong__0916/%EC%97%91%EC%85%80-%ED%91%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/%EC%97%91%EC%85%80-%ED%91%9C-%EB%A7%8C%EB%93%A4%EA%B8%B0</guid>
            <pubDate>Mon, 08 Jun 2026 00:59:10 GMT</pubDate>
            <description><![CDATA[<h2 id="요구사항">요구사항</h2>
<ul>
<li>조회된 데이터를 엑셀로 다운로드</li>
<li>선택한 기간을 파일명에 포함</li>
<li>선택한 필터 정보를 파일명에 포함</li>
<li>헤더 스타일 적용</li>
<li>컬럼 너비 지정</li>
<li>숫자 데이터 가독성 향상</li>
<li>예) 충전금내역_2026-05-01_2026-05-31_충전금_열람.xlsx</li>
</ul>
<h2 id="exceljs-선택-이유">ExcelJS 선택 이유</h2>
<pre><code>npm install exceljs</code></pre><ul>
<li>워크북 생성</li>
<li>시트 생성</li>
<li>셀 스타일 지정</li>
<li>컬럼 너비 지정</li>
<li>엑셀 파일 다운로드</li>
<li>기본 구조
등을 쉽게 처리 가능</li>
</ul>
<h2 id="workbook과-worksheet-생성">Workbook과 Worksheet 생성</h2>
<pre><code>const wb = new ExcelJS.Workbook();
const ws = wb.addWorksheet(&quot;충전금 내역&quot;);</code></pre><h2 id="컬럼-정의">컬럼 정의</h2>
<pre><code>ws.columns = [
  { header: &quot;일시&quot;, key: &quot;date&quot;, width: 20 },
  { header: &quot;내용&quot;, key: &quot;content&quot;, width: 40 },
  { header: &quot;포인트 변동&quot;, key: &quot;point&quot;, width: 20 },
  { header: &quot;잔여포인트&quot;, key: &quot;balance&quot;, width: 20 },
];</code></pre><ul>
<li>일시 / 내용 / 포인트 변동 / 잔여 포인트 <ul>
<li>형태의 테이블이 생성됨</li>
</ul>
</li>
</ul>
<h2 id="데이터-추가">데이터 추가</h2>
<ul>
<li>API에서 받은 데이터를 행 단위로 추가<pre><code>rows.forEach((r) =&gt; {
ws.addRow({
  date: formatDateTimeWithoutSeconds(r.date),
  content: r.content ?? &quot;-&quot;,
  point: r.point ?? &quot;-&quot;,
  balance: (r.balance ?? 0).toLocaleString(),
});
});</code></pre></li>
</ul>
<h2 id="헤더-스타일-적용">헤더 스타일 적용</h2>
<ul>
<li><p>배경색 추가</p>
</li>
<li><p>Bold 처리</p>
</li>
<li><p>중앙 정렬</p>
<pre><code>ws.getRow(1).eachCell((cell) =&gt; {
cell.fill = {
  type: &quot;pattern&quot;,
  pattern: &quot;solid&quot;,
  fgColor: { argb: &quot;FFEEEEEE&quot; },
};

cell.font = {
  bold: true,
};

cell.alignment = {
  vertical: &quot;middle&quot;,
  horizontal: &quot;center&quot;,
};
});</code></pre></li>
</ul>
<h2 id="전체-셀-정렬-처리">전체 셀 정렬 처리</h2>
<ul>
<li>데이터 행은 왼쪽 정렬로 설정<pre><code>ws.eachRow((row) =&gt; {
row.eachCell((cell) =&gt; {
  cell.alignment = {
    vertical: &quot;middle&quot;,
    horizontal: &quot;left&quot;,
  };
});
});</code></pre></li>
</ul>
<h2 id="파일명에-검색-조건-반영하기">파일명에 검색 조건 반영하기</h2>
<ul>
<li>파일명 : 충전금내역_2026-05-01_2026-05-31_전체.xlsx 혹은 충전금내역_2026-05-01_2026-05-31_충전금_열람.xlsx</li>
<li>먼저 전체 선택 여부 판단<pre><code>const isAllSelected =
allFilterIdxs.length &gt; 0 &amp;&amp;
allFilterIdxs.every((idx) =&gt; selectedFilterIdxs.includes(idx));</code></pre></li>
<li>전체가 아니면 선택된 필터명 조합<pre><code>const selectedLabels = filterOptions
.filter((f) =&gt; selectedFilterIdxs.includes(f.filterIdx))
.map((f) =&gt; f.label);
</code></pre></li>
</ul>
<p>filterText = selectedLabels.join(&quot;_&quot;);</p>
<pre><code>
## 브라우저에서 다운로드 처리
- ExcelJs가 생성한 데이터를 Blob으로 변환한 뒤 다운로드 수행</code></pre><p>const buffer = await wb.xlsx.writeBuffer();</p>
<p>const blob = new Blob([buffer], {
  type: &quot;application/vnd.openxmlformats-officedocument.spreadsheetml.sheet&quot;,
});</p>
<pre><code>- 다운로드 링크 생성</code></pre><p>const url = URL.createObjectURL(blob);</p>
<p>const a = document.createElement(&quot;a&quot;);
a.href = url;
a.download = fileName;
a.click();</p>
<p>URL.revokeObjectURL(url);
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[토스 페이먼츠 next.js 연동]]></title>
            <link>https://velog.io/@gayeong__0916/%ED%86%A0%EC%8A%A4-%ED%8E%98%EC%9D%B4%EB%A8%BC%EC%B8%A0-next.js-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@gayeong__0916/%ED%86%A0%EC%8A%A4-%ED%8E%98%EC%9D%B4%EB%A8%BC%EC%B8%A0-next.js-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Mon, 11 May 2026 00:16:03 GMT</pubDate>
            <description><![CDATA[<pre><code>npm install @tosspayments/tosspayments-sdk</code></pre><pre><code>  const clientKey =
    process.env.NEXT_PUBLIC_TOSS_CLIENT_KEY ??
    &quot;test_ck_DpexMgkW36oWNvJzApKEVGbR5ozO&quot;;</code></pre><ul>
<li><p>이 버전은 비회원도 가능</p>
<pre><code>const handlePayment = async () =&gt; {
  if (!selected) {
    alert(&quot;충전 금액을 선택해주세요.&quot;);
    return;
  }

  // &quot;100,000원&quot; -&gt; 100000 (숫자)로 변환
  const amount = Number(selected.replace(/[^0-9]/g, &quot;&quot;));

  try {
    const tossPayments = await loadTossPayments(clientKey);

    // 결제창 띄우기
    const payment = tossPayments.payment({
      customerKey: &quot;ANONYMOUS&quot;, // 비회원 결제 시 필수값 (랜덤값도 가능)
    });

    await payment.requestPayment({
      method: &quot;CARD&quot;, // 결제 수단
      amount: {
        currency: &quot;KRW&quot;,
        value: amount,
      },
      orderId: `CHARGE-${Math.random().toString(36).slice(2, 11)}`,
      orderName: `캐시 ${selected} 충전`,
      successUrl: `${window.location.origin}/payment/success`,
      failUrl: `${window.location.origin}/payment/fail`,
    });
  } catch (error) {
    console.error(&quot;결제 요청 에러:&quot;, error);
  }
};
</code></pre></li>
</ul>
<pre><code>
- 로그인한 회원만 가능할 때</code></pre><p>&quot;use client&quot;;</p>
<p>import { useSession } from &quot;next-auth/react&quot;; // 예시: NextAuth 사용 시
// 또는 자신이 사용하는 Auth Hook (zustand, react-query 등)</p>
<p>export default function ChargeModal({ open, onClose }: ModalProps) {
  // 1. 로그인된 유저 정보 가져오기
  const { data: session } = useSession(); 
  const userIdentifier = session?.user?.id || &quot;ANONYMOUS&quot;; // 유저 고유 ID</p>
<p>  const handlePayment = async () =&gt; {
    // ... (금액 변환 로직 동일)</p>
<pre><code>try {
  const tossPayments = await loadTossPayments(clientKey);

  // 2. 고유 식별자를 customerKey에 할당
  const payment = tossPayments.payment({
    customerKey: userIdentifier, 
  });

  await payment.requestPayment({
    method: &quot;CARD&quot;,
    amount: {
      currency: &quot;KRW&quot;,
      value: amount,
    },
    orderId: `CHARGE-${Date.now()}-${userIdentifier.slice(0, 5)}`, // 주문번호에 유저 정보 살짝 섞기
    orderName: `캐시 ${selected} 충전`,
    // 3. 성공 시 우리 서버로 유저 정보를 같이 넘기기 위해 세팅 (선택사항)
    successUrl: `${window.location.origin}/payment/success?userId=${userIdentifier}`,
    failUrl: `${window.location.origin}/payment/fail`,
    customerEmail: session?.user?.email, // 유저 이메일 (결제창에 자동 입력됨)
    customerName: session?.user?.name,   // 유저 이름
  });
} catch (error) {
  console.error(error);
}</code></pre><p>  };</p>
<p>  // ... (UI 코드)
}</p>
<pre><code>
무조건 성공 또는 실패 후 페이지 이동 필수
</code></pre><p>&quot;use client&quot;;</p>
<p>import Modal from &quot;@/shared/ui/Modal&quot;;
import { useState } from &quot;react&quot;;</p>
<p>const SuccessPage = () =&gt; {
  const [successModalOpen, setSuccessModalOpen] = useState(true);</p>
<p>  return (
    &lt;Modal
      actions={[{ label: &quot;확인&quot;, onClick: () =&gt; {}, variant: &quot;green&quot; }]}
      title=&quot;결제 성공&quot;
      description=&quot;성공적으로 결제가 완료되었습니다.&quot;
      open={successModalOpen}
      onClose={() =&gt; {
        setSuccessModalOpen(false);
      }}
    /&gt;
  );
};</p>
<p>export default SuccessPage;</p>
<pre><code>
- 백엔드에게 정보 보내기</code></pre><p>const SuccessPage = () =&gt; {
  const [successModalOpen, setSuccessModalOpen] = useState(true);
  const searchParams = useSearchParams();</p>
<p>  useEffect(() =&gt; {
    // 1. URL에서 토스가 보내준 파라미터 추출
    const paymentKey = searchParams.get(&quot;paymentKey&quot;);
    const orderId = searchParams.get(&quot;orderId&quot;);
    const amount = searchParams.get(&quot;amount&quot;);</p>
<pre><code>const confirmPayment = async () =&gt; {
  try {
    // 2. 내 백엔드 서버(API)로 결제 승인 요청 보내기
    // BASE_URL은 아까 적어주신 http://123.142.202.212:8081/api/web 등을 활용하세요.
    const response = await axios.post(
      `${process.env.NEXT_PUBLIC_API_BASE_URL}/payments/confirm`,
      {
        paymentKey,
        orderId,
        amount: Number(amount),
      },
    );

    if (response.status === 200) {
      // 서버에서 최종 승인 성공 시 모달 띄우기
      setSuccessModalOpen(true);
    }
  } catch (error: any) {
    console.error(&quot;승인 실패:&quot;, error.response?.data || error.message);
    // 승인 실패 시 실패 페이지로 강제 이동하거나 에러 모달 처리
    router.push(
      `/billing/payment/fail?code=${error.response?.data?.code}&amp;message=${error.response?.data?.message}`,
    );
  } finally {
    setLoading(false);
  }
};

if (paymentKey &amp;&amp; orderId &amp;&amp; amount) {
  confirmPayment();
}</code></pre><p>  }, [searchParams, router]);</p>
<p>  if (loading) return <div>결제 승인 처리 중입니다...</div>;</p>
<p>  return (
    &lt;Modal
      actions={[{ label: &quot;확인&quot;, onClick: () =&gt; {}, variant: &quot;green&quot; }]}
      title=&quot;결제 성공&quot;
      description=&quot;성공적으로 결제가 완료되었습니다.&quot;
      open={successModalOpen}
      onClose={() =&gt; {
        setSuccessModalOpen(false);
      }}
    /&gt;
  );
};</p>
<pre><code>
실패
- 결제 시간 초과
- 결제 실패

- 토스에서 제공하는 에러 코드
PAY_PROCESS_ABORTED 사용자가 결제창을 닫음 &quot;결제가 취소되었습니다&quot; 
REJECT_CARD_PAYMENT 카드사 거절 (잔액부족 등) &quot;결제에 실패했습니다&quot; 
PAY_PROCESS_TIMEOUT 결제 시간 초과 &quot;시간 초과&quot; 
PROVIDER_TIMEOUT    카드사/은행 응답 초과 &quot;시간 초과&quot;

에러코드가 토스페이먼츠의 기본 작동 방식이 &quot;브라우저 주소창(URL)에 에러 정보를 담아서 약속된 페이지로 던져주는 것&quot;이기 때문입니다.
ex)만약 failUrl을 `https://mysite.com/payment/fail` 로 설정했다면, 실제 에러 발생 시 브라우저 주소창은 다음과 같이 변합니다:
</code></pre><p><a href="https://mysite.com/payment/fail?code=PAY_PROCESS_TIMEOUT&amp;message=%EA%B2%B0%EC%A0%9C%EC%8B%9C%EA%B0%84%EC%9D%B4+%EC%B4%88%EA%B3%BC%EB%90%98%EC%97%88%EC%8A%B5%EB%8B%88%EB%8B%A4">https://mysite.com/payment/fail?code=PAY_PROCESS_TIMEOUT&amp;message=결제시간이+초과되었습니다</a></p>
<pre><code></code></pre><p>&quot;use client&quot;;</p>
<p>import Modal from &quot;@/shared/ui/Modal&quot;;
import { useRouter, useSearchParams } from &quot;next/navigation&quot;;
import { useState } from &quot;react&quot;;</p>
<p>const FailPage = () =&gt; {
  const searchParams = useSearchParams();
  const router = useRouter();
  const [failModalOpen, setFailModalOpen] = useState(true);</p>
<p>  const errorCode = searchParams.get(&quot;code&quot;);</p>
<p>  const isTimeout =
    errorCode === &quot;PAY_PROCESS_TIMEOUT&quot; || errorCode === &quot;PROVIDER_TIMEOUT&quot;;
  const isAborted = errorCode === &quot;PAY_PROCESS_ABORTED&quot;; // 사용자가 직접 닫음</p>
<p>  const handleRetry = () =&gt; {
    router.push(&quot;/billing&quot;);
  };</p>
<p>  return (
    &lt;Modal
      actions={[{ label: &quot;확인&quot;, onClick: () =&gt; {}, variant: &quot;white&quot; }]}
      title=&quot;결제 실패&quot;
      description={
        isTimeout
          ? &quot;결제 가능 시간을 초과하였습니다. 다시 시도해주세요.&quot;
          : isAborted
            ? &quot;결제가 취소되었습니다.&quot;
            : &quot;결제 요청에 실패했습니다. 다시 시도해주세요.&quot;
      }
      open={failModalOpen}
      onClose={() =&gt; {
        setFailModalOpen(false);
        handleRetry();
      }}
    /&gt;
  );
};</p>
<p>export default FailPage;</p>
<p>```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[refresh API는 왜 반드시 별도 axios 인스턴스로 분리해야 할까?]]></title>
            <link>https://velog.io/@gayeong__0916/refresh-API%EB%8A%94-%EC%99%9C-%EB%B0%98%EB%93%9C%EC%8B%9C-%EB%B3%84%EB%8F%84-axios-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</link>
            <guid>https://velog.io/@gayeong__0916/refresh-API%EB%8A%94-%EC%99%9C-%EB%B0%98%EB%93%9C%EC%8B%9C-%EB%B3%84%EB%8F%84-axios-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4%EB%A1%9C-%EB%B6%84%EB%A6%AC%ED%95%B4%EC%95%BC-%ED%95%A0%EA%B9%8C</guid>
            <pubDate>Mon, 04 May 2026 06:23:52 GMT</pubDate>
            <description><![CDATA[<ul>
<li>refresh API가 기존 axios 인스턴스를 타면, 자기 자신을 가로채는 interceptor 때문에 무한 루프가 생길 수 있다.</li>
</ul>
<h3 id="기본-axios-인스턴스api는-토큰-관리용이다">기본 axios 인스턴스(api)는 “토큰 관리용”이다</h3>
<p>보통 우리가 쓰는 api 인스턴스에는 이런 역할이 들어가 있음</p>
<ul>
<li>request interceptor<ul>
<li>accessToken 자동 첨부</li>
</ul>
</li>
<li>response interceptor<ul>
<li>401 감지</li>
<li>refresh 시도</li>
<li>실패 요청 재시도</li>
</ul>
</li>
</ul>
<p>즉, api = “인증이 필요한 일반 API 요청 전용 인스턴스”</p>
<h3 id="만약-refresh-api도-api-인스턴스를-쓰면">만약 refresh API도 api 인스턴스를 쓰면?</h3>
<ol>
<li>어떤 API 요청이 401을 맞음</li>
<li>response interceptor 진입</li>
<li>refresh를 위해 /token/access 호출</li>
<li>!!! 그런데 이 요청도 api 인스턴스를 탐</li>
<li>→ request interceptor 실행<ul>
<li>accessToken 붙임 (이미 만료된 토큰)</li>
</ul>
</li>
<li>→ 서버에서 refreshToken도 만료 / 문제 발생</li>
<li>→ /token/access 요청도 401 응답</li>
<li>→ response interceptor 다시 진입</li>
<li>“어? 401이네? refresh 해야지”</li>
<li>다시 /token/access 호출 …</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Axios Interceptor로 Access Token 자동 갱신하기 (Refresh Token + Queue 처리)]]></title>
            <link>https://velog.io/@gayeong__0916/Axios-Interceptor%EB%A1%9C-Access-Token-%EC%9E%90%EB%8F%99-%EA%B0%B1%EC%8B%A0%ED%95%98%EA%B8%B0-Refresh-Token-Queue-%EC%B2%98%EB%A6%AC</link>
            <guid>https://velog.io/@gayeong__0916/Axios-Interceptor%EB%A1%9C-Access-Token-%EC%9E%90%EB%8F%99-%EA%B0%B1%EC%8B%A0%ED%95%98%EA%B8%B0-Refresh-Token-Queue-%EC%B2%98%EB%A6%AC</guid>
            <pubDate>Wed, 28 Jan 2026 00:00:18 GMT</pubDate>
            <description><![CDATA[<h2 id="request-인터셉터">request 인터셉터</h2>
<ol>
<li>토큰을 어디에 넣을지</li>
<li>헤더 형식
Request Interceptor: Access Token 자동 첨부</li>
</ol>
<p>Access Token은 SessionStorage에 저장해두고, 모든 요청에 자동으로 붙인다.</p>
<pre><code>api.interceptors.request.use(
  (config) =&gt; {
    if (typeof window !== &quot;undefined&quot;) {
      const accessToken = SessionStorage.getItem(&quot;accessToken&quot;);

      if (accessToken) {
        config.headers.Authorization = `Bearer ${accessToken}`;
      }
    }

    return config;
  },
  (error) =&gt; Promise.reject(error),
);</code></pre><h2 id="response-인터셉터">response 인터셉터</h2>
<ul>
<li>“언제 재발급할지” 규칙만 맞추면 됨</li>
</ul>
<ol>
<li>어떤 상태 코드에서 refresh 할지</li>
<li>refresh API가 어떤 응답을 주는지</li>
<li>refresh 실패 시 어떻게 처리할지</li>
</ol>
<p>Access Token이 만료되면 보통 401 Unauthorized 가 떨어진다.
이때 refresh API를 호출해서 새 access token을 받아오고, 실패했던 요청을 다시 실행한다.
주의: 동시에 여러 요청이 401로 터질 수 있으므로, refresh 요청을 한 번만 보내고 나머지는 대기시켜야 한다. (큐 처리)
별도 인스턴스 만드는거 권장 -&gt; url이 달라도 같은 axios 인스턴스 탈 수 있기 때문에</p>
<ul>
<li><code>isRefreshing</code> : 현재 refresh 요청이 진행 중인지 여부</li>
<li><code>failedQueue</code> : refresh가 끝날 때까지 대기할 요청들의 Promise resolve/reject 모음<pre><code>type FailedQueueItem = {
resolve: (token: string) =&gt; void;
reject: (err: unknown) =&gt; void;
};
</code></pre></li>
</ul>
<p>let isRefreshing = false;
let failedQueue: FailedQueueItem[] = [];</p>
<p>const processQueue = (error: unknown, token: string | null) =&gt; {
  failedQueue.forEach((p) =&gt; (token ? p.resolve(token) : p.reject(error)));
  failedQueue = [];
};</p>
<p>api.interceptors.response.use(
  (res) =&gt; res,
  async (error) =&gt; {
    const originalRequest = error.config as any;</p>
<pre><code>// 1) 토큰 만료 조건: 보통 401
const isExpired = error.response?.status === 401;

// 이미 retry한 요청이면 무한루프 방지
if (!isExpired || originalRequest._retry) {
  return Promise.reject(error);
}

// 2) 이미 refresh 중이면 큐에 대기
if (isRefreshing) {
  return new Promise((resolve, reject) =&gt; {
    failedQueue.push({
      resolve: (newToken) =&gt; {
        originalRequest.headers.Authorization = `Bearer ${newToken}`;
        resolve(api(originalRequest));
      },
      reject,
    });
  });
}

originalRequest._retry = true;
isRefreshing = true;

try {
  // 3) 여기서 /token/access 호출 = refresh api 호출
  // refresh token은 httpOnly 쿠키로 자동 전송됨
  const res = await refreshApi.post(&quot;/token/access&quot;);

  // 4) 서버 응답 구조에 맞게 accessToken 추출
  // { data: { accessToken: &quot;...&quot; } } 또는 { data: { data: { accessToken } } } 등
  const newAccessToken = res.data?.accessToken;

  if (!newAccessToken)
    throw new Error(&quot;No accessToken in refresh response&quot;);

  // 5) 새 토큰 저장
  sessionStorage.setItem(&quot;accessToken&quot;, newAccessToken);

  // 대기 중 요청들 처리
  processQueue(null, newAccessToken);

  // 6) refresh를 수행한 요청(A) 재시도
  originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
  return api(originalRequest);
} catch (refreshError) {
  // refresh 실패 → 모든 요청 실패 처리 + 로그아웃
  processQueue(refreshError, null);
  sessionStorage.removeItem(&quot;accessToken&quot;);
  window.location.href = &quot;/login&quot;;
  return Promise.reject(refreshError);
} finally {
  isRefreshing = false;
}</code></pre><p>  },
);</p>
<pre><code>### 실행 과정
여러 API 요청(A, B, C)이 동시에 나갔는데
accessToken이 만료된 상태로 가정

1️⃣ A 요청
- A → 401
- isRefreshing = false
- isRefreshing = true로 변경
- /token/access 호출 (refresh 시작)

2️⃣ B 요청
- B → 401
- 이미 isRefreshing === true
- 아래 코드로 진입</code></pre><p>if (isRefreshing) {
  return new Promise((resolve, reject) =&gt; {
    failedQueue.push({
      resolve: (newToken) =&gt; {
        originalRequest.headers.Authorization = <code>Bearer ${newToken}</code>;
        resolve(api(originalRequest)); // B 재요청 예약
      },
      reject,
    });
  });
}</p>
<pre><code>- 새로운 Promise를 생성
- resolve / reject를 큐에 저장
- B는 Promise pending 상태로 대기

3️⃣ refresh 성공 (A 흐름 복귀)</code></pre><p>processQueue(null, newAccessToken);</p>
<p>const processQueue = (error: unknown, token: string | null) =&gt; {
  failedQueue.forEach((p) =&gt; (token ? p.resolve(token) : p.reject(error)));
  failedQueue = [];
};</p>
<pre><code>- 큐에 쌓인 B, C 요청들의 resolve(newToken) 실행
- 내부에서:
    - Authorization 헤더를 새 토큰으로 교체
    - api(originalRequest) 실행 → B 재요청

## 전체 흐름 요약</code></pre><ol>
<li><p>A 요청 → 401</p>
</li>
<li><p>isRefreshing = true</p>
</li>
<li><p>/token/access 호출</p>
<p>(그 사이)</p>
</li>
<li><p>B 요청 → 401</p>
</li>
<li><p>failedQueue에 B의 재요청 함수 push</p>
</li>
<li><p>B는 Promise pending 상태로 대기</p>
<p>(refresh 성공)</p>
</li>
<li><p>새 토큰 저장</p>
</li>
<li><p>processQueue 실행
→ B의 resolve(newToken) 실행
→ B 재요청 시작</p>
</li>
<li><p>A도 자기 자신 재요청</p>
</li>
<li><p>isRefreshing = false
```</p>
</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[CI/CD 개념 정리]]></title>
            <link>https://velog.io/@gayeong__0916/CICD-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@gayeong__0916/CICD-%EA%B0%9C%EB%85%90-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Mon, 12 Jan 2026 01:23:17 GMT</pubDate>
            <description><![CDATA[<h2 id="1-내가-직접-하던-일ci-없을-때">1. 내가 직접 하던 일(CI 없을 때)</h2>
<pre><code>1. 코드 작성
2. git push
3. 로컬에서 npm run lint
4. 로컬에서 npm run build
5. 문제 없다고 판단되면 merge
6. 서버 접속해서 직접 배포</code></pre><h2 id="2-ci를-쓰면-뭐가-달라질까">2. CI를 쓰면 뭐가 달라질까?</h2>
<ul>
<li>CI는 이 코드가 merge 되어도 괜찮은지 자동으로 검사하는 역할</li>
<li>lint / test / build를 자동으로 실행</li>
<li>하나라도 실패하면 merge 불가</li>
<li>사람이 빼먹을 수 없음</li>
<li>merge 가능 여부만!!!! 판단</li>
</ul>
<h2 id="3-push만-했는데-자동으로-실행되는-이유">3. push만 했는데 자동으로 실행되는 이유</h2>
<ul>
<li>.gitlab-ci.yml 파일에 실행 규칙이 정의돼 있기 때문<pre><code>lint:
script:
  - npm run lint
rules:
  - if: $CI_COMMIT_BRANCH</code></pre></li>
<li>코드의 뜻<ul>
<li>브랜치에 push가 발생하면 npm run lint를 자동으로 실행해라<h2 id="cd는">CD는?</h2>
</li>
</ul>
</li>
<li>CI를 통과한 코드를 실제 서버에 배포하는 단계</li>
<li>수동 배포여도 CD임<pre><code>CI: 합쳐도 되는지 검사
CD: 서버에 올려주는 과정</code></pre></li>
<li>중요한 기준<pre><code>배포 명령을 사람이 직접 서버에서 치느냐, CI/CD 파이프라인이 대신 치느냐</code></pre></li>
<li>CI에서 버튼 눌러 배포 -&gt; CD</li>
<li>CI 통과 후 자동 배포 -&gt; CD</li>
<li>사람은 시작만 하고, 실제 배포 작업은 파이프라인이 하면 CD!!!</li>
<li>배포 명령을 CI/CD 파이프라인이 실행하느냐가 중요<h2 id="전체-흐름">전체 흐름</h2>
```</li>
</ul>
<ol>
<li>git push</li>
<li>CI 자동 실행<ul>
<li>lint</li>
<li>test</li>
<li>build</li>
</ul>
</li>
<li>CI 결과로 merge 가능 여부 결정</li>
<li>사람이 merge 버튼 클릭</li>
<li>(선택)<ul>
<li>CD 자동 배포</li>
<li>또는 배포 버튼 클릭 (manual)<pre><code></code></pre></li>
</ul>
</li>
</ol>
<h2 id="gitlab-ciyml-예시">.gitlab-ci.yml 예시</h2>
<pre><code># 1️⃣ 파이프라인 단계 정의 (실행 순서)
stages:
  - lint
  - test
  - build
  - deploy

# 2️⃣ 기본 실행 환경
default:
  image: node:20-alpine
  before_script:
    - node -v
    - npm -v
    - npm ci

# 3️⃣ CI - 코드 검사 (lint)
lint:
  stage: lint
  script:
    - npm run lint
  rules:
    - if: $CI_PIPELINE_SOURCE == &quot;merge_request_event&quot;
    - if: $CI_COMMIT_BRANCH

# 4️⃣ CI - 테스트
test:
  stage: test
  script:
    - npm run test --if-present
  rules:
    - if: $CI_PIPELINE_SOURCE == &quot;merge_request_event&quot;
    - if: $CI_COMMIT_BRANCH

# 5️⃣ CI - 빌드 검증
build:
  stage: build
  script:
    - npm run build
  rules:
    - if: $CI_PIPELINE_SOURCE == &quot;merge_request_event&quot;
    - if: $CI_COMMIT_BRANCH

# 6️⃣ CD - 배포 (main 브랜치만)
deploy:
  stage: deploy
  script:
    - echo &quot;🚀 deploy to server&quot;
    # 예: ssh 서버접속, pm2 reload 등
  rules:
    - if: &#39;$CI_COMMIT_BRANCH == &quot;main&quot;&#39;
      when: manual
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[[회고] 2025년 - 취준생에서 스타트업 직원이 되기까지]]></title>
            <link>https://velog.io/@gayeong__0916/%ED%9A%8C%EA%B3%A0-2025%EB%85%84-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%97%90%EC%84%9C-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%EC%A7%81%EC%9B%90%EC%9D%B4-%EB%90%98%EA%B8%B0%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@gayeong__0916/%ED%9A%8C%EA%B3%A0-2025%EB%85%84-%EC%B7%A8%EC%A4%80%EC%83%9D%EC%97%90%EC%84%9C-%EC%8A%A4%ED%83%80%ED%8A%B8%EC%97%85-%EC%A7%81%EC%9B%90%EC%9D%B4-%EB%90%98%EA%B8%B0%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Wed, 31 Dec 2025 04:05:43 GMT</pubDate>
            <description><![CDATA[<h2 id="고통-끝에-낙이-온다는-말처럼">고통 끝에 낙이 온다는 말처럼</h2>
<p>2025년을 한 줄로 요약하자면,
고통 끝에 낙이 온다는 사자성어가 가장 잘 어울리는 해였다.</p>
<p>취업 준비를 다시 시작해야겠다고 마음먹은 순간부터 불안은 자연스럽게 따라왔다.
다시 취준생이 되었고, 어디로 가게 될지 알 수 없는 상태에서 하루하루를 보냈다.</p>
<h2 id="끝이-보이지-않던-취업-준비">끝이 보이지 않던 취업 준비</h2>
<p>서류는 셀 수 없을 정도로 넣었다.
하지만 그중에서 연락이 온 곳은 몇 곳 되지 않았다.</p>
<p>탈락 메일을 받을 때마다 이유를 알 수 없어 답답했고,
어떻게 하면 더 나은 서류가 될 수 있을지 계속해서 고민했다.
주변에 물어보고, 조언을 듣고, 다시 고치는 일을 반복했다.
그 과정은 생각보다 길고 지치기 쉬웠다.</p>
<h2 id="운처럼-찾아온-기회">운처럼 찾아온 기회</h2>
<p>그렇게 흐르던 시간 속에서 면접을 보게 된 회사는 스타트업이었다.
큰 기대를 하지 않으려 했지만, 마음 한편에는 간절함이 자리 잡고 있었다.</p>
<p>그리고 운 좋게도 면접에 합격했다.
그렇게 나는 다시 ‘취준생’이 아닌 ‘회사원’이 되었다.</p>
<h2 id="첫-한-달-실무는-생각보다-달랐다">첫 한 달, 실무는 생각보다 달랐다</h2>
<p>입사한 지 벌써 한 달이 지났다.
회사를 다니면서 가장 크게 느낀 점은
아직 내가 많이 부족하다는 사실이었다.</p>
<p>개발을 시작하기 전에 고민해야 할 것들이 생각보다 많았고,
실무는 단순히 코드를 잘 작성하는 것과는 전혀 다른 영역이라는 걸 깨닫게 되었다.
왜 이 기능이 필요한지, 어떤 흐름에서 사용되는지,
그리고 다른 사람들과 어떻게 약속되어 있는지가 훨씬 중요했다.</p>
<h2 id="부족함을-마주하면서도-얻은-확신">부족함을 마주하면서도 얻은 확신</h2>
<p>회사에 다니며 부족함을 느끼는 순간이 많았지만,
그만큼 이전의 나와는 다른 시선으로 개발을 바라보게 되었다.</p>
<p>예전보다 더 많이 고민하고,
더 깊이 생각하고,
그 이유를 설명하려고 노력하고 있다는 점은 분명했다.</p>
<h2 id="기록으로-남기려는-이유">기록으로 남기려는 이유</h2>
<p>회사에서 알게 된 것들,
실무를 통해 깨달은 점들,
그리고 다시 공부하게 된 내용들을
이 공간에 하나씩 기록해보려고 한다.</p>
<p>2025년이 방향을 찾기 시작한 해였다면,
2026년은 그 방향으로 꾸준히 걸어가며
개발자로서 한 단계 더 성장한 해가 되었으면 한다.</p>
<p>아직 완벽하지는 않지만,
어디로 가고 있는지는 알고 있다는 점에서
이 한 해를 의미 있게 마무리하고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[GitLab CI/CD]]></title>
            <link>https://velog.io/@gayeong__0916/GitLab-CICD</link>
            <guid>https://velog.io/@gayeong__0916/GitLab-CICD</guid>
            <pubDate>Wed, 31 Dec 2025 03:52:17 GMT</pubDate>
            <description><![CDATA[<p>GitLab CI/CD는 GitLab에 기본으로 내장된 CI/CD 시스템이다.
별도의 CI 툴(Jenkins, CircleCI 등)을 붙이지 않아도,
저장소 하나만 있으면 바로 파이프라인을 구성할 수 있다.
별도 툴 설치 ❌
GitLab 저장소에 바로 연동 ⭕</p>
<blockquote>
<p>GitLab CI/CD를 배우는 게 아니라, CI/CD 파이프라인을 배우는 거고 GitLab은 그걸 구현하는 수단 중 하나다.</p>
</blockquote>
<h2 id="gitlab-cicd의-핵심-구성-요소">GitLab CI/CD의 핵심 구성 요소</h2>
<h3 id="gitlab-ciyml">.gitlab-ci.yml</h3>
<ul>
<li>GitLab CI/CD 파이프라인의 설계도 역할을 한다.<pre><code>stages:
- build
- test
- deploy</code></pre></li>
<li>어떤 작업을</li>
<li>어떤 순서로</li>
<li>언제 실행할지 정의</li>
</ul>
<ul>
<li>이 파일 하나로 CI/CD 동작 전체가 결정된다.<h3 id="job">Job</h3>
</li>
<li>Job은 파이프라인에서 실행되는 가장 작은 작업 단위다.</li>
<li>각 단계에서 실행되는 작업 단위</li>
<li>수행할 작업을 정의<pre><code>test:
stage: test
script:
  - npm install
  - npm test</code></pre></li>
<li>하나의 Job = 하나의 자동화 작업<h3 id="stage">Stage</h3>
</li>
<li>여러 Job들을 묶는 단계</li>
<li>Job들의 묶음</li>
<li>작업을 실행할 시기를 정의<pre><code>build → test → deploy</code></pre></li>
<li>앞 stage 실패 시 다음 stage는 실행 ❌<h3 id="runner">Runner</h3>
</li>
<li>Job을 실제로 실행하는 머신<pre><code>Docker
VM
Shell
Kubernetes 등</code></pre></li>
<li>일 시키는 사람 = GitLab</li>
<li>일 하는 사람 = Runner</li>
<li>GitLab은 “무엇을 할지”만 결정하고, Runner가 실제 명령어(npm run build 등)를 실행한다.<h2 id="gitlab-cicd-동작-흐름">GitLab CI/CD 동작 흐름</h2>
</li>
</ul>
<ol>
<li>개발자가 코드 push</li>
<li>GitLab이 .gitlab-ci.yml 확인</li>
<li>GitLab이 Runner에게 작업 지시</li>
<li>Runner가 실제 작업 실행<ul>
<li>빌드</li>
<li>테스트</li>
<li>배포</li>
</ul>
</li>
<li>결과를 GitLab UI에 표시<h2 id="gitlab-cicd를-쓰는-이유">GitLab CI/CD를 쓰는 이유</h2>
<h3 id="장점">장점</h3>
</li>
</ol>
<ul>
<li>GitLab과 완전 통합</li>
<li>설정 파일 하나로 관리</li>
<li>UI에서 파이프라인 시각화</li>
<li>브랜치 / MR 조건별 실행 가능<h3 id="단점">단점</h3>
</li>
<li>Runner 개념이 처음엔 어려움</li>
<li>YAML 문법 실수 시 디버깅 빡셈</li>
</ul>
<ul>
<li>파이프라인 예시<pre><code>stages:
- install
- build
- deploy
</code></pre></li>
</ul>
<p>// 의존성을 항상 동일한 상태로 설치
install:
  stage: install
  script:
    - npm ci</p>
<p>// 프로젝트가 빌드 가능한 상태인지 검증
// 실제 배포 전에 가장 중요한 단계
build:
  stage: build
  script:
    - npm run build</p>
<p>// main 브랜치에 머지될 때만 실행
// 현재는 배포 동작을 흉내만 내는 상태
// 실제 배포를 하려면 이 부분에 서버 업로드 / 클라우드 배포 명령이 추가되어야 한다.
deploy:
  stage: deploy
  script:
    - echo &quot;Deploying...&quot;
  only:
    - main
```</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CI/CD란?]]></title>
            <link>https://velog.io/@gayeong__0916/CICD%EB%9E%80</link>
            <guid>https://velog.io/@gayeong__0916/CICD%EB%9E%80</guid>
            <pubDate>Wed, 31 Dec 2025 00:28:10 GMT</pubDate>
            <description><![CDATA[<ul>
<li>CI/CD는 코드를 더 자주, 더 안전하게, 더 자동으로 배포하기 위한 개발 문화이자 자동화 파이프라인이다.<h2 id="ci란">CI란?</h2>
</li>
<li>Continuous Integration, 지속적 통합</li>
<li>feature 브랜치에서 작업한 코드를 main에 병합하기 전/후에 자동으로 빌드·테스트해서 병합해도 안전한지 검증하는 과정<h3 id="ci가-없는-경우">CI가 없는 경우</h3>
</li>
<li>각자 로컬에서만 개발</li>
<li>나중에 한 번에 머지</li>
<li>충돌 폭발, 테스트 지옥<h3 id="ci가-있는-경우">CI가 있는 경우</h3>
</li>
<li>코드 push</li>
<li>자동으로 빌드 / 테스트 / 린트 검사</li>
<li>문제 있으면 즉시 실패 알림</li>
<li>머지 시점에 문제가 드러나는 구조<h3 id="ci의-핵심-목적">CI의 핵심 목적</h3>
</li>
<li>버그를 초기에 발견</li>
<li>코드 품질 자동 검증</li>
<li>팀원 간 코드 충돌 최소화<h2 id="cd란">CD란?</h2>
</li>
<li>Continuous Delivery/Deployment, 지속적 제공/배포</li>
<li>CI 이후 단계</li>
<li>배포와 관련된 자동화</li>
</ul>
<h3 id="continuous-delivery">Continuous Delivery</h3>
<ul>
<li>배포 직전까지 자동화</li>
<li>실제 배포는 사람이 버튼 클릭<pre><code>CI 통과 → 배포 준비 완료 → (사람이 승인) → 배포</code></pre></li>
<li>예시<pre><code>deploy:
stage: deploy
script:
  - echo &quot;Ready to deploy&quot;
when: manual
only:
  - main</code></pre></li>
<li>CI는 자동</li>
<li>배포는 버튼 클릭<h3 id="continous-deployment">Continous Deployment</h3>
</li>
<li>CI 통과하면 자동으로 배포까지<pre><code>CI → 테스트 통과 → (자동) 서버 배포</code></pre></li>
<li>예시<pre><code>deploy:
stage: deploy
script:
  - aws s3 sync build/ s3://my-bucket
only:
  - main</code></pre></li>
<li>main 브랜치 머지</li>
<li>CI 통과</li>
<li>즉시 서비스 반영<h3 id="cd의-핵심-목적">CD의 핵심 목적</h3>
</li>
<li>배포 과정의 반복 작업 제거</li>
<li>배포 실수 감소</li>
<li>빠른 피드백 &amp; 빠른 릴리즈</li>
</ul>
<h2 id="cicd-전체-흐름-한줄-요약">CI/CD 전체 흐름 한줄 요약</h2>
<pre><code>코드 Push → 자동 빌드 → 자동 테스트 → 자동 배포</code></pre><p>사람이 하는 일은 : 코드 작성 + 결과 확인 뿐</p>
<p>CI/CD는 코드를 더 자주, 더 안전하게, 더 자동으로 배포하기 위한
개발 문화이자 이를 실현하기 위한 자동화 파이프라인이다.</p>
<p>CI는 코드 변경이 발생할 때마다
자동으로 빌드·테스트를 수행해 품질을 검증하는 단계이고,</p>
<p>CD는 CI를 통과한 코드를
사람의 개입을 최소화해 배포까지 연결하는 단계다.</p>
<p>CI</p>
<ul>
<li>병합해도 괜찮은지?</li>
<li>코드 품질 괜찮은지?</li>
<li>빌드 깨지지 않는지?</li>
</ul>
<p>CD</p>
<ul>
<li>언제 배포할지?</li>
<li>자동 vs 수동?</li>
<li>어디에 배포할지?</li>
</ul>
<h2 id="파이프라인">파이프라인</h2>
<ul>
<li>코드가 배포되기까지 거치는 모든 자동화 단계를 한 줄로 연결한 흐름</li>
<li>자동으로 실행되는 작업들의 흐름</li>
<li>Stage들의 묶음</li>
<li>코드 push 한 번 -&gt; 파이프라인 1개 생성</li>
<li>그 안에 stage들이 순서대로 실행됨<blockquote>
<p>CI/CD는 결국 파이프라인을 설계하고 자동으로 실행하는 시스템이다.</p>
</blockquote>
</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[개발을 시작하기 전, 내가 먼저 작성했던 4가지 문서]]></title>
            <link>https://velog.io/@gayeong__0916/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8</link>
            <guid>https://velog.io/@gayeong__0916/%EB%8B%A8%EC%9C%84%ED%85%8C%EC%8A%A4%ED%8A%B8</guid>
            <pubDate>Tue, 30 Dec 2025 04:09:19 GMT</pubDate>
            <description><![CDATA[<p>회사를 다니기 전에는 개발이라고 하면 자연스럽게 코드를 먼저 떠올렸다.
하지만 실무에 들어와 보니, 실제 개발은 코드를 작성하기 전 단계에서 이미 절반 이상 결정되고 있었다.</p>
<p>입사 후 한 달 동안 나는 실제 구현에 앞서 다음 4가지 문서를 작성해보았다.</p>
<ul>
<li>유스케이스</li>
<li>플로우 차트</li>
<li>시퀀스 다이어그램</li>
<li>단위 테스트</li>
</ul>
<p>이 문서들을 작성하면서 “왜 개발 전에 정리가 필요한지”를 체감할 수 있었다.</p>
<h2 id="유스케이스-use-case">유스케이스 (Use Case)</h2>
<ul>
<li>사용자의 행동을 기준으로 시스템이 어떻게 반응해야 하는지 정리하는 문서<blockquote>
<p>누가, 어떤 상황에서, 무엇을 하며, 그 결과는 무엇인가?</p>
</blockquote>
</li>
</ul>
<p>라는 질문에 답하는 과정이다.
예를 들어 ‘로그인’이라는 기능 하나만 보더라도</p>
<ul>
<li>로그인 성공</li>
<li>로그인 실패</li>
<li>최초 로그인</li>
<li>조건에 따른 분기</li>
</ul>
<p>처럼 생각보다 많은 경우의 수가 존재한다.</p>
<p>유스케이스를 먼저 작성하면서
기능을 단순히 ‘있다 / 없다’가 아니라, 흐름과 조건으로 바라보게 되었다.</p>
<h2 id="플로우-차트-flow-chart">플로우 차트 (Flow Chart)</h2>
<ul>
<li>유스케이스를 시각적으로 풀어낸 문서</li>
</ul>
<p>텍스트로만 정리했을 때 놓치기 쉬운:</p>
<ul>
<li>조건 분기</li>
<li>반복되는 흐름</li>
<li>예외 케이스</li>
</ul>
<p>를 한눈에 확인할 수 있었다.</p>
<blockquote>
<p>특히, “이 단계에서 실패하면 어디로 돌아가야 하지?” </p>
</blockquote>
<p>같은 질문이 자연스럽게 생겼고, 불필요한 분기나 복잡한 흐름을 미리 정리할 수 있었다.</p>
<h2 id="시퀀스-다이어그램-sequence-diagram">시퀀스 다이어그램 (Sequence Diagram)</h2>
<ul>
<li>시간의 흐름에 따라 시스템 간의 상호작용을 정리하는 문서</li>
</ul>
<ul>
<li>사용자</li>
<li>프론트엔드</li>
<li>서버</li>
<li>외부 시스템</li>
</ul>
<p>이 각각이 어떤 순서로 요청하고 응답하는지를 명확히 표현할 수 있다.</p>
<p>이 문서를 작성하면서</p>
<ul>
<li>이 로직은 프론트 책임인지</li>
<li>서버에서 처리해야 하는지</li>
<li>언제 API가 호출되는지</li>
</ul>
<p>같은 경계가 훨씬 또렷해졌다.</p>
<h2 id="단위-테스트-unit-test">단위 테스트 (Unit Test)</h2>
<ul>
<li>말  그대로 코드의 가장 작은 단위(함수/모듈/컴포넌트)가 의도대로 동작하는지 검증하는 테스트</li>
</ul>
<p>단위 테스트 관점에서 기능을 정리하다 보니</p>
<ul>
<li>정상 케이스</li>
<li>실패 케이스</li>
<li>경계 조건</li>
</ul>
<p>을 자연스럽게 생각하게 되었고,
그 과정에서 </p>
<blockquote>
<p>“이 기능은 이렇게 구현하면 안 되겠구나” </p>
</blockquote>
<p>라는 생각이 구현 전에 먼저 들었다.</p>
<p>단위 테스트가 중요한 이유는 다음과 같다.</p>
<ul>
<li>구현 전에 요구사항의 빈틈이 보인다</li>
<li>코드 변경과 리팩토링이 쉬워진다</li>
<li>버그를 더 빠른 단계에서 발견할 수 있다</li>
</ul>
<p>테스트는 단순한 검증이 아니라, 기능을 정확하게 정의하는 도구라는 느낌을 받았다.</p>
<h2 id="문서를-먼저-쓰며-느낀-점">문서를 먼저 쓰며 느낀 점</h2>
<p>이 네 가지 문서를 작성하며 공통적으로 느낀 점은 다음과 같다.</p>
<ul>
<li>구현 전에 고민할수록</li>
<li>코드 작성 속도는 오히려 빨라진다</li>
<li>질문의 질이 달라진다</li>
<li>“왜 이렇게 구현했는지” 설명할 수 있게 된다</li>
</ul>
<p>문서 작성은 개발의 부수적인 작업이 아니라 개발 사고를 정리하는 핵심 과정이라는 걸 실감했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Tailwind v4 vs 이전 버전: 코드 짜며 느낀 변경점만 딱 정리]]></title>
            <link>https://velog.io/@gayeong__0916/Tailwind-v4-vs-%EC%9D%B4%EC%A0%84-%EB%B2%84%EC%A0%84-%EC%BD%94%EB%93%9C-%EC%A7%9C%EB%A9%B0-%EB%8A%90%EB%82%80-%EB%B3%80%EA%B2%BD%EC%A0%90%EB%A7%8C-%EB%94%B1-%EC%A0%95%EB%A6%AC</link>
            <guid>https://velog.io/@gayeong__0916/Tailwind-v4-vs-%EC%9D%B4%EC%A0%84-%EB%B2%84%EC%A0%84-%EC%BD%94%EB%93%9C-%EC%A7%9C%EB%A9%B0-%EB%8A%90%EB%82%80-%EB%B3%80%EA%B2%BD%EC%A0%90%EB%A7%8C-%EB%94%B1-%EC%A0%95%EB%A6%AC</guid>
            <pubDate>Fri, 26 Dec 2025 04:41:34 GMT</pubDate>
            <description><![CDATA[<p>Tailwind 4.0으로 넘어오면서 “공식 문서에 있는 차이점”보다, 실제로 코드를 치면서 바로 체감되는 변화가 있었다.
내 경우엔 크게 세 가지였다.</p>
<ol>
<li>파일 삽입 방식이 바뀌었다</li>
<li>커스텀 유틸리티를 @utility로 정의하는 흐름이 생겼다</li>
<li>폰트 커스텀 설정 방식이 바뀌었다.</li>
</ol>
<p><strong>이 글은 내가 프로젝트에서 실제로 적용하면서 느낀 포인트만 기록한다.</strong></p>
<h2 id="파일-삽입-방식">파일 삽입 방식</h2>
<h3 id="v3-기준">v3 기준</h3>
<ol>
<li>tailwind.config.ts를 먼저 만들고<pre><code>npx tailwindcss init</code></pre></li>
<li>config에서 content 경로 설정<pre><code>export default {
content: [
 &quot;./src/**/*.{js,ts,jsx,tsx}&quot;,
],
};</code></pre></li>
<li>CSS 파일에 Tailwind 레이어 선언<pre><code>@tailwind base;
@tailwind components;
@tailwind utilities;</code></pre></li>
</ol>
<h3 id="v4-기준">v4 기준</h3>
<ol>
<li>globals.css에서 바로 Tailwind 불러오기<pre><code>@import &quot;tailwindcss&quot;;</code></pre></li>
<li>config 파일 없이도 시작 가능</li>
</ol>
<ul>
<li>처음엔 tailwind.config.ts 안 만들고 진행</li>
<li>필요한 순간에만 생성</li>
</ul>
<ol start="3">
<li>커스텀 토큰도 CSS에서 관리<pre><code>@theme {
--color-primary-normal: #3acdb9;
--color-text-01: #212121;
}</code></pre><blockquote>
<p>이전엔 Tailwind를 config에서 설정했다면, v4에선 CSS에서 바로 시작했다.</p>
</blockquote>
</li>
</ol>
<h2 id="커스텀-유틸리티-작성-방식">커스텀 유틸리티 작성 방식</h2>
<h3 id="v3-기준-1">v3 기준</h3>
<pre><code>@layer utilities {
  .text-body {
    font-size: 14px;
    line-height: 1.5;
  }
}</code></pre><p>또는</p>
<ul>
<li>plugin 작성</li>
<li>config extend 사용</li>
</ul>
<h4 id="문제점">문제점</h4>
<ul>
<li>이게 유틸인가? 컴포넌트인가? 애매함</li>
<li>점점 CSS가 비대해짐</li>
</ul>
<h3 id="v4-기준-1">v4 기준</h3>
<pre><code>@utility text-body {
  font-size: 14px;
  line-height: 1.5;
}</code></pre><p>사용할 때</p>
<pre><code>&lt;p className=&quot;text-body text-text-02&quot;&gt;
  내용
&lt;/p&gt;</code></pre><ul>
<li>의도가 명확함 -&gt; 이건 유틸이다</li>
<li>Tailwind 클래스랑 같은 레벨로 사용 가능</li>
<li>디자인 토큰 + 유틸 분리하기 쉬움</li>
</ul>
<blockquote>
<p>v4의 @utility는 커스텀 스타일을 Tailwind 유틸로 공식 편입시키는 느낌이었다.</p>
</blockquote>
<h2 id="폰트-삽입-방식">폰트 삽입 방식</h2>
<h3 id="v3-과-v4-차이">v3 과 v4 차이</h3>
<p><a href="https://velog.io/@gayeong__0916/Next.js-App-Router-Tailwind-v4%EC%97%90%EC%84%9C-Pretendard-%EA%B8%B0%EB%B3%B8-%ED%8F%B0%ED%8A%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0">https://velog.io/@gayeong__0916/Next.js-App-Router-Tailwind-v4%EC%97%90%EC%84%9C-Pretendard-%EA%B8%B0%EB%B3%B8-%ED%8F%B0%ED%8A%B8-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[취업]]></title>
            <link>https://velog.io/@gayeong__0916/%EC%B7%A8%EC%97%85</link>
            <guid>https://velog.io/@gayeong__0916/%EC%B7%A8%EC%97%85</guid>
            <pubDate>Fri, 26 Dec 2025 04:21:25 GMT</pubDate>
            <description><![CDATA[<p>거의 1년만의 취준 끝에 스타트업에 입사를 하게되었다.
다닌지는 벌써 한 달이 다 되어가는데 사람들도 좋고 일도 할만하다</p>
<p>내가 계속 잘 할 수 있을까?
꾸준히 공부를 할 예정이다.
파이팅!!!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[해결] SVG import 시 ‘모듈을 찾을 수 없습니다’ 오류 해결하기]]></title>
            <link>https://velog.io/@gayeong__0916/%ED%95%B4%EA%B2%B0-SVG-import-%EC%8B%9C-%EB%AA%A8%EB%93%88%EC%9D%84-%EC%B0%BE%EC%9D%84-%EC%88%98-%EC%97%86%EC%8A%B5%EB%8B%88%EB%8B%A4-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@gayeong__0916/%ED%95%B4%EA%B2%B0-SVG-import-%EC%8B%9C-%EB%AA%A8%EB%93%88%EC%9D%84-%EC%B0%BE%EC%9D%84-%EC%88%98-%EC%97%86%EC%8A%B5%EB%8B%88%EB%8B%A4-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 09 Nov 2025 03:30:48 GMT</pubDate>
            <description><![CDATA[<h2 id="문제">문제</h2>
<ul>
<li>프로젝트에서 SVG 파일을 import할 때 갑자기 발생하는 에러<pre><code>import logo from &quot;@/assets/logo.svg&quot;; 
// ❌ 오류: 모듈 &#39;@/assets/logo.svg&#39; 또는 해당 형식 선언을 찾을 수 없습니다. ts(2307)</code></pre><h2 id="원인">원인</h2>
</li>
<li>분명 잘 되던 코드인데, 어느 날 갑자기????</li>
<li>원인은 : TypeScript가 <code>.svg</code> 파일의 타입을 모르기 때문에</li>
<li>TypeScript는 JS 자산(.js, .ts) 만 타입 정보를 알고 있음</li>
<li>.svg, .png, .jpg, .css 같은 파일은 모듈 선언이 없으면 “이게 뭔 타입인지 몰라서” 컴파일 에러 발생</li>
<li>예전엔 느슨했지만, moduleResolution: &quot;bundler&quot;로 바뀐 뒤 엄격해짐<pre><code>// tsconfig.json (Next.js 14 이상 기본 설정)
&quot;moduleResolution&quot;: &quot;bundler&quot;</code></pre><h2 id="해결-방법">해결 방법</h2>
</li>
<li>declare module &quot;*.svg&quot; 추가<pre><code>// src/types/global.d.ts
declare module &#39;*.svg&#39; {
const content: string;
export default content;
}</code></pre></li>
<li>그리고 tsconfig.json에 타입 파일 포함<pre><code>&quot;include&quot;: [
&quot;next-env.d.ts&quot;,
&quot;src/types/**/*.d.ts&quot;,
&quot;**/*.ts&quot;,
&quot;**/*.tsx&quot;,
&quot;.next/types/**/*.ts&quot;
]</code></pre>이제 TypeScript는 “.svg 파일은 string으로 취급하자”고 이해함</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 면접 질문 모음 (9)]]></title>
            <link>https://velog.io/@gayeong__0916/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8-%EB%AA%A8%EC%9D%8C-9</link>
            <guid>https://velog.io/@gayeong__0916/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EB%A9%B4%EC%A0%91-%EC%A7%88%EB%AC%B8-%EB%AA%A8%EC%9D%8C-9</guid>
            <pubDate>Sat, 08 Nov 2025 03:13:50 GMT</pubDate>
            <description><![CDATA[<h1 id="cs분야">CS분야</h1>
<h3 id="11-jwtjson-web-token는-어떻게-동작하나요">11. JWT(Json Web Token)는 어떻게 동작하나요?</h3>
<ul>
<li>JWT는 사용자 인증 정보를 JSON형태로 인코딩한 토큰이며, 서버가 서명을 추가해 위변조를 방지합니다. 로그인 시 서버가 사용자 정보를 바탕으로 JWT를 발급하고, 클라이언트는 이 토큰을 로컬 스토리지나 쿠키에 저장합니다. 이후 요청마다 JWT를 함께 보내면 서버는 서명을 검증해 사용자 신원을 확인합니다. 특징은 세션처럼 서버에 사용자 정보를 저장하지 않고, 토큰 자체에 인증 정보가 담겨있는 무상태 방식이라는 점입니다.<h3 id="12-oauth-인증의-흐름은-어떻게-되나요">12. OAuth 인증의 흐름은 어떻게 되나요?</h3>
</li>
<li>사용자가 소셜 로그인을 요청하면, 클라이언트는 사용자의 브라우저를 인증 서버로 리다이렉트를 합니다. 이후 사용자는 Google이나 Kakao 같은 OAuth 제공자에 로그인하고, 애플리케이션 접근 권한을 허용합니다. 그러면 인증 서버는 클라이언트에게 Authorization Code를 전달합니다. 클라이언트는 이 코드를 백엔드 서버로 전달하고, 백엔드 서버는 인증 서버에 해당 코드를 제출해 Access Token(필요 시 Refresh Token도 함께)을 발급받습니다. 이후 클라이언트는 이 Access Token을 이용해 사용자 정보 API를 호출하여 사용자 데이터를 가져올 수 있습니다.
<img src="https://velog.velcdn.com/images/gayeong__0916/post/2d8e3767-ea58-4e34-907c-5022d9e8b928/image.png" alt=""></li>
</ul>
<h3 id="13-배열과-객체의-차이">13. 배열과 객체의 차이</h3>
<ul>
<li>배열과 객체는 모두 JavaScript의 자료 구조지만 데이터 저장 방식과 사용 목적이 다릅니다. 객체는 키-값 형태로 데이터를 저장하며, 특정 속성에 이름을 붙여 관리할 때 사용합니다. 배열은 순서가 있는 데이터 집합으로, 순서가 있는 리스트 형태의 데이터를 저장할 때 사용합니다. 배열은 반복 처리(map, filter 등)나 목록 데이터를 다룰 때 적합하고, 객체는 개별 속성을 가진 엔티티(유저 정보 등)을 표현할 때 사용합니다.<h3 id="14-stack과-queue의-차이">14. Stack과 Queue의 차이</h3>
</li>
<li>Stack과 Queue는 데이터 저장 및 꺼내는 순서가 다른 자료 구조입니다. Stack은 LIFO(Last In, First Out)구조로 나중에 들어온 데이터가 먼저 나갑니다. Queue는 FIFO(First In, First Out) 구조로 먼저 들어온 데이터가 먼저 나갑니다.<h3 id="15-성능-최적화를-위해-어떤-개선을-해본-경험이-있나요">15. 성능 최적화를 위해 어떤 개선을 해본 경험이 있나요?</h3>
</li>
<li>(OpenMind)성능 최적화를 위해 리렌더링 최소화와 초기 로딩 속도 개선에 중점을 두었습니다. 먼저 useMemo, useCallback, React.memo를 활용해 불필요한 리렌더링을 최소화하고, 컴포넌트가 실제로 변경된 부분만 효율적으로 업데이트되도록 했습니다. 또한 스켈레톤 UI를 적용해 데이터 로딩 중에도 사용자에게 시각적 피드백을 제공하여 체감 성능을 높였습니다. 렌더링 구조 측면에서는 SSR과 CSR을 혼합해, 초기 화면은 서버에서 빠르게 렌더링하고 이후 상호작용은 클라이언트에서 처리하도록 구성했습니다. 즉, 렌더링 최소화 + 사용자 체감 속도 개선 + 서버/클라이언트 병행 최적화를 통해 전반적인 성능을 향상시켰습니다.<h3 id="16-csr만-사용하는-spa의-단점은">16. CSR만 사용하는 SPA의 단점은?</h3>
</li>
<li>CSR만 사용하는 SPA는 사용자가 처음 접속할 때 빈 HTML과 큰 JS 번들을 받아야 해서 초기 로딩 속도가 느릴 수 있고, JavaScript가 실행되기 전까지는 화면이 보이지 않아 초기 체감 UX가 떨어질 수 있습니다. 또한 렌더링이 브라우저에서만 이루어지기 때문에 검색 엔진 최적화(SEO)에 불리하고, JS 에러나 네트워크 환경 문제로 스크립트가 로드되지 않으면 페이지가 제대로 표시되지 않는 위험이 있습니다.<h3 id="17-seo가-무엇인지-설명하고-개선을-위해-어떤-작업을-할-수-있을지-설명해-주세요">17. SEO가 무엇인지 설명하고, 개선을 위해 어떤 작업을 할 수 있을지 설명해 주세요.</h3>
</li>
<li>SEO는 웹사이트가 검색 엔진에 잘 노출되도록 최적화하는 작업을 말합니다. 이를 위해 시맨틱 태그와 구조화된 HTML을 사용해 페이지 의미를 명확하게 표현하고, title, description 같은 메타 태그를 최적화합니다. 또한 SSR/SSG를 적용해 검색 크롤러가 실제 콘텐츠를 바로 읽을 수 있게 하고, 이미지에 alt 속성을 추가해 접근성과 검색 인덱싱 효율을 높입니다.<h3 id="18-세션-기반-인증과-토큰-기반-인증을-비교해서-설명해-주세요">18. 세션 기반 인증과 토큰 기반 인증을 비교해서 설명해 주세요.</h3>
</li>
<li>세션 기반 인증과 토큰 기반 인증은 사용자의 로그인 상태를 유지하는 방식이 다릅니다. 세션 기반 인증은 사용자가 로그인하면 서버가 세션을 생성하고, 세션 ID를 쿠키로 클라이언트에 전달합니다. 이후 요청마다 쿠키를 전송하면 서버가 세션 저장소에서 상태를 조회해 인증을 처리합니다. 구현이 단순하고 보안적으로 안정적이지만, 세션 정보를 서버가 관리하기 때문에 서버 부하가 커지고 수평 확장에 불리한 단점이 있습니다. 반면 토큰 기반 인증은 로그인시 서버가 사용자 정보를 담은 토큰을 발급하고, 클라이언트는 이 토큰을 로컬스토리지나 쿠키에 저장합니다. 이후 요청마다 헤더에 토큰을 담아 보내면 서버는 별도의 세션 저장소 없이 토큰의 유효성만 검증합니다. 서버는 별도 상태 저장 없이 토큰의 유효성만 검증하므로 무상태 방식이라 확장성과 모바일/다중 서비스 환경에 유리합니다.</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>