<?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>Fri, 10 Jul 2026 05:39:03 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%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>
        <item>
            <title><![CDATA[프론트엔드 면접 질문 모음 (8)]]></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-8</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-8</guid>
            <pubDate>Fri, 07 Nov 2025 02:57:38 GMT</pubDate>
            <description><![CDATA[<h1 id="cs분야">CS분야</h1>
<h3 id="1-브라우저의-렌더링-과정은-어떻게-되나요">1. 브라우저의 렌더링 과정은 어떻게 되나요?</h3>
<ul>
<li>순서를 간단히 말하면 HTML/CSS파싱 -&gt; 렌더 트리 생성 -&gt; 레이아웃 -&gt; 페인팅 -&gt; 합성 순서로 진해됩니다. 먼저, 브라우저는 서버에서 받은 HTML을 파싱해서 DOM 트리를 만들고, CSS를 파싱해 CSSOM트리를 생성합니다. 이를 결합해 화면에 표시될 요소만 포함한 렌더 트리를 구성하비다. 이후 레이아웃 단계에서 각 요소의 크기와 위치를 계산하고, 페인팅 단계에서 계산된 정보를 기반으로 색상, 그림자, 이미지 같은 시각적 스타일을 픽셀 단위로 그립니다. 마지막으로 합성 단계에서 여러 레이어를 GPU를 이용해 하나의 화면으로 조합합니다. 참고로, JavaScript가 DOM이나 CSSOM을 변경하면, 브라우저는 필요에 따라 다시 계산하는 리플로우 또는 리페인트를 수행합니다. <h3 id="2-http와-https의-차이는-무엇인가요">2. HTTP와 HTTPS의 차이는 무엇인가요?</h3>
</li>
<li>HTTP와 HTTPS는 모두 웹에서 데이터를 주고받는 프로토콜이지만, 보안 측면에서 큰 차이가 있습니다. HTTP는 데이터를 암호화하지 않고 전송하기 때문에 중간에서 데이터를 가로채거나 변조할 위험이 있습니다. 반면 HTTPS는 SSL/TLS 암호화 방식을 사용해 데이터를 암호화하고 서버의 신원을 인증하며 데이터가 중간에 변경되지 않았음을 보장하는 무결성을 제공합니다. 즉, HTTPS는 HTTP에 보안 계층이 추가된 방식으로 로그인 정보, 결제 정보처럼 민감한 데이터를 안전하게 전송할 수 있습니다. <h3 id="3-http-메서드get-post-put-patch-delete의-차이점은">3. HTTP 메서드(GET, POST, PUT, PATCH, DELETE)의 차이점은?</h3>
</li>
<li>GET은 서버에서 데이터를 조회하는데 사용되며, 요청 데이터는 URL에 포함됩니다. 서버의 상태를 변경하지 않는 안전한 메서드입니다. POST는 서버에 새로운 데이터를 생성하거나 처리 요청을 보낼 때 사용됩니다. 요청 본문에 데이터를 담고, 서버 상태가 변경됩니다. PUT은 리소스를 전체를 덮어쓰기 방식으로 업데이트합니다. 대상 리소스가 없으면 생성하기도 합니다. PATCH는 리소스의 일부만 수정할 때 사용합니다. PUT과 달리 변경할 속성만 전송합니다. DELETE는 리소스를 삭제할 때 사용합니다.<h3 id="4-쿠키cookie-세션session-로컬스토리지localstorage-세션스토리지session-storage의-차이점은">4. 쿠키(Cookie), 세션(Session), 로컬스토리지(LocalStorage), 세션스토리지(Session Storage)의 차이점은?</h3>
</li>
<li>쿠키, 세션, 로컬스토리지는 모두 브라우저에 데이터를 저장하는 방법이지만 저장 위치, 전송 방식, 유지 기간, 보안 목적이 다릅니다. 먼저, 쿠키는 브라우저에 저장되며, HTTP 요청마다 자동으로 서버에 전송되는 작은 데이터입니다. 주로 세션 ID나 인증 정보 저장에 사용되며, 용량이 작고 보안 설정(HttpOnly, Secure, SameSite)이 중요합니다. 세션은 데이터가 서버에 저장되고, 브라우저에는 세션 ID만 쿠키로 보관합니다. 서버에서 관리하기 때문에 쿠키보다 보안성이 높지만,서버 메모리/스토리지 사용량이 증가합니다. 로컬 스토리지는 브라우저에 영구 저장되며 탭을 닫아도 유지됩니다. 서버로 자동 전송되지 않기 때문에 사용자 환경 정보, UI 상태, 캐시 데이터 저장에 적합합니다. 반면 보안 목적의 토큰 저장은 권장되지 않습니다. 세션스토리지는 같은 브라우저 탭에서만 유지되며, 탭을 닫으면 제거됩니다. 페이지 이동 중 필요한 임시 데이터에 사용됩니다.<h3 id="5-cors는-무엇이고-왜-발생하나요">5. CORS는 무엇이고, 왜 발생하나요?</h3>
</li>
<li>CORS는 다른 출처의 리소스 요청을 제어하기 위한 브라우저 보안 정책입니다. 브라우저는 보안을 위해 기본적으로 같은 출처에서만 요청을 허용하는 동일 출처 정책을 가지고 있습니다. 그래서 프론트엔드와 API 서버가 도메인, 포트, 또는 프로토콜이 다르면 브라우저가 보안상 요청을 차단하게 되는데 이러한 상황을 CORS 에러라고 부릅니다. CORS 문제를 해결하려면 서버에서 Access-Control-Aloow-Origin 헤더 등을 통해 허용할 출처를 명시하여 브라우저가 요청을 허용하도록 해야 합니다.<h3 id="6-rest-api란-무엇인가요">6. REST API란 무엇인가요?</h3>
</li>
<li>REST 아키텍처 스타일을 기반으로 리소스를 URL로 표현하고, HTTP 메서드를 통해 해당 리소스에 대한 CRUD 동작을 정의하는 API 설계 방식입니다. REST의 핵심은 리소스 중심이며 각 자원은 고유한 URL로 식별되고 GET, POST, PUT, PATCH, DELETE 같은 표준 HTTP 메서드를 사용해 각각 조회, 생성, 수정, 삭제를 수행합니다. REST API는 서버가 이전 요청의 상태를 기억하지 않는 무상태성을 가지며 서버와 클라이언트가 명확히 분리되어 있고 캐싱이 가능해 확장성과 성능이 좋습니다. <h3 id="7-브라우저의-캐시cache는-어떻게-동작하나요">7. 브라우저의 캐시(Cache)는 어떻게 동작하나요?</h3>
</li>
<li>브라우저 캐시는 이전에 받은 리소스를 저장해두었다가, 다음 요청 시 서버에 다시 요청하지 않고 로컬에서 불러오는 방식입니다. 이를 통해 네트워크 비용과 로딩 시간을 줄여 성능을 개선합니다.<h3 id="8-이미지나-리소스-최적화-방법은">8. 이미지나 리소스 최적화 방법은?</h3>
</li>
<li>이미지나 리소스 최적화는 파일 크기와 네트워크 전송량을 줄여 로딩 속도를 개선하는 기법입니다. 주로 WebP 같은 최적화된 이미지 포맷 사용,
화면에 보일 때만 로드하는 Lazy Loading 적용, 이미지 크기 리사이징,
그리고 웹폰트 최적화(예: font-display: swap) 등을 활용합니다.<h3 id="9-코드-스플리팅code-splitting과-lazy-loading은-무엇인가요">9. 코드 스플리팅(Code Splitting)과 Lazy Loading은 무엇인가요?</h3>
</li>
<li>코드 스플리팅은 번들을 여러 조각으로 나누고 필요한 순간에만 로드하여 초기 번들 크기를 줄이는 기법입니다. Lazy Loading은 특정 컴포넌트나 리소스를 사용자에게 필요할 때 가져오는 방식으로,코드 스플리팅을 구현하는 대표 방법입니다.<h3 id="10-렌더링-성능을-개선하는-방법은">10. 렌더링 성능을 개선하는 방법은?</h3>
</li>
<li>렌더링 성능을 개선한다는 것은 불필요한 연산과 리렌더링을 줄여 브라우저의 작업 부담을 낮추는 것입니다. React에서는 React.memo, useMemo, useCallback 등을 사용해 불필요한 컴포넌트 렌더링을 최소화하고, 상태를 적절히 분리해 렌더링 범위를 줄이는 것이 효과적입니다. 브라우저 레벨에서는 DOM 변경을 최소화하고, 레이아웃(Reflow) 비용이 큰 작업을 줄이며, 애니메이션은 transform과 opacity 중심으로 처리해 페인트 비용을 줄이는 방식이 사용됩니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 면접 질문 모음 (7)]]></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-7</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-7</guid>
            <pubDate>Thu, 06 Nov 2025 03:11:57 GMT</pubDate>
            <description><![CDATA[<h1 id="nextjs">Next.js</h1>
<h3 id="1-react만-사용할-때와-비교해-nextjs를-사용하는-이유에-대해-설명해주세요">1. React만 사용할 때와 비교해 Next.js를 사용하는 이유에 대해 설명해주세요.</h3>
<ul>
<li>Next.js는 React에 없는 기능을 프레임워크 수준에서 제공하여
운영성과 성능이 뛰어난 웹 애플리케이션을 빠르게 구축할 수 있게 해줍니다. React는 UI 라이브러리이기 때문에 라우팅, 서버 렌더링, 데이터 패칭, 최적화 같은 기능을 직접 설정해야 하지만, Next.js는 CSR 뿐만 아니라 SSR/SSG/ISR를 지원해 초기 렌더링 속도와 SEO를 개선할 수 있습니다. 또한 파일 기반 라우팅, 이미지 최적화, 서버 컴포넌트, API 라우트 등도 기본으로 제공됩니다. 즉, Next.js는 React를 확장해 서버 렌더링, 라우팅, 성능 최적화까지 포함한 올인원 웹 프레임워크입니다. </li>
</ul>
<h3 id="2-sever-component와-client-component의-차이점은-무엇인가요">2. Sever Component와 Client Component의 차이점은 무엇인가요?</h3>
<ul>
<li>서버 컴포넌트는 서버에서 렌더링되는 컴포넌트로, 서버 환경에서 직접 데이터 패칭이 가능하고 번들 크기를 줄여 성능을 최적화 할 수 있습니다. 
렌더링된 HTML을 클라이언트에 전달하기 때문에 초기 로딩 속도와 SEO에 유리합니다. 다만 브라우저 환경이 아니므로 상태 관리, 이벤트 처리, DOM조작 같은 클라이언트 상호작용은 불가능합니다. 반면에 클라이언트 컴포너트는 브라우저에서 실행되는 컴포넌트로 useState, useEffet 같은 훅을 사용해 상태 관리와 이벤트 처리, DOM 조작이 가능합니다. 그러나 클라이언트 번들에 포함되기 때문에 번들 크기에 영향이 있고 초기 로드 비용이 더 큽니다.</li>
</ul>
<h3 id="3-nextjs의-app-router와-pages-router의-차이점을-설명해주세요">3. Next.js의 App Router와 Pages Router의 차이점을 설명해주세요.</h3>
<ul>
<li>Next.js의 App Router와 Pages Router의 가장 큰 차이는 구조와 렌더링 방식, 데이터 패칭 방식입니다. 먼저 파일 구조입니다. Pages Router는 pages/ 디렉토리를 사용하고 파일 기반 라우팅만 제공하지만, App Router는 app/ 디렉토리와 레이아웃 기반 라우팅 및 중첩 레이아웃을 지원해 페이지 공통 UI 구성이 더 쉽습니다. 두번째는 렌더링 방식입니다. Pages Router는 기본이 클라이언트 컴포넌트이고, 서버렌더링이 필요하면 getServerSideProps 등을 명시해야 합니다. 반면 App Router는 서버 컴포넌트가 기본이며, 필요한 경우에만 &#39;use client&#39;를 선언해 클라이언트 컴포넌트를 사용합니다. 세번째는 데이터 패칭 방식입니다. Pages Router는 getStaticProps, getServerSideProps 같은 특수 함수가 필요하지만, App Router는 서버 컴포넌트 내부에서 직접 fetch할 수 있고
Server Actions로 서버 로직을 처리할 수 있습니다. 정리하자면, Pages Router는 전통적인 CSR 기반 아키텍처이고, App Router는 서버 중심의 현대적 아키텍처로 성능과 확장성이 크게 향상된 방식입니다.</li>
</ul>
<h3 id="4-spa와-mpa-csrssrssgisr이-어떤-기준으로-나뉘나요">4. SPA와 MPA CSR/SSR/SSG/ISR이 어떤 기준으로 나뉘나요?</h3>
<ul>
<li>SPA와 MPA는 애플리케이션 구조 기준, CSR/SSR/SSG/ISR은 렌더링 시점과 위치 기준으로 구분됩니다. 즉, SPA/MPA는 페이지 전환 방식이고, 렌더링 방식들은 HTML을 어디서, 언제 생성하는지에 따라 나뉩니다.<h3 id="5-spa와-mpa-차이-설명해보세요">5. SPA와 MPA 차이 설명해보세요.</h3>
</li>
<li>SPA는 전체 앱이 하나의 HTML로 로드되고, 페이지 이동은 클라이언트 라우팅으로 처리합니다. 화면 전환이 빠르고 사용자 경험이 좋지만, 초기 JS 로드가 크고 SEO 대응이 상대적으로 어렵습니다. MPA는 페이지 이동 시마다 서버에서 새로운 HTML을 가져오는 방식입니다. 초기 로딩와 SEO에 유리하지만, 페이지 전환 시 새로고침이 발생합니다.<h3 id="6-csrssrssgisr-차이-설명해보세요">6. CSR/SSR/SSG/ISR 차이 설명해보세요.</h3>
</li>
<li>CSR은 서버가 기본 HTML과 JS 번들을 보내고, 브라우저가 JS를 실행하여 화면을 렌더링하는 방식입니다. 초기 로딩이 느릴 수 있지만, 한 번 로딩되면 페이지 전환이 빠르고 사용자 경험이 부드러운 것이 특징입니다. SSR은 요청 시 서버가 HTML을 생성해 전달하고, 브라우저는 전달된 HTML에 JS를 하이드레이션합니다. 초기 렌더링이 빠르고 SEO에 유리합니다. SSG는 빌드 시 HTML을 미리 생성해 제공하는 방식입니다. 매우 빠르고 비용 효율적이지만, 데이터 변경 시 재빌드가 필요합니다. ISR은 SSG처럼 정적으로 제공하되,일정 주기마다 백그라운드에서 페이지를 다시 생성합니다. 정적 페이지의 성능 + 최신 데이터 유지를 동시에 제공합니다.<h3 id="7-hydration에-대해-설명해-주세요">7. Hydration에 대해 설명해 주세요.</h3>
</li>
<li>Hydration은 서버에서 미리 렌더링된 HTML에 브라우저가 JS를 연결해 인터랙션이 가능하도록 만드는 과정입니다. 즉, SSR이나 SSG로 만든 정적인 HTML을 살려서 상태 관리, 이벤트 처리 같은 기능을 붙여 동적으로 동작하도록 만드는 단계입니다. <h3 id="8-getstaticprops와-getserversideprops의-차이점은-무엇인가요">8. getStaticProps와 getServerSideProps의 차이점은 무엇인가요?</h3>
</li>
<li>둘 다 Next.js에서 Next.js에서 데이터를 가져오는 방식이지만, 실행 시점과 렌더링 방식이 다릅니다. getStaticProps는 빌드 시점에 실행되어 정적 HTML 파일을 미리 생성하는 SSG 방식입니다. 요청 시마다 서버 연산이 필요 없기 때문에 응답 속도가 매우 빠르고 CDN 캐싱이 가능합니다.
다만, 빌드 이후 데이터가 바뀌면 반영이 어려워 자주 변경되지 않는 콘텐츠에 적합합니다. getServerSideProps는 요청 시마다 서버에서 실행되는 SSR 방식입니다. 매 요청마다 최신 데이터를 가져올 수 있어 동적 데이터에 적합하지만, 서버 계산이 필요하기 때문에 응답 속도가 느릴 수 있고 서버 부하가 증가할 수 있습니다.</li>
</ul>
]]></description>
        </item>
    </channel>
</rss>