<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>silver_hq.log</title>
        <link>https://velog.io/</link>
        <description></description>
        <lastBuildDate>Thu, 12 Jun 2025 02:10:00 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>silver_hq.log</title>
            <url>https://velog.velcdn.com/images/silver_hq/profile/b29cffcf-5495-40ff-adf1-bd45dcf74bd4/image.jpeg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. silver_hq.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/silver_hq" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[Typescript Infer]]></title>
            <link>https://velog.io/@silver_hq/Typescript-Infer</link>
            <guid>https://velog.io/@silver_hq/Typescript-Infer</guid>
            <pubDate>Thu, 12 Jun 2025 02:10:00 GMT</pubDate>
            <description><![CDATA[<p>Typescript의 Infer 키워드는 복잡한 타입에서 특정 부분을 추출하거나 추론할 때 사용하는 키워드이다.
Infer 키워드는 조건부 타입 내에서만 사용할 수 있고 Typescript 컴파일러가 타입을 자동으로 추론하게 한다.</p>
<h2 id="조건부-타입">조건부 타입</h2>
<p>먼저 조건부 타입에 대해 알아보고자 한다. 조건부 타입은 <code>T extends U ? X : Y</code> 형태로 타입을 조건에 따라 결정하는 기능이다.
내장 유틸 타입을 조건부 타입을 통해 구현할 수 있다.</p>
<pre><code class="language-typescript">
type CustomExclude&lt;T,U&gt; = T extends U ? never : T;

type Result1 = CustomExclude&lt;1 | 2 | 3 , 1&gt;; // 2 | 3
type Result2 = CustomExclude&lt;string | number | boolean , string&gt;; // number | boolean
</code></pre>
<p>조건부 타입을 통해 T가 U를 포함할 경우 never 타입을, 포함하지 않을 경우 T를 반환한다.
예를 들어 <code>1 | 2 | 3</code>과 <code>1</code>을 넣을 경우 결과로 <code>2 | 3</code>이 나오게 된다.</p>
<p>여기서 조금 헷갈리는 부분이 있었는데, <code>1 | 2 | 3 extends 1 ? never : 1 | 2 | 3</code>이 되는데 왜 <code>never</code>가 아닌 <code>2 | 3</code>이 나올까?</p>
<p>그 이유는 Union 타입이 <code>T</code>의 자리에 오게 되면 분산 조건부 타입이 적용되기 때문이다. 마치 수학에서 <code>(1+2+3)*2</code>를 하면 <code>1*2+2*2+3*2</code>가 되는 것 처럼 Union 타입에 있는 각각의 타입들에 대해 <code>extends U ? never : T</code>를 적용하는 것이다. </p>
<pre><code class="language-typescript">1 extends 1 ? never : 1 // never
2 extends 1 ? never : 2 // 2
3 extends 1 ? never : 3 // 3

Result1 = 2 | 3;</code></pre>
<p>위와 같이 적용되어 <code>CustomExclude&lt;1 | 2 | 3 , 1&gt;</code>의 타입은 <code>2 | 3</code>이 되는 것이다.</p>
<h2 id="infer">Infer</h2>
<pre><code class="language-typescript">
type ExtractReturnType&lt;T&gt; = T extends (...args: any[]) =&gt; infer R ? R : never;

type Result1 = ExtractReturnType&lt;()=&gt;string&gt;; // string
type Result2 = ExtractReturnType&lt;(x:number)=&gt;boolean&gt;; // boolean
</code></pre>
<p>위와 같이 infer를 사용한 유틸리티 타입 <code>ExtractReturnType</code>은 제네릭으로 입력받은 T 타입에서 리턴값을 추론한다. </p>
<p>여기서 중요한 점은 <code>extends</code>를 통해 T의 타입이 함수 형태인지를 먼저 확인한 다음 해당 함수에서 리턴 타입을 <code>infer</code>를 통해 추출한다는 것이다.</p>
<p>그 이유는 <code>infer</code>키워드는 타입의 구조 안에서 특정 부분을 추론하는데 이 타입의 구조를 알기 위해선 제네릭으로 입력된 타입 <code>T</code>의 구조를 먼저 <code>extends</code>를 통해 이러한 구조일 것이다 가정한 다음 그 안에서 부분적으로 타입을 추론하는 것이다. </p>
<p>만약 <code>extends</code>가 false일 경우엔 삼항 연산자의 <code>:</code>에 해당하는 타입을 반환한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Typescript Discriminated Union Type이란?]]></title>
            <link>https://velog.io/@silver_hq/Typescript-Discriminated-Union-%ED%83%80%EC%9E%85</link>
            <guid>https://velog.io/@silver_hq/Typescript-Discriminated-Union-%ED%83%80%EC%9E%85</guid>
            <pubDate>Wed, 04 Jun 2025 08:19:47 GMT</pubDate>
            <description><![CDATA[<h2 id="discriminated-union-type이란">Discriminated Union Type이란?</h2>
<p>Discriminated Union Type은 공통된 리터럴 속성(값이 고정된 속성)을 가진 여러 객체 타입을 하나의 유니온 타입으로 묶은 것이다. 공통 속성을 통해 타입을 구분(Discriminate)할 수 있어서 조건문 등을 통해 안전하게 타입을 좁힐 수 있다.</p>
<pre><code class="language-typescript">
interface Dog {
  type : &#39;dog&#39;;
  bark : ()=&gt;void;
 }

interface Cat {
  type : &#39;cat&#39;;
  meow : ()=&gt;void;
}

type Pet = Dog | Cat;

const handlePet =(pet:Pet)=&gt; {
  switch(pet.type) {
    case &#39;dog&#39;:
      pet.bark();
      break;
    case &#39;cat&#39;:
      pet.meow();
      break;
  }

const dog = {
  type: &#39;dog&#39;,
  bark : ()=&gt; console.log(&#39;왈왈&#39;),
}

const cat = {
  type: &#39;cat&#39;,
  meow : ()=&gt; console.log(&#39;야옹&#39;),
}

handlePet(dog); // &#39;왈왈&#39;
handlePet(cat); // &#39;야옹&#39;</code></pre>
<p>위 예시처럼 type이라는 공통된 리터럴 속성을 갖고 있다면 <code>handlePet</code>함수 안에서 <code>pet.type</code>값을 기준으로 타입을 정확하게 좁힐 수 있다.</p>
<p>또한 <code>case &#39;dog&#39;</code> 블록 안에서는 <code>pet.</code>을 입력하면 자동으로 <code>bark()</code>와 같이 <code>dog</code>타입의 속성만 자동완성으로 표시되기 때문에 생산성을 높일 수 있다.</p>
<h2 id="in-타입-가드의-한계">in 타입 가드의 한계</h2>
<p>만약 type이라는 공통된 리터럴 속성이 없다면 <code>in</code>연산자를 사용해 타입을 좁혀야 한다.</p>
<pre><code class="language-typescript">
const handlePet=(pet:Pet)=&gt; {
  if (&#39;bark&#39; in pet) return pet.bark();
  if (&#39;meow&#39; in pet) return pet.meow();
}</code></pre>
<p>이러한 방식도 동작은 하지만 다음과 같은 단점이 있다.</p>
<h3 id="1속성이-많아질수록-in을-통한-조건문이-많아진다">1.속성이 많아질수록 in을 통한 조건문이 많아진다.</h3>
<p>만약 속성이 &#39;bark&#39;와 &#39;meow&#39; 뿐이라면 이렇게 해도 큰 문제는 없지만 더 많은 속성들이 타입 안에 있을 경우 속성들을 사용할 때 하나하나 in을 통해 확인해야한다.</p>
<h3 id="2중복된-속성이-될-경우">2.중복된 속성이 될 경우</h3>
<p>속성이 추가되더라도 만약 <code>&#39;bark&#39;</code>가 <code>dog</code>에 유일하게 있는 속성이라면 <code>&#39;bark&#39; in pet</code> 을 통해 타입을 <code>dog</code>로 좁힐 수 있지만, 만약 <code>wolf</code>라는 타입이 추가돼서 <code>bark</code>속성을 갖게 된다면 <code>dog</code>로 타입을 좁히는 것이 불가능해진다.</p>
<h3 id="3자동완성-미지원">3.자동완성 미지원</h3>
<p>또한 <code>&#39;bark&#39; in pet</code>과 같이 타입을 검증할 땐 자동완성이 뜨지 않는다는 점과 만약 <code>bark</code> 함수의 이름이 변경되더라도 <code>&#39;bark&#39; in pet</code>이 컴파일 에러 없이 그대로 남아서 버그를 유발할 수 있다.</p>
<h2 id="exhaustiveness-checking을-통한-안전한-타입-처리">Exhaustiveness Checking을 통한 안전한 타입 처리</h2>
<p>Discriminated Union타입을 Switch문으로 좁힐 때 모든 case를 처리하지 않는다면 타입이 누락되는 상황이 발생할 수 있다.
이를 방지하기 위해 never 타입과 default 블록을 활용하면 타입이 누락될 경우 컴파일 타임에 오류를 발생시켜서 타입 누락을 막을 수 있다.</p>
<pre><code class="language-typescript">
interface Dog {
  type : &#39;dog&#39;;
  bark : ()=&gt;void;
}

interface Cat {
  type : &#39;cat&#39;;
  meow : ()=&gt;void;
}

interface Bird {
  type : &#39;bird&#39;;
  chirp : ()=&gt;void;
}

type Pet = Dog | Cat | Bird;

const handlePet =(pet:Pet)=&gt; {
  switch (pet.type) {
    case &#39;dog&#39;:
      pet.bark();
      break;
    case &#39;cat&#39;:
      pet.meow();
      break;
    case &#39;bird&#39;:
      pet.chirp();
      break;
    default:
      const _exhaustiveCheck: never = pet;
      return _exhaustiveCheck;
  }
}
</code></pre>
<p>여기서 만약 새로운 타입인 <code>Turtle</code>이 <code>Pet</code> 타입에 추가되거나 <code>bird</code>타입이 빠지게 되면 컴파일 에러가 표시된다.</p>
<h3 id="모든-타입에-대한-case가-있을-때">모든 타입에 대한 case가 있을 때</h3>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/2fc84f9e-7a6b-43d8-9fc7-8b4e5a07de71/image.png" alt=""></p>
<h3 id="turtle이-추가됐을-때">Turtle이 추가됐을 때</h3>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/deb89a2f-4311-48e8-9db3-5a6dfc820c2f/image.png" alt=""></p>
<h3 id="bird가-빠졌을-때">Bird가 빠졌을 때</h3>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/76de692d-7ed0-4e03-bd16-48e3e53b705f/image.png" alt=""></p>
<p>Turtle이 추가됐을 떈 pet.type의 default로 never가 아닌 Turtle이 와서 에러가 발생하고
Bird가 빠졌을 땐 Pet의 타입인 <code>&quot;dog&quot; | &quot;cat&quot;</code>과 일치하지 않는다는 오류가 표시되는 것을 볼 수 있다.</p>
<h2 id="실제-사용-사례">실제 사용 사례</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/71d033ec-ec50-4b3c-ba0d-989d00d2121f/image.png" alt=""></p>
<p>위와 같이 서로 공통된 요소를 갖고 있지만 조금씩 다른 요소들이 존재하는 UI를 하나의 컴포넌트로 구현하기 위해 Discriminated Union 타입을 사용했다.</p>
<pre><code class="language-typescript">
// 공통된 props(기사님 이름, 리뷰 평점, 리뷰 수, 경력 등등)
interface BaseProps {
  movingType : MovingType[];
  moverName : string;
  rating : number;
  //... 그 외 공통된 속성들
}

interface CompletedQuoteProps extends BaseProps {
  variant: &#39;quote&#39;;
  subVariant: &#39;completed&#39;;
  description?: string;
  price?: number;
}

interface PendingQuoteProps extends BaseProps {
  variant : &#39;quote&#39;;
  subVariant : &#39;pending&#39;;
  quoteId: string;
  price? : number;
  // ... 그 외 속성들
}

interface WrittenReviewProps extends BaseProps {
  variant : &#39;review&#39;;
  subVariant : &#39;written&#39;;
  reviewContent : string;
  writtenAt : Date;
}

interface PendingReviewProps extends BaseProps {
  variant : &#39;review&#39;;
  subVariant : &#39;pending&#39;;
  onClickReviewButton : ()=&gt;void;
  // ... 그 외 속성들
}

type MoverInfoProps = CompletedQuoteProps | PendingQuoteProps 
| WrittenReviewProps | PendingReviewProps;
</code></pre>
<p>먼저 공통으로 사용되는 props들을 <code>BaseProps</code>로 선언하고 모든 유형의 interface에서 <code>extends</code>를 통해 해당 interface를 확장하도록 했다.</p>
<p>그리고 <code>variant</code>속성을 discriminant(구분자)로 사용해 유형을 구분했다. 큰 구분자 안에서 세부 구분을 위해 <code>subVariant</code>를 추가했는데 이렇게 할 경우 <code>variant</code>가 <code>quote</code>일 땐 <code>subVariant</code>는 <code>completed</code>과<code>pending</code>중 선택할 수 있게 좁혀진다.</p>
<pre><code class="language-tsx">
export default function MoverInfo(props : MoverInfoProps) {
    return (
      &lt;div&gt;
        &lt;공통된UI {...someProps} /&gt;
        {props.variant === &#39;quote&#39; &amp;&amp; (
          { props.subVariant === &#39;completed&#39; &amp;&amp; (
           &lt;CompletedQuote에 해당하는 UI/&gt;
           )}
        {props.variant === &#39;review&#39; &amp;&amp; (
          { props.subvariant === &#39;written&#39; &amp;&amp; (
           &lt;WrittenReview에 해당하는 UI/&gt;
           )}
        // ... 그 외 UI
      &lt;/div&gt;
    )
}
</code></pre>
<p>이렇게 컴포넌트 내에서 props의 variant,subVariant를 통해 표시할 UI를 선택할 수 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[리액트의 Virtual DOM]]></title>
            <link>https://velog.io/@silver_hq/%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%9D%98-Virtual-DOM</link>
            <guid>https://velog.io/@silver_hq/%EB%A6%AC%EC%95%A1%ED%8A%B8%EC%9D%98-Virtual-DOM</guid>
            <pubDate>Sun, 25 May 2025 05:58:24 GMT</pubDate>
            <description><![CDATA[<h2 id="dom">DOM</h2>
<p>Virtual DOM에 대해 설명하기 전에 DOM에 대해 알아보려 한다.
DOM(Document Object Model)은 HTML을 트리 구조의 객체로 표현한 모델이다. 이 DOM에는 API가 있어서 자바스크립트와 같은 프로그래밍 언어로 컨트롤할 수 있다.</p>
<p>웹 페이지를 동적으로 작동시킬 때 이 DOM API를 활용한다. 사용자의 행동에 따라 UI를 변경하거나 데이터가 변할 때 마다 DOM을 조작해서 UI를 업데이트하는 것이다. </p>
<h3 id="dom-조작-시-브라우저에서-일어나는-작업">DOM 조작 시 브라우저에서 일어나는 작업</h3>
<p>DOM을 조작할 때 브라우저는 다음과 같은 과정을 거친다.</p>
<ol>
<li>DOM트리 수정 - HTML 구조가 변경된다.</li>
<li>스타일 재계산 - 어떤 요소에 어떤 CSS가 적용되어야 하는지 계산한다.</li>
<li>레이아웃(reflow) - 요소들의 위치와 크기를 계산한다.</li>
<li>페인트 - 계산된 스타일로 픽셀 단위로 화면을 그린다.</li>
<li>합성 - 여러 레이어를 조합해서 최종 화면 출력한다.</li>
</ol>
<p>이러한 작업은 모두 연산 비용이 높은 작업이다. 많은 요소를 동시에 조작하거나 DOM을 자주 업데이트하는 경우 성능 저하가 발생할 수 있다.</p>
<p>예를 들어 특정 데이터를 표시하는 UI가 있을 때 데이터가 바뀔 때마다 DOM을 조작해야 한다. 이 때 데이터의 양이 많아지거나 데이터가 자주 변경된다면 이 과정은 비효율적이다.</p>
<h2 id="virtual-dom">Virtual DOM</h2>
<p>리액트는 이러한 문제를 해결하기 위해 Virtual DOM을 도입했다. Virtual DOM은 실제 DOM과 비슷하지만 메모리 상에 존재하는 가상의 DOM 트리이다.</p>
<p>Virtual DOM은 컴포넌트가 렌더링 될 때마다 새로운 Virtual DOM을 생성해서 이전 Virtual DOM과 비교(Diffing)하고 변경사항이 있는 부분만 실제 DOM에 반영한다. 이를 통해 불필요한 DOM 조작을 줄이고 효율적으로 UI를 업데이트할 수 있게 된다.</p>
<p>Virtual DOM이 동작하는 흐름은 다음과 같다.</p>
<ol>
<li>상태(state)또는 props가 변경된다.</li>
<li>해당 컴포넌트의 함수가 다시 실행되어 새로운 Virtual DOM이 생성된다.</li>
<li>이전 Virtual DOM과 비교(Diffing)하여 변경된 부분을 찾는다.</li>
<li>변경된 요소에 대해서만 실제 DOM을 업데이트한다.</li>
</ol>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/428f9baf-78ca-4ae5-83bc-96be54272178/image.jpg" alt=""></p>
<p>위 이미지처럼 기존에 <code>[1,2,3,4,5,6]</code> 데이터를 출력하는 요소가 있고 이 데이터가 <code>[1,7,3,8,5,9]</code>로 변경되는 경우를 예로 들어보자.</p>
<p>Virtual DOM을 사용하지 않고 배열의 map을 통해 UI를 리렌더링 하게 될 경우 전체 데이터에 해당하는 UI를 새롭게 렌더링하게 된다.</p>
<p>Virtual DOM을 사용하게 되면 데이터가 변화할 때 컴포넌트 함수가 다시 실행되면서 Virtual DOM을 새로 생성하게 되고 이를 이전 Virtual DOM과 비교한다. 변경사항은 <code>2 -&gt; 7, 4 -&gt; 8, 6 -&gt; 9</code>이기 때문에 실제 DOM에선 이 세개의 노드만 업데이트 한다. </p>
<h2 id="key를-사용하는-이유">Key를 사용하는 이유</h2>
<p>리액트에서 배열을 기반으로 요소를 렌더링할 때 key를 입력하는 이유는 Virtual DOM을 통해 Diffing하는 작업을 최적화하기 위함이다. </p>
<p>key는 각 요소를 고유하게 식별하는 값으로 React가 이전 Virtual DOM과 새로운 Virtual DOM을 비교할 때 어떤 요소가 변경,추가되고 삭제됐는지 빠르게 파악할 수 있도록 도와준다.</p>
<p>만약 key가 입력되지 않거나 배열의 index를 key로 사용하게 되면 중간에 요소가 삽입되거나 삭제될 때 React는 그 이후의 모든 요소가 변경된 것으로 간주한다.</p>
<p>예를 들어 다음과 같은 배열이 있다고 해보자.</p>
<pre><code class="language-javascript">const before = [1,2,3,4,5];
const after = [1,2,4,5];</code></pre>
<p>3이 제거된 상황이기 때문에 실제로는 해당 요소만 제거하면 된다. 하지만 index를 Key로 입력하게 되면 아래와 같이 비교하게 된다.</p>
<h3 id="key를-index로-입력했을-때">Key를 Index로 입력했을 때</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>0</td>
<td>1</td>
<td>1</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>2</td>
</tr>
<tr>
<td>2</td>
<td>3</td>
<td>4</td>
</tr>
<tr>
<td>3</td>
<td>4</td>
<td>5</td>
</tr>
<tr>
<td>4</td>
<td>5</td>
<td>X</td>
</tr>
</tbody></table>
<p>특정 값이 제거되더라도 인덱스는 변함이 없기 때문에 key는 고정된 상태로 값만 바뀌게 된다.</p>
<p>그러면 리액트는 key가 2인 요소가 삭제된 것이 아니라
key가 2인 요소의 값이 4로 변경되고
key가 3인 요소의 값이 5로 변경되고
key가 4인 요소가 삭제된 것으로 판단한다.</p>
<p>key가 2인 요소만 삭제하면 되는데 이렇게 되면 key가 2,3인 요소들이 리렌더링 되고 key가 4인 요소가 삭제된다.</p>
<p>그렇기 때문에 key에는 index가 아닌 각 데이터의 고유한 값을 입력해줘야 한다.</p>
<h3 id="key를-id로-입력했을-때">Key를 Id로 입력했을 때</h3>
<table>
<thead>
<tr>
<th>Key</th>
<th>Before</th>
<th>After</th>
</tr>
</thead>
<tbody><tr>
<td>id1</td>
<td>{ id :&#39;id1&#39;, value : 1 }</td>
<td>{ id : &#39;id1&#39;, value : 1 }</td>
</tr>
<tr>
<td>id2</td>
<td>{ id :&#39;id2&#39;, value : 2 }</td>
<td>{ id :&#39;id2&#39;, value : 2 }</td>
</tr>
<tr>
<td>id3</td>
<td>{ id :&#39;id3&#39;, value : 3 }</td>
<td>X</td>
</tr>
<tr>
<td>id4</td>
<td>{ id :&#39;id4&#39;, value : 4 }</td>
<td>{ id :&#39;id4&#39;, value : 4 }</td>
</tr>
<tr>
<td>id5</td>
<td>{ id :&#39;id5&#39;, value : 5 }</td>
<td>{ id :&#39;id5&#39;, value : 5 }</td>
</tr>
</tbody></table>
<p>Key를 각 요소의 ID로 입력했다면 세번째 요소를 삭제할 경우 key가 id3인 요소가 삭제된 것으로 간주해 id3에 해당하는 요소만 삭제해서 최적화된 DOM 조작이 가능하다.</p>
<h2 id="정리">정리</h2>
<p>처음에는 Virtual DOM이 DOM의 성능을 개선한 별도의 새로운 기술이라고 생각했다. 하지만 실제로는 기존의 DOM을 가장 효율적으로 조작하기 위해 만들어진 패턴이란 것을 알게 되었다.</p>
<p>리액트도 결국 자바스크립트로 만들어진 라이브러리이기 때문에 브라우저가 제공하는 DOM API의 한계를 벗어날 수 없다. 그렇기 때문에 브라우저에서 DOM을 조작하는 완전히 새로운 방식을 만들어낸 것이 아니라 기존 기술을 바탕으로 DOM 업데이트를 최적화하는 구조를 만든 것이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[서버 컴포넌트에서 데이터 패칭을 위한 AxiosInstance 설정하기]]></title>
            <link>https://velog.io/@silver_hq/%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%97%90%EC%84%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD%EC%9D%84-%EC%9C%84%ED%95%9C-AxiosInstance-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@silver_hq/%EC%84%9C%EB%B2%84-%EC%BB%B4%ED%8F%AC%EB%84%8C%ED%8A%B8%EC%97%90%EC%84%9C-%EB%8D%B0%EC%9D%B4%ED%84%B0-%ED%8C%A8%EC%B9%AD%EC%9D%84-%EC%9C%84%ED%95%9C-AxiosInstance-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 14 May 2025 09:51:44 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>나는 CSRF 공격 방지를 위해 CSRF 토큰 기능을 구현했다. 내가 클라이언트 단에서 사용하는 CSRF 토큰 기능은 다음과 같다.</p>
<h3 id="백엔드-미들웨어">백엔드 미들웨어</h3>
<p>Request의 쿠키에서 CSRF 토큰을 가져온다.
=&gt; Request의 헤더에서 x-csrf-token을 가져온다.
=&gt; 둘이 일치하지 않거나 둘 중 하나가 없으면 403으로 응답한다.</p>
<h3 id="프론트엔드-서버">프론트엔드 서버</h3>
<p>CSRF 토큰을 발급해서 쿠키에 담는 <code>/csrf-token</code> API Route 구현
(클라이언트에서 쿠키를 사용하기 위해 httpOnly는 false로 하는 대신 sameSite를 Strict로 설정)</p>
<h3 id="프론트엔드-클라이언트">프론트엔드 클라이언트</h3>
<p>Axios request interceptor를 통해 매 요청마다 쿠키에 CSRF 토큰이 있는지 확인
=&gt; 없을 경우 <code>/csrf-token</code> API Route 호출
=&gt; 호출 이후 또는 쿠키가 있을 경우 쿠키에서 CSRF token 가져온 다음 x-csrf-token 헤더에 CSRF토큰 값을 추가</p>
<h2 id="문제">문제</h2>
<p>서버 컴포넌트에서 데이터를 패칭할 때도 CSRF 토큰을 포함시키지 않는다면 미들웨어를 통과할 수 없다. 그동안은 AxiosInstance를 쓰지 않고 fetch를 썼었는데 매번 요청 때마다 CSRF 토큰을 가져오고 추가하는 작업을 해야한다는 점에서 비효율적이었다.</p>
<p>그래서 서버 컴포넌트에서 데이터를 패칭할 때도 AxiosInstance를 쓰기로 했다.
한가지 문제점이 있다. 클라이언트에서 사용하는 AxiosInstance의 경우 사용자의 브라우저에 있는 메모리에 생성되기 때문에 한 사용자가 하나의 AxiosInstance를 사용하게 된다.</p>
<p>하지만 서버에서 사용할 땐 여러 사용자가 동일한 서버를 공유하기 때문에 하나의 AxiosInstance에 대해 여러 사용자가 사용하게 된다. 그러면 요청 간에 쿠키나 헤더가 충돌할 수 있어서 문제가 발생할 수 있다.</p>
<p>이를 해결하기 위해 요청 때마다 AxiosInstance를 생성하도록 구현했다. 이렇게 했을 때 성능적으로 문제가 없을지 궁금했다. GPT에 질문했을 때 이렇게 답변했다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/1c61bf2a-b813-4dd0-a31f-35a4e0b6a085/image.png" alt=""></p>
<h2 id="구현">구현</h2>
<pre><code class="language-typescript">import crypto from &#39;crypto&#39;;
import axios from &#39;axios&#39;;
import { cookies } from &#39;next/headers&#39;;

export const createServerAxiosInstance = () =&gt; {
  const instance = axios.create({
    baseURL: process.env.NEXT_PUBLIC_API_URL,
    timeout: 5000,
    headers: {
      &#39;Content-Type&#39;: &#39;application/json&#39;,
    },
  });

  instance.interceptors.request.use((config) =&gt; {
    const cookieStore = cookies();
    const allCookies = cookieStore.toString();

    const csrfToken = crypto.randomBytes(32).toString(&#39;hex&#39;);

    const cleanedCookies = allCookies
      .split(&#39;;&#39;)
      .map((cookie) =&gt; cookie.trim())
      .filter((cookie) =&gt; !cookie.startsWith(&#39;csrfToken=&#39;))
      .join(&#39;; &#39;);

    const cookieWithCsrf = cleanedCookies
      ? `${cleanedCookies}; csrfToken=${csrfToken}`
      : `csrfToken=${csrfToken}`;

    config.headers[&#39;Cookie&#39;] = cookieWithCsrf;
    config.headers[&#39;X-CSRF-Token&#39;] = csrfToken;

    return config;
  });

  return instance;
};

</code></pre>
<p> 기존의 AxiosInstance와 구현하는 코드는 비슷하다. 다만 CSRF 토큰이 만료된 경우가 조금 복잡했다. CSRF토큰을 API Route를 통해 쿠키에 담고 있는데 서버에서 서버로 요청할 경우 응답은 오지만 쿠키를 자동으로 저장하지 않기 때문이다. </p>
<p>응답에서 쿠키를 직접 꺼내 파싱해서 쓰는 방법도 있지만, 그보단 CSRF토큰을 새로 생성하는 것이 더 간편했다. 그래서 쿠키에 CSRF 토큰이 있거나 없거나 상관 없이 그냥 새로 생성한 다음 쿠키와 헤더에 붙이도록 설정했다.</p>
<p>이렇게 설정하니 서버 컴포넌트에서 데이터를 패칭할 때도 CSRF 미들웨어를 통과해서 데이터를 잘 불러오는 것을 확인했다.</p>
<pre><code class="language-typescript">
import { createServerAxiosInstance } from &#39;@/lib/axiosForServer&#39;;

export const getQuoteRequest = async () =&gt; {
  const instance = createServerAxiosInstance();
  try {
    const response = await instance.get(&#39;/quote-requests/latest&#39;);
    return response.data;
  } catch (e) {
    console.log(e);
    throw e;
  }
};
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[백엔드 리다이렉트 시 프론트엔드에서 토스트 표시하기 | 쿼리 파라미터]]></title>
            <link>https://velog.io/@silver_hq/%EB%B0%B1%EC%97%94%EB%93%9C-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%ED%8A%B8-%EC%8B%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%86%A0%EC%8A%A4%ED%8A%B8-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0-%EC%BF%BC%EB%A6%AC-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0</link>
            <guid>https://velog.io/@silver_hq/%EB%B0%B1%EC%97%94%EB%93%9C-%EB%A6%AC%EB%8B%A4%EC%9D%B4%EB%A0%89%ED%8A%B8-%EC%8B%9C-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C%EC%97%90%EC%84%9C-%ED%86%A0%EC%8A%A4%ED%8A%B8-%ED%91%9C%EC%8B%9C%ED%95%98%EA%B8%B0-%EC%BF%BC%EB%A6%AC-%ED%8C%8C%EB%9D%BC%EB%AF%B8%ED%84%B0</guid>
            <pubDate>Mon, 12 May 2025 05:27:02 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/b2e75dff-6838-4bba-b3f4-bcabcf3adc8b/image.png" alt=""></p>
<p>프론트엔드에서 알림을 표시하기 위해 위의 이미지처럼 토스트를 사용하고 있다.</p>
<pre><code class="language-typescript">
const toast = useToaster();
toast(&#39;warn&#39;,&#39;존재하지 않는 이메일입니다.);
</code></pre>
<p>useToaster로 toast를 선언한 다음 toast(알림 종류,메시지)를 하면 토스트가 표시된다.</p>
<p>그런데 한가지 문제가 생겼다. OAuth로 로그인할 때 문제가 생기면 로그인 페이지로 리다이렉트 시키도록 설정했다. 이때 문제가 생겼다는 토스트를 표시해야 하는데 백엔드에서 리다이렉트를 시키기 때문에 토스트를 사용할 수 없었다.</p>
<h2 id="해결-방법">해결 방법</h2>
<p>이를 해결하기 위해 쿼리 파라미터를 입력해서 리다이렉트 시키고 이 쿼리 파라미터를 감지해서 토스트를 표시하는 프로바이더를 구현했다.</p>
<p>1.리다이렉트할 때 warn 쿼리 파라미터를 포함해서 리다이렉트 시킨다.
2.프로바이더에서 warn 쿼리 파라미터를 조회한다.
3.경고 메시지를 저장한 상수 객체를 통해 메시지를 불러온다.
4.toast(&#39;warn&#39;,메시지)로 토스트를 표시한다.
5.warn 쿼리 파라미터를 제거한다.</p>
<pre><code class="language-typescript">// @/constants/warningMessages.ts
export const WARNING_MESSAGES = {
  emailNotExist : &#39;존재하지 않는 이메일입니다.&#39;,
}

// @/providers/warningProvider.tsx
&#39;use client&#39;;

import { usePathname, useRouter, useSearchParams } from &#39;next/navigation&#39;;
import { useToaster } from &#39;@/hooks/useToaster&#39;;
import { useEffect } from &#39;react&#39;;
import { WARNING_MESSAGES } from &#39;@/constants/warningMessages&#39;;

export default function WarningProvider({
  children,
}: {
  children: React.ReactNode;
}) {
  const searchParams = useSearchParams();
  const warn = searchParams.get(&#39;warn&#39;) as keyof typeof WARNING_MESSAGES;
  const toast = useToaster();
  const router = useRouter();
  const pathname = usePathname();

  useEffect(() =&gt; {
    if (warn &amp;&amp; warn.length &amp;&amp; warn in WARNING_MESSAGES) {
      toast(&#39;warn&#39;, WARNING_MESSAGES[warn]);
    }

    if (warn) {
      const newParams = new URLSearchParams(searchParams.toString());
      newParams.delete(&#39;warn&#39;);

      const queryString = newParams.toString();
      const newPathname = queryString ? `${pathname}?${queryString}` : pathname;
      router.replace(newPathname);
    }
  }, [searchParams, warn]);

  return &lt;&gt;{children}&lt;/&gt;;
}

</code></pre>
<p>이렇게 구현하고 가장 상단 layout에 해당 프로바이더를 감싸주면 어느 페이지든 뒤에 <code>?warn=&#39;메시지 키&#39;</code>를 입력해주면 메시지 키에 해당하는 메시지가 토스트로 표시된 다음 warn 쿼리 파라미터가 제거된다.</p>
<p><code>if (warn)</code> 조건문 안에 있는 코드를 통해 warn 외에 입력된 쿼리 파라미터가 있다면 해당 쿼리 파라미터는 유지하고 warn만 제거할 수 있다.</p>
<p>이렇게 구현함으로써 OAuth뿐만 아니라 Next.js 미들웨어에서 리다이렉트 시킬 때도 경고 토스트를 표시할 수 있었다.</p>
<p>이런 토스트 뿐만 아니라 프론트엔드에서 작업이 필요한데 백엔드나 Next.js 미들웨어에서 리다이렉트를 시키기 때문에 프론트에서 직접 통제가 불가능한 경우에 모두 적용할 수 있는 기능이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSRF(Cross-Site Request Forgery)공격과 방어]]></title>
            <link>https://velog.io/@silver_hq/CSRFCross-Site-Request-Forgery%EA%B3%B5%EA%B2%A9%EA%B3%BC-%EB%B0%A9%EC%96%B4</link>
            <guid>https://velog.io/@silver_hq/CSRFCross-Site-Request-Forgery%EA%B3%B5%EA%B2%A9%EA%B3%BC-%EB%B0%A9%EC%96%B4</guid>
            <pubDate>Sun, 11 May 2025 07:30:54 GMT</pubDate>
            <description><![CDATA[<p>OAuth를 구현하던 중 Callback API 엔드포인트의 쿼리에 state를 추가했는데 이게 CSRF 공격을 방어하기 위한 것임을 알게 됐다. CSRF에 대해 이해하게 된 과정과 이해한 내용을 정리해보려 한다.</p>
<h2 id="이해하는-과정">이해하는 과정</h2>
<p>먼저 내가 OAuth에서 CSRF 공격이 어떻게 일어나는지 이해한 과정을 정리해보려 한다. 일단 클로드에게 CSRF에 대해 물어봤다.
<img src="https://velog.velcdn.com/images/silver_hq/post/0ccc85ec-5557-4332-b097-dcfd66b13750/image.png" alt=""></p>
<p>사실 처음엔 이 과정이 이해가 되지 않았다. 사용자가 공격자의 OAuth 계정으로 인증하게 되면 그냥 공격자의 계정으로 로그인하게 되는 건데 어떤 문제가 있는 걸까? 라고 생각했다.</p>
<p>알고보니 기존에 사용자가 이미 해당 서비스에 로그인이 돼있는 상태로 OAuth로그인을 하면 해당 계정에 OAuth 로그인 수단이 연결되는 것이었다. 그러므로 사용자의 계정에 공격자가 OAuth를 통해 로그인할 수 있게 되는 것이다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/5e301f2a-6c01-4552-a8e1-7eb2383761dd/image.png" alt=""></p>
<p>나는 OAuth 인증을 할 때 이메일을 기준으로 다음과 같이 처리했다.</p>
<ul>
<li>만약 OAuth로 인증을 했는데 이미 동일한 이메일로 직접 회원가입이 되어 있다면 해당 계정과 OAuth를 연결시킨다.</li>
<li>기존 회원 중 일치하는 메일이 없다면 새로 회원을 추가한다.</li>
</ul>
<p>이렇게 했기 때문에 공격을 받아도 이메일이 일치하지 않아서 공격자의 계정이 새로 추가될 뿐이었다. 의도하지 않았지만 CSRF 공격을 막도록 구현한 것이다. 이러한 부분 때문에 이 과정을 이해하는데 조금 오래 걸렸던 것 같다.</p>
<p>공격이 어떤식으로 이루어지는지 이해하는데 도움이 된 것은 state였다. 
state는 사용자가 OAuth 인증을 완료한 후 리다이렉트 시키는 Callback API에 쿼리 파라미터로 포함된다. 이를 통해 CSRF 공격은 이 Callback API에서 이루어지며 state 값을 통해 검증하면 CSRF 공격을 막을 수 있다는 것을 알 수 있었다. State에 대해서는 밑에서 설명할 예정이다.</p>
<p>그러면 CSRF 공격이 구체적으로 어떻게 이루어지는지를 알아보자.</p>
<h2 id="csrf-공격이-일어나는-과정">CSRF 공격이 일어나는 과정</h2>
<p>CSRF 공격이 일어나는 과정은 다음과 같다.</p>
<ol>
<li>사용자가 서비스에 로그인 된 상태</li>
<li>공격자가 악성 사이트에 실제 서비스에서 사용되는 API URL을 호출하도록 설정</li>
<li>사용자가 악성 사이트에 방문</li>
<li>사용자의 인증 정보로 API를 호출</li>
</ol>
<p>예를 들어 만약 사용자가 특정 은행 앱에 로그인 된 상태에서 악성 사이트에 접속하면 송금하는 API가 호출되고 송금이 이루어지는 것이다.</p>
<h2 id="csrf-공격을-방어하는-법">CSRF 공격을 방어하는 법</h2>
<p>이러한 공격이 가능한 이유는 브라우저가 API를 호출할 때 쿠키를 자동으로 전달하기 때문이다. 그렇기 때문에 만약 권한 인증 수단을 쿠키를 통해 전달하는 경우 CSRF 공격에 노출될 수 있다. 이를 막기 위한 방법이 몇가지 있다.</p>
<h3 id="cookie-samesite-lax-설정">Cookie sameSite lax 설정</h3>
<p>서버에서 권한 인증 수단을 쿠키로 응답할 때 sameSite 옵션을 lax로 설정하는 것이다.
이렇게 되면 HTTP 메서드 중 GET을 제외한 모든 메서드에 대해 다른 사이트에서 쿠키를 전달하지 못하게 된다.</p>
<p>2020년 2월 이후로 크롬은 SameSite 속성이 없는 쿠키에 대해 SameSite=Lax로 처리하도록 바뀌었다. 많은 브라우저들이 이러한 기능을 지원하지만 지원하지 않는 브라우저도 있기 때문에 SameSite를 lax로 설정하는 것이 중요하다.</p>
<p>옵션 중에 Strict도 있지만 설정할 경우 Get 요청을 포함한 모든 요청에 대해 다른 사이트에서 쿠키를 전달하는게 불가능해지기 때문에 제한사항이 많이 생긴다. </p>
<p>예를 들어 A라는 서비스가 페이지를 이동할 때마다 쿠키를 통해 사용자의 로그인 상태를 확인하도록 설정했을 경우 다른 사이트에서 링크를 통해 A로 이동하게 되면 로그인이 된 상태더라도 쿠키를 전달하지 못해서 로그인 여부 검증에 실패하는 것이다.</p>
<p>그렇기 때문에 SameSite는 lax로 설정하는 것이 좋다. </p>
<p>GET 메서드를 사용하는 API가 리소스의 상태를 변경하지 않는다는 가정 하에선 GET 메서드는 CSRF 공격에 안전하다고 볼 수 있다. 다만 GET 요청으로 민감한 정보를 불러오는 API가 있다면 별도의 처리가 필요하다.</p>
<h3 id="authorization-header-활용jwt">Authorization header 활용(JWT)</h3>
<p>권한 인증 수단으로 JWT를 사용하고 있다면 전달할 때 Authorization header를 사용하면 CSRF공격을 방어할 수 있다. </p>
<p>API를 호출할 때 자동으로 전달되는 쿠키와 다르게 Authorization header는 보통 localstorage에 토큰을 저장했다가 API를 호출할 때 마다 Header에 포함시키기 때문이다.</p>
<p>이렇게 되면 악성 사이트에서는 토큰을 헤더에 포함시킬 수 없기 때문에 CSRF 공격을 막을 수 있다.</p>
<p>다만 Next.js의 미들웨어나 서버 사이드 렌더링 시에는 localstorage를 사용할 수 없기 때문에 header에 토큰을 포함시킬 수 없다는 점이나 Session을 사용한다면 쿠키 방식을 사용하기 때문에 문제점이 있다.</p>
<h3 id="csrf-토큰-활용">CSRF 토큰 활용</h3>
<p>그 다음은 CSRF 토큰을 활용하는 방법이다. CSRF 공격의 핵심은 쿠키 자동 전달이기 때문에 
요청을 보낼 때 header에 CSRF 토큰을 포함시키고 서버에서 이 토큰을 검증하도록 설정해서 CSRF 공격을 방어하는 것이다.</p>
<p>이렇게 하면 공격자의 사이트에서 API를 호출하더라도 CSRF 토큰이 누락된 상태이기 때문에 API 호출에 실패하게 된다.</p>
<p>처음 로그인을 하면 CSRF 토큰을 응답하고 프론트엔드에서 요청을 보낼 때마다 이 토큰을 x-csrf-token 헤더에 포함시켜서 보내도록 설정하면 된다. 이때 token을 쿠키로 응답할 수도 있고 body나 header에 포함시켜서 응답하고 로컬스토리지에 저장하는 방식이 있다.</p>
<p>쿠키로 응답할 땐 httpOnly를 false로 처리해야 요청을 보낼 때 CSRF 토큰에 접근해서 보낼 수 있다. CSRF 토큰의 경우 외부에서 사용을 금해야하기 때문에 SameSite를 Strict로 설정한다. </p>
<p>그리고 CSRF토큰을 저장하는 쿠키의 생명주기는 로그인할 때 발급하는 refreshToken과 동일하게 설정하고 로그아웃 할 땐 쿠키를 삭제해줘야 한다.</p>
<h4 id="추가내용">추가내용</h4>
<p>CSRF 토큰을 구현하던 중 Double Submit Cookie 방식에 대해 알게 됐다. 이 방식은 길이가 긴 랜덤 값을 생성해서 쿠키에 담고 x-CSRF-Token 헤더에도 포함시키는 것이다.</p>
<p>공격자는 헤더를 위조할 순 있지만 브라우저에서 보내는 쿠키는 조작할 수 없기 때문에 헤더의 값과 쿠키의 값을 비교해서 일치 여부를 확인해서 CSRF 공격을 막을 수 있는 것이다.</p>
<p>나는 로그인을 하지 않아도 이 CSRF 토큰을 포함시키고 싶었다. 하지만 기존에 백엔드에서 로그인할 때 토큰을 발급하는 방식으론 로그인을 해야만 토큰을 발급받을 수 있다. </p>
<p>그래서 프론트엔드에서 Next API Route를 통해 토큰을 생성해 쿠키에 담고 Axios의 request interceptor에서 Header에 포함시키도록 구현했다. </p>
<p>(처음엔 JWT Secret을 사용하지 않기 때문에 굳이 API Route로 할 필요가 있을까 생각했지만 쿠키에 토큰을 설정하려면 서버 사이드에서 실행해야하기 때문에 API Route를 쓰는 것이 적절하다.)</p>
<p>그리고 백엔드에서 쿠키에 담긴 CSRF 토큰과 x-CSRF-Token 헤더에 담긴 토큰을 비교하는 CSRF 미들웨어를 구현했다.</p>
<h5 id="프론트엔드">프론트엔드</h5>
<pre><code class="language-typescript">// @/app/api/csrf-token/route.ts

import { NextResponse } from &#39;next/server&#39;;
import crypto from &#39;crypto&#39;;

export async function GET() {
  const token = crypto.randomBytes(64).toString(&#39;hex&#39;);

  const response = NextResponse.json({ message: &#39;CSRF Token 생성 완료&#39; });

  response.cookies.set(&#39;csrfToken&#39;, token, {
    httpOnly: false,
    secure: process.env.NODE_ENV === &#39;production&#39;,
    sameSite: &#39;strict&#39;,
    maxAge: 1000 * 60 * 5,
    path: &#39;/&#39;,
  });

  return response;
}
</code></pre>
<pre><code class="language-typescript">// axiosInstance.ts

const getCSRFTokenFromCookie = () =&gt; {
  if (typeof document === &#39;undefined&#39;) return null;

  const match = document.cookie.match(/csrfToken=([^;]+)/);
  return match ? match[1] : null;
};

axiosInstance.interceptors.request.use(
  async (config) =&gt; {
    let csrfToken = getCSRFTokenFromCookie();
    if (!csrfToken) {
      await fetch(&#39;/api/csrf-token&#39;, {
        method: &#39;GET&#39;,
        credentials: &#39;include&#39;,
      });
      csrfToken = getCSRFTokenFromCookie();
    }
    if (csrfToken) config.headers[&#39;X-CSRF-Token&#39;] = csrfToken;

    return config;
  },
  (error) =&gt; {
    return Promise.reject(error);
  },
);
</code></pre>
<h5 id="백엔드">백엔드</h5>
<pre><code class="language-typescript">// @/middleware/csrf.ts

import { NextFunction, Request, Response } from &#39;express&#39;;

export const csrfMiddleware = (req: Request, res: Response, next: NextFunction) =&gt; {
  const csrfTokenFromCookie = req.cookies.csrfToken;
  const csrfTokenFromHeader = req.headers[&#39;x-csrf-token&#39;];
  const csrfTokenMatch = csrfTokenFromCookie === csrfTokenFromHeader;

  if (!csrfTokenMatch || !csrfTokenFromCookie || !csrfTokenFromHeader) {
    res.status(403).json({ message: &#39;외부에서 조회할 수 없는 API입니다.&#39; });
    return;
  }

  return next();
};

</code></pre>
<h3 id="state-활용-oauth">State 활용 (OAuth)</h3>
<p>OAuth와 같이 Callback API를 제공하는 경우 CSRF를 막을 때 state를 사용할 수 있다.
먼저 OAuth의 절차를 간단히 설명하고 State로 CSRF 공격을 막는 방법을 설명하려 한다.</p>
<h4 id="oauth-절차">OAuth 절차</h4>
<p>OAuth 로그인을 시도하면 먼저 해당 OAuth 프로바이더의 로그인 페이지로 이동한다. 이때 로그인 페이지 URL에는 사용자가 로그인 후에 리다이렉트(호출)할 Callback API의 URI가 포함되어 있다.</p>
<p>그리고 사용자가 로그인을 하면 Callback API의 URI로 리다이렉트 되는데 이때 쿼리 파라미터로 code라는 값이 추가된다. 이 code는 사용자의 정보를 요청할 때 쓰는 accessToken을 발급받기 위한 코드이다. 이 액세스 토큰을 사용해 요청한 정보를 서버에 보내서 새로 계정을 추가하거나 기존 계정에 연결하게 되는 것이다.</p>
<h4 id="oauth-csrf-공격-시나리오">OAuth CSRF 공격 시나리오</h4>
<p>Callback API의 URI와 자신의 code는 노출되어 있기 때문에 공격자가 이를 수집해서 악성 사이트에 이 API를 호출하도록 설정해놓는다.
이때 만약 사용자가 해당 서비스에 로그인 된 상태에서 API를 호출하면 사용자의 계정과 공격자의 OAuth가 연결되는 것이다.</p>
<h4 id="state">State</h4>
<p>이러한 공격을 막기 위해 URI에 포함된 state를 통해 해당 Callback API URI가 서버에서 제공한 것인지 검증하는 방법을 쓴다.</p>
<p>State는 서버에서 발급한 값이 맞는지 검증할 수 있어야 한다. 그래서 나는 JWT를 활용했고 만료 시간을 5분으로 짧게 잡아서 노출되더라도 재사용할 수 없도록 했다.</p>
<pre><code class="language-typescript">
 oAuthCallback = async (req: Request, res: Response, next?: NextFunction) =&gt; {
    const { provider } = req.params;
    const { state } = req.query;

    let userType: LowercaseUserType = &#39;customer&#39;;

    if (!state) {
      return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
    }

    if (typeof state === &#39;string&#39;) {
      try {
        const decodedState = this.authService.decodeState(state);
        userType = decodedState.userType as LowercaseUserType;
      } catch (error) {
        console.error(&#39;상태 정보 디코딩 실패:&#39;, error);
        return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
      }
    }
</code></pre>
<pre><code class="language-typescript">
 generateState(data: { userType: string }) {
    const stateObj = {
      userType: data.userType,
    };
    const token = jwt.sign(stateObj, process.env.OAUTH_STATE_SECRET!, {
      expiresIn: &#39;5m&#39;,
    });
    return encodeURIComponent(token);
  }

  decodeState(state: string) {
    const decoded = jwt.verify(
      decodeURIComponent(state),
      process.env.OAUTH_STATE_SECRET!,
    ) as JwtPayload;
    return {
      userType: decoded.userType,
    };
  }
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[OAuth API 구현하기]]></title>
            <link>https://velog.io/@silver_hq/OAuth-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@silver_hq/OAuth-API-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Thu, 08 May 2025 08:52:48 GMT</pubDate>
            <description><![CDATA[<p>OAuth란 로그인/회원가입과 같은 권한 인증 작업을 구글,카카오,네이버 등과 같은 신뢰할 수 있는 대형 서비스가 대행해주는 시스템이다. 구글이나 네이버와 같이 OAuth 서비스에 로그인이 되어있는 상태라면 사용자가 직접 아이디,비밀번호 등을 입력하지 않고 로그인/회원가입을 할 수 있게 해준다.</p>
<h2 id="oauth-절차">OAuth 절차</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/72576dca-76d8-4037-b3cc-c8036736d415/image.png" alt=""></p>
<p>사용자가 OAuth 로그인 버튼을 클릭 
-&gt; 프론트엔드에서 백엔드에 OAuth url을 요청
-&gt; 백엔드가 OAuth url로 리다이렉트
-&gt; 사용자가 OAuth 서비스 페이지에서 로그인
-&gt; 백엔드의 callback API를 호출
-&gt; 백엔드가 OAuth에 AccessToken을 요청
-&gt; OAuth가 accessToken을 응답
-&gt; AccessToken을 사용해서 백엔드가 OAuth에 회원 정보를 요청
-&gt; OAuth가 회원 정보를 응답
-&gt; 백엔드에서 회원 정보를 가지고 회원가입 또는 로그인 처리(토큰을 발행하고 로그인 성공 후 이동할 페이지로 리다이렉트)</p>
<h2 id="구현">구현</h2>
<h3 id="passport">Passport</h3>
<p>Passport라는 라이브러리를 사용하면 위 절차에서 회원 정보를 받아오는 부분까지 처리해준다. 하지만 나는 전체 프로세스를 이해하기 위해 처음엔 Passport없이 구현해본 다음 Passport를 적용했다.</p>
<h3 id="oauth-url-리다이렉트-및-oauth-서비스에-로그인">OAuth url 리다이렉트 및 OAuth 서비스에 로그인</h3>
<p>먼저 사용자를 OAuth 로그인 Url로 리다이렉트 시켜야 한다.
OAuth 로그인 Url에는 각각 OAuth client id와 사용자가 로그인 한 다음 호출할 Callback API의 엔드포인트,state가 쿼리 파라미터로 입력되어야 한다. state에 대해선 밑에서 설명할 예정이다.</p>
<h4 id="authservice">AuthService</h4>
<pre><code class="language-typescript">
export default class AuthService {
  private SNS_AUTH_URLS = {
    google: `https://accounts.google.com/o/oauth2/auth?client_id=${process.env.GOOGLE_CLIENT_ID}&amp;redirect_uri=${process.env.GOOGLE_REDIRECT_URI}&amp;response_type=code&amp;scope=${this.scopes}&amp;access_type=offline&amp;prompt=consent`,
    kakao: `https://kauth.kakao.com/oauth/authorize?client_id=${process.env.KAKAO_CLIENT_ID}&amp;redirect_uri=${process.env.KAKAO_REDIRECT_URI}&amp;response_type=code`,
    naver: `https://nid.naver.com/oauth2.0/authorize?client_id=${process.env.NAVER_CLIENT_ID}&amp;redirect_uri=${process.env.NAVER_REDIRECT_URI}&amp;response_type=code`,
  };

  constructor(private userRepository:UserRepository){}

  getSnsLoginUrl(provider: keyof typeof this.SNS_AUTH_URLS, state: string) {
    if (!this.SNS_AUTH_URLS[provider]) {
      throw new Error(&#39;지원하지 않는 로그인 제공자입니다.&#39;);
    }

    return this.SNS_AUTH_URLS[provider]+`&amp;state=${state}`;
  };

generateState(data: { userType: string }) {
    const stateObj = {
      userType: data.userType,
    };
    const token = jwt.sign(stateObj, process.env.OAUTH_STATE_SECRET!, {
      expiresIn: &#39;5m&#39;,
    });
    return encodeURIComponent(token);
  }

  decodeState(state: string) {
    const decoded = jwt.verify(
      decodeURIComponent(state),
      process.env.OAUTH_STATE_SECRET!,
    ) as JwtPayload;
    return {
      userType: decoded.userType,
    };
  }
}</code></pre>
<h4 id="authcontroller">AuthController</h4>
<pre><code class="language-typescript">
export default class AuthController {
    constructor(private authService:AuthService){}

        snsLogin = async (req: Request, res: Response) =&gt; {
    const { provider } = req.params; // 로그인 제공자 (google, kakao, naver)
    const { userType } = req.query;
    if (!userType || (userType !== &#39;customer&#39; &amp;&amp; userType !== &#39;mover&#39;)) {
      return res.status(400).json({ message: &#39;유효하지 않은 사용자 타입입니다.&#39; });
    }

    const state = this.authService.generateState({ userType: userType as string });
    const loginUrl = this.authService.getSnsLoginUrl(provider as OauthTypes, state);
    return res.redirect(loginUrl);
  };
}
</code></pre>
<h3 id="callback-api로-리다이렉트--accesstoken-요청-및-응답">Callback API로 리다이렉트 &amp; AccessToken 요청 및 응답</h3>
<p>OAuth 페이지에서 로그인을 성공했다면 Url에 포함된 redirectUri(callback API)로 이동(호출)하게 되는데 이때 Code와 앞에서 OAuth login url에 포함시켰던 State가 쿼리 파라미터로 입력된다.</p>
<h4 id="code">code</h4>
<p>여기서 Code는 OAuth 서비스에게 사용자의 AccessToken을 요청할 때 사용된다.</p>
<h4 id="state">state</h4>
<p>Callback API URI의 쿼리 파라미터에 state가 포함되는데 이 state는 CSRF 공격을 막기 위한 목적으로 사용하는 것이기 때문에 암호화하거나 해당 서버에서 발급한 것인지 여부를 확인할 수 있어야 한다. 나는 JWT를 사용했다.</p>
<p>*CSRF는 쿠키가 자동으로 전송되는 시스템을 악용하는 공격이기 때문에 만약 쿠키를 사용하지 않고 요청 header에 포함시키는 방식을 쓰고 있다면 걱정하지 않아도 된다.</p>
<p>참고 : <a href="https://velog.io/@silver_hq/CSRFCross-Site-Request-Forgery%EA%B3%B5%EA%B2%A9%EA%B3%BC-%EB%B0%A9%EC%96%B4">CSRF(Cross-Site Request Forgery)공격과 방어</a></p>
<h4 id="callback-api">Callback API</h4>
<p>먼저 state를 검증한 다음 이상이 없다면 clientId,clientSecret,code 등 필요한 값들을 쿼리 파라미터에 추가해서 accessToken을 요청한다.</p>
<h4 id="액세스-토큰-주의사항">액세스 토큰 주의사항</h4>
<p>OAuth 프로바이더가 제공하는 AccessToken은 OAuth API를 호출하는 용도이다. 하지만 이를 서비스에서 프론트와 백엔드 간에 인증을 위해 사용하는 경우가 많다고 한다.</p>
<p>하지만 이는 잘못된 방식이다. 해당 토큰은 내 서버에서 발행한 것이 아니기 때문에 토큰을 검증할 때 JWT Secret이 일치하지 않아 검증에 실패하기 때문이다.</p>
<p>만약 해당 AccessToken을 사용해서 인증을 하고 있었다면 JWT 검증이 제대로 이루어지지 않고 있었다는 뜻으로 토큰 검증 과정을 확인해봐야 한다.</p>
<h3 id="회원-정보-요청-및-응답--로그인회원가입-처리">회원 정보 요청 및 응답 / 로그인,회원가입 처리</h3>
<p>앞에서 전달받은 AccessToken을 header의 Authorization에 포함시켜서 OAuth provider에게 회원 정보를 요청한다. 그리고 전달 받은 회원 정보를 통해 회원가입 또는 로그인을 처리한다. 나는 email을 id로 사용했기 때문에 전달 받은 email로 DB를 조회했다.</p>
<p>조회했을 때 나올 수 있는 결과는 다음과 같다.
1.해당 OAuth로 이미 가입된 경우
=&gt;해당 회원의 정보를 바탕으로 토큰을 발행하고 응답하면 끝이난다.
2.동일한 email로 OAuth가 아닌 해당 앱에 직접 가입한 경우
=&gt;DB의 소셜 로그인 테이블에 해당 소셜 로그인 정보를 추가한다.
3.OAuth,직접 가입 모두 하지 않은 경우
=&gt;해당 이메일로 회원을 추가한다.</p>
<p>OAuth 관련 기능은 아니기 때문에 회원을 생성하거나 찾는 예시 코드는 포함하지 않았다.
Service 레이어 코드는 handleNaverCallback만 포함했다. 구글,카카오의 경우도 비슷한 방식으로 정보를 요청하면 된다.</p>
<h4 id="authservicehandlenavercallback">AuthService.handleNaverCallback</h4>
<pre><code class="language-typescript">
async handleNaverCallback(code: string, state:string, provider: string, type: LowercaseUserType): Promise&lt;any&gt; {
    try {
      // 네이버 토큰 요청
      const tokenResponse = await axios.post(
        &#39;https://nid.naver.com/oauth2.0/token&#39;,
        new URLSearchParams({
          grant_type: &#39;authorization_code&#39;,
          client_id: process.env.NAVER_CLIENT_ID as string,
          client_secret: process.env.NAVER_CLIENT_SECRET as string,
          code,
        }).toString(),
        {
          headers: {
            &#39;Content-Type&#39;: &#39;application/x-www-form-urlencoded;charset=utf-8&#39;,
          },
        },
      );

      const { access_token } = tokenResponse.data;

      const userInfoResponse = await axios.get(&#39;https://openapi.naver.com/v1/nid/me&#39;, {
        headers: {
          Authorization: `Bearer ${access_token}`,
        },
      });

      const naverUserInfo = userInfoResponse.data.response;
      const userInfo = {
        email: naverUserInfo.email,
        phoneNumber: naverUserInfo.mobile,
        name: naverUserInfo.name,
        provider,
        providerId: naverUserInfo.id,
      };
// findOrCreateUser 메서드는 액세스 토큰과 리프레쉬 토큰, 사용자 정보를 리턴한다.
      const response = await this.findOrCreateUser(userInfo, type);
      return response;
    } catch (error: any) {
      console.error(&#39;Naver OAuth 오류&#39;, error);
      if (axios.isAxiosError(error) &amp;&amp; error.response) {
        console.error(&#39;네이버 응답 오류:&#39;, error.response.data);
      }
      if (error.message === &#39;wrong type&#39;) throw error;
      throw new Error(&#39;Naver 로그인 중 오류가 발생했습니다.&#39;);
    }
  }
</code></pre>
<h4 id="authcontrolleroauthcallback">AuthController.oAuthCallback</h4>
<pre><code class="language-typescript">
oAuthCallback = async (req: Request, res: Response) =&gt; {
    const { provider } = req.params;
    const { code, state } = req.query;

    if (!code || typeof code !== &#39;string&#39;) {
      return res.status(400).json({ message: &#39;인증 코드가 없습니다.&#39; });
    }

    let userType: LowercaseUserType = &#39;customer&#39;;

      if (state &amp;&amp; typeof state === &#39;string&#39;) {
      try {
        const decodedState = this.authService.decodeState(state);
        userType = decodedState.userType as LowercaseUserType;
      } catch (error) {
        console.error(&#39;상태 정보 디코딩 실패:&#39;, error);
        return res.status(400).json({ message: &#39;유효하지 않은 상태 정보입니다.&#39; });
      }
    }

    try {
      let token;

      switch (provider) {
        case &#39;naver&#39;: {
          const naverResult = await this.authService.handleNaverCallback(
            code,
            state,
            provider,
            userType
          );
          token = naverResult.tokens;
          break;
        }
        // ... 그 외 OAuth callback을 처리
        default:
          return res.status(400).json({ message: &#39;지원하지 않는 로그인 제공자입니다.&#39; });
      }
      const { accessToken, refreshToken } = token;

      this.setAccessToken(res, accessToken);
      this.setRefreshToken(res, refreshToken);
      res.redirect(this.REDIRECT_URL_ON_SUCCESS[userType]);
    } catch (error: any) {
      if (error.message === &#39;wrong type&#39;)
        return res.redirect(`${this.REDIRECT_URL_ON_FAIL[userType]}${this.FAIL_QUERY[userType]}`);
      return res.status(500).json({ message: &#39;로그인 중 오류 발생&#39; });
    }
  };
</code></pre>
<h2 id="passport-적용">Passport 적용</h2>
<p>앞서서 설명한 것처럼 Passport 라이브러리를 사용하면 로그인 URL로 리다이렉트하는 작업부터 사용자의 정보를 받아오는 부분까지 대신 처리해준다.</p>
<p>passport 관련 라이브러리 설치
=&gt;app.ts에 passport 초기화
=&gt;각 OAuth 별 Strategy 설정
=&gt;OAuth API에 passport 적용
=&gt;OAuth Callback API에 passport 적용</p>
<h3 id="관련-라이브러리-설치">관련 라이브러리 설치</h3>
<pre><code class="language-bash">npm install passport passport-google-oauth20 passport-naver passport-kakao</code></pre>
<p>타입스크립트를 쓰고 있다면 @types도 설치해줘야 한다.</p>
<pre><code class="language-bash">npm install --D @types/passport @types/passport-google-oauth20 @types/passport-naver @types/passport-kakao</code></pre>
<h3 id="passport-초기화--oauth-strategy-설정">passport 초기화 &amp; OAuth Strategy 설정</h3>
<p>passport 초기화는 서버 애플리케이션을 초기화하는 파일(ex:app.ts)에서 초기화 해준다.</p>
<p>그리고 각 OAuth별로 Strategy를 설정해준다. Strategy의 첫번째 인자로는 OAuth 인증에 필요한 변수들을 객체 형태로 입력해준다. 그리고 두번째 인자에는 passport가 전달해주는 데이터들을 처리하는 콜백함수를 입력한다. </p>
<p>(accessToken,refreshToken,profile,done)이 파라미터로 주어진다.
앞에 두 토큰은 OAuth의 api를 호출할 때 사용할 토큰이다. 
그리고 Profile은 사용자의 정보가 담겨있다.
done은 passport.authenticate에 값을 넘길 때 사용한다.</p>
<p>이미 사용자의 프로필을 요청해서 passport가 가져오지만 여기에 빠진 정보가 있을 수 있기 때문에 전달받은 토큰을 활용해서 OAuth에 정보를 요청한 다음 done을 통해 전달하면 된다.
done에는 첫번째로 error를 두번째로 사용자 정보를 입력한다.</p>
<h4 id="appts">app.ts</h4>
<pre><code class="language-typescript">const app = express();

app.use(passport.initialize());
setupNaverStrategy();
// 그 외 strategy도 설정</code></pre>
<h4 id="naverstrategyts">naver.strategy.ts</h4>
<pre><code class="language-typescript">
import axios from &#39;axios&#39;;
import passport from &#39;passport&#39;;
import { Strategy as NaverStrategy } from &#39;passport-naver&#39;;

export const setupNaverStrategy = () =&gt; {
  passport.use(
    &#39;naver&#39;,
    new NaverStrategy(
      {
        clientID: process.env.NAVER_CLIENT_ID!,
        clientSecret: process.env.NAVER_CLIENT_SECRET!,
        callbackURL: process.env.NAVER_REDIRECT_URI!,
      },
      async (accessToken, refreshToken, profile, done) =&gt; {
        try {
          const response = await axios.get(&#39;https://openapi.naver.com/v1/nid/me&#39;, {
            headers: {
              Authorization: `Bearer ${accessToken}`,
            },
          });
          const naverProfile = response.data.response;

          const userInfo = {
            provider: &#39;naver&#39;,
            providerId: naverProfile.id,
            email: naverProfile.email,
            name: naverProfile.name,
            phoneNumber: naverProfile.mobile,
          };

          return done(null, userInfo);
        } catch (error) {
          return done(error);
        }
      },
    ),
  );
};

</code></pre>
<h3 id="oauth-api에-passport-적용">OAuth API에 passport 적용</h3>
<p>passport.authenticate에 첫번째 인자로 provider(&#39;kakao&#39;,&#39;naver&#39;,&#39;google&#39; 등)을 입력하고 두번째 인자로 URL에 포함시킬 쿼리파라미터를 입력한다. 그러면 미들웨어를 반환하는데 뒤에 (req,res,next)를 입력해서 해당 미들웨어를 실행시킨다.</p>
<pre><code class="language-typescript">
  snsLogin = async (req: Request, res: Response, next?: NextFunction) =&gt; {
    const { provider } = req.params; // 로그인 제공자 (google, kakao, naver)
    const { userType } = req.query;
    if (!userType || (userType !== &#39;customer&#39; &amp;&amp; userType !== &#39;mover&#39;)) {
      return res.status(400).json({ message: &#39;유효하지 않은 사용자 타입입니다.&#39; });
    }

    const state = this.authService.generateState({ userType: userType as string });
    const scope =
      provider === &#39;google&#39; ? [this.GOOGLE_SCOPE, &#39;email&#39;, &#39;profile&#39;] : [&#39;email&#39;, &#39;profile&#39;];

    passport.authenticate(provider, { state, scope })(req, res, next);
  };
</code></pre>
<h3 id="callback-api에-passport-적용">Callback API에 passport 적용</h3>
<p>Callback API에서 passport.authenticate를 통해 Strategy에서 전달받은 데이터를 사용해 처리한다.</p>
<p>이 과정에서 수정한 점이 있었는데 만약 중간에 문제가 발생했을 때 res.status.json으로 응답을 하면 현재 서버의 Callback API로 리다이렉트된 상태이기 때문에 사용자가 raw JSON 텍스트를 보게 된다. 
그렇기 때문에 json으로 응답하는 것이 아니라 프론트엔드의 URL로 리다이렉트 시켜야 한다.
프론트엔드에서 쿼리 파라미터를 통해 에러 메시지를 표시하는 기능을 구현해놨기 때문에 쿼리 파라미터를 적용해서 리다이렉트 시키도록 설정했다.</p>
<h3 id="callback-apioauth-최초-호출-구분">Callback API,OAuth 최초 호출 구분</h3>
<p>Callback API와 OAuth를 최초 호출할 때 모두 passport.authenticate를 사용하는데 passport는 이 두가지를 어떻게 구분할까?
OAuth 인증 과정에서 최초 사용자가 로그인을 하면 콜백 API에 쿼리 파라미터로 code가 입력되기 때문에
passport는 query파라미터에 code의 존재 여부를 통해 최초 OAuth 호출인지 Callback API 호출인지 구분한다.</p>
<pre><code class="language-typescript">
oAuthCallback = async (req: Request, res: Response, next?: NextFunction) =&gt; {
    const { provider } = req.params;
    const { state } = req.query;

    let userType: LowercaseUserType = &#39;customer&#39;;

    if (!state) {
      return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
    }

    if (typeof state === &#39;string&#39;) {
      try {
        const decodedState = this.authService.decodeState(state);
        userType = decodedState.userType as LowercaseUserType;
      } catch (error) {
        console.error(&#39;상태 정보 디코딩 실패:&#39;, error);
        return this.redirectToError(userType, this.FAIL_QUERY.invalidRequest, res);
      }
    }

    passport.authenticate(provider, { session: false }, async (error: any, userInfo: any) =&gt; {
      const oppositeUserType = userType === &#39;customer&#39; ? &#39;mover&#39; : &#39;customer&#39;;
      if (error || !userInfo) {
        return this.redirectToError(userType, this.FAIL_QUERY.common, res);
      }
      try {
        const response = await this.authService.findOrCreateUser(userInfo, userType);
        if (!response) return this.redirectToError(userType, this.FAIL_QUERY.common, res);
        const { accessToken, refreshToken } = response.tokens;

        this.setAccessToken(res, accessToken);
        this.setRefreshToken(res, refreshToken);
        return res.redirect(this.REDIRECT_URL_ON_SUCCESS[userType]);
      } catch (error: any) {
        if (error.message === &#39;wrong type&#39;) {
          return this.redirectToError(oppositeUserType, this.FAIL_QUERY[userType], res);
        }
        return this.redirectToError(userType, this.FAIL_QUERY.common, res);
      }
    })(req, res, next);
  };

</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[새롭게 알게 된 개념들(마이크로서비스 아키텍처,메시지 큐,쿠버네티스)]]></title>
            <link>https://velog.io/@silver_hq/%EC%83%88%EB%A1%AD%EA%B2%8C-%EC%95%8C%EA%B2%8C-%EB%90%9C-%EA%B0%9C%EB%85%90%EB%93%A4%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A9%94%EC%8B%9C%EC%A7%80-%ED%81%90%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4</link>
            <guid>https://velog.io/@silver_hq/%EC%83%88%EB%A1%AD%EA%B2%8C-%EC%95%8C%EA%B2%8C-%EB%90%9C-%EA%B0%9C%EB%85%90%EB%93%A4%EB%A7%88%EC%9D%B4%ED%81%AC%EB%A1%9C%EC%84%9C%EB%B9%84%EC%8A%A4-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98%EB%A9%94%EC%8B%9C%EC%A7%80-%ED%81%90%EC%BF%A0%EB%B2%84%EB%84%A4%ED%8B%B0%EC%8A%A4</guid>
            <pubDate>Wed, 07 May 2025 03:56:37 GMT</pubDate>
            <description><![CDATA[<p>오늘은 <a href="https://www.youtube.com/watch?v=NzlFLoALqYs">개발 면접 관련 영상</a>을 보다가 새롭게 알게 된 개념과 기술이 있어서 정리해보려 한다. </p>
<h2 id="마이크로서비스-아키텍처">마이크로서비스 아키텍처</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/3570408b-8d08-47f0-b2fc-09f85324c4ad/image.png" alt="">
아키텍처 구조를 설명하는 부분에서 나온 내용이었는데 처음 이 구조를 봤을 때 admin 서버군과 worker서버군이라 설명하는데 왜 서버가 여러 개인지 궁금했다.</p>
<p>이와 관련해서 GPT에 질문을 하니 이게 마이크로서비스 아키텍처라는 것을 알려줬다.</p>
<blockquote>
<p>마이크로서비스 아키텍처(Microservices Architecture)**는 하나의 애플리케이션을 작고 독립적인 서비스들로 분리해서 개발, 배포, 확장하는 아키텍처 스타일이에요. 각 서비스는 자신만의 데이터베이스와 비즈니스 로직을 갖고, 독립적으로 배포 및 확장이 가능합니다.</p>
</blockquote>
<p>이와 반대로 하나의 애플리케이션에 하나의 서비스를 운영하는 방식을 모놀리식(Monolithic) 아키텍처라 한다.</p>
<p>이처럼 서비스를 분리하는 이유는 다음과 같다.</p>
<h3 id="1부하-분산-및-독립적-스케일링">1.부하 분산 및 독립적 스케일링</h3>
<p>하나의 서비스에서 담당하던 작업을 도메인 별로 분리하기 때문에 부하를 분산시킬 수 있다. 또 자주 호출되거나 많은 리소스를 사용하는 서비스의 경우 더 많은 인스턴스를 구동시키도록 설정해서 효율적으로 서버를 운영할 수 있다.</p>
<h3 id="2기술-스택-최적화">2.기술 스택 최적화</h3>
<p>도메인 마다 가장 적합한 기술을 선택할 수 있다는 장점이 있다. 예를 들어 트랜잭션의 안정성이 중요한 금융 처리에는 Java + Spring으로 개발한 서버를 사용하고 데이터 처리와 관련된 도메인은 Python으로 개발한 서버를 사용하는 것과 같이 서버가 도메인 별로 분리 돼있기 때문에 각 도메인에 맞는 기술 스택을 활용할 수 있다.</p>
<h3 id="3장애-격리">3.장애 격리</h3>
<p>만약 하나의 도메인에 트래픽이 몰려서 해당 서비스가 다운되더라도 다른 서비스에는 영향을 주지 않는다. 모놀리식 아키텍처에선 모든 도메인이 하나의 서비스에 몰려있기 때문에 만약 다운되면 다른 도메인도 사용이 불가능하다.</p>
<p>쇼핑 플랫폼을 예로 들면 블랙프라이데이 세일로 인해 상품 검색 서비스에 트래픽이 몰리더라도 장바구니,결제 서비스 등은 분리되어 있기 때문에 정상적으로 작동하는 것이다. </p>
<p>마이크로서비스 아키텍처에 관한 글도 참고하면 도움이 될 것 같다.
<a href="https://www.atlassian.com/ko/microservices/cloud-computing/advantages-of-microservices">https://www.atlassian.com/ko/microservices/cloud-computing/advantages-of-microservices</a></p>
<h2 id="메시지-큐">메시지 큐</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/461002c7-d3c1-47e4-a8ce-9d7463718ad5/image.png" alt="">
이렇게 분리된 서버 간에 통신을 위해 RabbitMQ를 사용한다고 했다.
이 RabbitMQ는 메시지 큐 시스템 중 하나인데 메시지 큐의 정의는 다음과 같다. </p>
<blockquote>
<p>메시지 큐는 소프트웨어 컴포넌트 간에 비동기 통신을 가능하게 하는 중요한 미들웨어입니다. 마이크로서비스 아키텍처에서는 서비스 간 통신의 핵심 요소로 활용됩니다.</p>
</blockquote>
<p>간단히 말하면 메시지 큐는 발행자(Publisher)와 구독자(Subscriber) 사이의 중개자 역할을 한다. 만약 서버 A가 특정한 요청을 받으면 이 요청에 대해 메시지 큐에 이러한 요청이 있었다고 메시지를 보낸다. 그러면 메시지 큐는 이를 전달 받아서 저장하고 이 메시지를 구독하고 있는 다른 서버들에게 전달한다. 그리고 전달 받은 서버들은 이 메시지에 맞는 작업을 처리한다.</p>
<p>메시지 큐는 말그대로 메시지를 저장하는 큐(대기열)역할을 한다. 
발행자가 보낸 메시지는 큐에 저장되어 구독자가 수신하고 처리할 수 있을 때까지 대기한다. 
그리고 구독자가 준비되면 메시지를 전달하고 메시지 처리 완료를 확인한 다음 메시지를 큐에서 제거한다.</p>
<p>메시지 큐는 비동기 통신이기 때문에 발행자와 구독자가 동시에 활성화될 필요가 없고 발행자는 메시지를 보낸 다음 응답을 기다릴 필요 없이 다른 작업을 수행할 수 있다.</p>
<p>메시지 큐 시스템으로 Kafka,Redis Pub/Sub,Amazon SQS,Google Cloud Pub/Sub,RabitMQ 등이 있다.</p>
<h2 id="쿠버네티스">쿠버네티스</h2>
<p>평소에 쿠버네티스라는 이름은 자주 들었지만 어떤 목적으로 사용하는지는 잘 모르고 있었다. 마이크로서비스 아키텍처를 공부하던 중 알게되어 정리해봤다.(쿠버네티스가 마이크로서비스 아키텍처만을 위한 것은 아니다.)</p>
<p>쿠버네티스는 컨테이너를 관리하는 컨테이너 오케스트레이션 플랫폼이다. 
여기서 컨테이너는 도커와 같이 애플리케이션과 실행에 필요한 모든 종속성을 함께 패키징한 경량화된 독립적인 실행환경을 말한다.</p>
<p>이러한 컨테이너에 서버를 설치하고 실행하도록 설정한 다음 이 컨테이너를 몇개를 어떤 상태로 유지할지 설정하면 쿠버네티스가 그에 맞게 상태를 유지하도록 관리하는 것이다.</p>
<p>만약 컨테이너가 오류로 중단 또는 비정상적으로 종료될 경우 이를 감지해 새 컨테이너를 시작해서 설정한 수량을 유지한다.</p>
<p>또 트래픽이나 부하에 따라서 컨테이너의 수를 자동으로 조절하거나 수동으로 조절할 수 있다. 그리고 여러 컨테이너에 트래픽을 분산시키는 로드 밸런싱 기능도 있다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 미들웨어로 권한 확인하기]]></title>
            <link>https://velog.io/@silver_hq/Next.js-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4%EB%A1%9C-%EA%B6%8C%ED%95%9C-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@silver_hq/Next.js-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4%EB%A1%9C-%EA%B6%8C%ED%95%9C-%ED%99%95%EC%9D%B8%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 06 May 2025 12:53:41 GMT</pubDate>
            <description><![CDATA[<h2 id="nextjs-미들웨어">Next.js 미들웨어</h2>
<p>Next.js 미들웨어는 특정 페이지로 이동하거나 API Route를 호출하는 것과 같이 클라이언트에서 next.js 서버로 요청할 때 중간에 실행되는 함수다.</p>
<h2 id="권한-확인">권한 확인</h2>
<p>나는 특정 페이지에 접근할 때 로그인 여부를 체크할 때 미들웨어를 활용했다.먼저 회원 인증 시스템은 로그인 시 쿠키에 JWT 토큰을 저장하는 방식으로 구현했다. 먼저 로그인이 되어 있다면 브라우저의 쿠키에 토큰이 담겨있는 상태이고 요청할 때 쿠키가 포함된다. 그러므로 로그인 상태를 확인하려면 요청에 담긴 쿠키를 가져와서 토큰 값을 확인하면 된다.</p>
<p>절차는 다음과 같다.
<img src="https://velog.velcdn.com/images/silver_hq/post/b268a259-6eac-48fc-84ff-4b69912ac255/image.png" alt=""></p>
<h3 id="요청에서-토큰-추출-및-검증">요청에서 토큰 추출 및 검증</h3>
<p>요청에 담긴 쿠키는 <code>request.cookies</code>를 통해 가져올 수 있다. 그리고 쿠키에 있는 accessToken을 먼저 검증한다. 이때 Next.js의 미들웨어는 엣지 런타임에서 실행되고 엣지 런타임은 node.js의 기능 사용이 제한되기 때문에 Jwt 검증 라이브러리로 jose를 사용해야 한다.</p>
<p>또 미들웨어에서 NEXT_PUBLIC이 아닌 환경변수를 사용하기 위해선 nextconfig에서 별도 설정이 필요하다. 해당 내용은 아래 글에 정리해 두었다.</p>
<blockquote>
<p><a href="https://velog.io/@silver_hq/Next.js-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4%EC%97%90%EC%84%9C-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0">Next.js 미들웨어에서 환경변수 사용하기</a></p>
</blockquote>
<h3 id="액세스-토큰-갱신-시도-및-헤더에-액세스-토큰-담아-응답">액세스 토큰 갱신 시도 및 헤더에 액세스 토큰 담아 응답</h3>
<p>토큰을 갱신할 땐 fetch 함수에 <code>Cookie:`refreshToken=${refreshToken}`</code>를 입력해 headers에 쿠키를 설정해줘야 한다.
그리고 만약 갱신에 성공했다면 갱신 API 호출에 주어진 응답에서 <code>headers.getSetCookies()</code>를 통해 쿠키를 가져오고 NextResponse.headers에 넣은 다음 response를 리턴하면 된다.</p>
<h3 id="실패-시-토큰-제거-후-로그인-페이지로-리다이렉트">실패 시 토큰 제거 후 로그인 페이지로 리다이렉트</h3>
<p>만약 토큰 갱신에 실패했다면 refreshToken도 만료됐을 수 있다. 이 때 쿠키에 아직 액세스토큰,리프레쉬토큰이 남아있다면 이를 삭제하고 로그인 페이지로 이동시킨다. <code>const response = NextResponse.redirect(loginUrl)</code>를 통해 응답에 리다이렉트하도록 설정하고 <code>response.cookies.delete()</code>를 통해 액세스 토큰과 리프레쉬 토큰을 제거하고 response를 리턴한다.</p>
<h3 id="코드">코드</h3>
<pre><code class="language-typescript">import { NextResponse } from &#39;next/server&#39;;
import type { NextRequest } from &#39;next/server&#39;;
import { jwtVerify } from &#39;jose&#39;;

const refreshAccessToken = async(refreshToken:string)=&gt; {
    try{
        const response = await fetch(
        `${process.env.NEXT_PUBLIC_API_URL}/auth/refresh-token`,
        {
          method: &#39;POST&#39;,
          headers: {
            Cookie: `refreshToken=${refreshToken}`,
          },
        },
      )
      return response;
        } catch(error) {
        console.error(&#39;액세스 토큰 갱신 실패&#39;, error);
        return null;
        }
}

export default async function middleware(request:NextRequest) {
  const ENCODED_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
  const accessToken = request.cookies.get(&#39;accessToken&#39;)?.value;
  const refreshToken = request.cookies.get(&#39;refreshToken&#39;)?.value;
  const loginUrl = new URL(&#39;/sign-in&#39;,request.url);

  if (!accessToken &amp;&amp; !refreshToken) return NextResponse.redirect(loginUrl);

  try {
    await jwtVerify(accessToken,ENCODED_SECRET);
    return NextResponse.next();
    }catch(err) {
      if (refreshToken) {
        const refreshedTokenResponse = await refreshAccessToken(refreshToken); 
        if (!refreshedTokenResponse) {
              const response = NextResponse.redirect(loginUrl);
              response.cookies.delete(&#39;accessToken&#39;);
              response.cookies.delete(&#39;refreshToken&#39;);
            return response;
        }
        if (refreshedTokenResponse.ok) {
            const response = NextResponse.next();
            const setCookieHeader = refreshedTokenResponse.headers.getSetCookies();
            setCoockieHeader.split(&#39;,&#39;).forEach((cookie)=&gt; response.headers.append(&#39;Set-Cookie&#39;,cookie);
            return response;
        }
      }
     return NextResponse.redirect(loginUrl); 
    }</code></pre>
<h2 id="주의할-점">주의할 점</h2>
<h3 id="x-middleware-subrequest-헤더">x-middleware-subrequest 헤더</h3>
<p>Next.js 미들웨어에 보안 취약점이 발견됐다. <a href="https://github.com/vercel/next.js/security/advisories/GHSA-f82v-jwr5-mffw">CVE-2025-29927</a> 
x-middleware-subrequest 헤더가 원인인데 이 헤더는 원래 미들웨어의 재귀 호출을 방지하기 위한 헤더이다. 하지만 해당 헤더를 외부에서 조작해 요청에 포함시키면 Next.js가 해당 요청을 NextResponse.redirect(),rewrite() 등을 통해 발생하는 내부 서브 요청으로 오인해 미들웨어를 실행하지 않게 된다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/54cdd9da-8d0d-4dc4-96a2-09980e5d23be/image.png" alt="">
크롬 익스텐션인 Modheader를 설치하고 여기에 x-middleware-subrequest 헤더에 <code>src/middleware:</code>를 5번 입력하고 미들웨어가 작동해야되는 페이지로 이동하게 되면 미들웨어가 작동하지 않는 것을 확인할 수 있다.</p>
<h3 id="해결-방법">해결 방법</h3>
<p>해당 문제는 버전이 업데이트되면서 x-middleware-subrequest 헤더가 클라이언트 요청에서 오면 무시하도록 수정되었다. 수정된 버전은 다음과 같다.</p>
<p>12.x : 12.3.5
13.x : 13.5.9
14.x : 14.2.25
15.x : 15.2.3</p>
<p>만약 Vercel이나 Netlify를 통해 배포하고 있거나 middleware를 사용하지 않고 있다면 문제가 되지 않는다.
하지만 만약 next start 명령어와 output: &#39;standalone&#39; 설정을 사용하는 자체 호스팅 환경이라면 본인의 버전을 확인하고 반드시 업데이트해야 한다.</p>
<p>나는 AWS Amplify를 통해 배포하고 있는데 직접 확인해보니 해당 헤더를 포함시켰을 때 미들웨어가 작동하지 않았다. 이후 버전을 업데이트하고 새로 배포했고 헤더를 포함시켜도 미들웨어가 정상적으로 작동하는 것을 확인했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드잇 스프린트 풀스택 3기 회고]]></title>
            <link>https://velog.io/@silver_hq/%EC%BD%94%EB%93%9C%EC%9E%87-%EC%8A%A4%ED%94%84%EB%A6%B0%ED%8A%B8-%ED%92%80%EC%8A%A4%ED%83%9D-3%EA%B8%B0-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@silver_hq/%EC%BD%94%EB%93%9C%EC%9E%87-%EC%8A%A4%ED%94%84%EB%A6%B0%ED%8A%B8-%ED%92%80%EC%8A%A4%ED%83%9D-3%EA%B8%B0-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Wed, 23 Apr 2025 13:11:35 GMT</pubDate>
            <description><![CDATA[<p>24년 9월 5일부터 25년 4월 11일까지 진행한 스프린트 풀스택 과정이 드디어 끝이 나서 회고 글을 작성해보려 한다.</p>
<h2 id="계기">계기</h2>
<p>나는 1년 반 정도 프론트엔드를 독학했다. 개인 프로젝트를 하나 만들고 이를 토대로 이력서를 작성해서 지원을 해봤지만 결과는 좋지 못했다.</p>
<p>다른 방법을 찾아보다가 부트캠프에서 취업 관련해서 도움을 받았다는 지인의 얘기를 듣고 부트캠프를 참여 해보기로 결정했다. 이미 프론트엔드는 경험이 있었고 백엔드도 할 줄 아는 것이 경쟁력이 있지 않을까 하는 생각에 풀스택 과정을 지원하게 됐다.</p>
<h2 id="커리큘럼-및-진행-방식">커리큘럼 및 진행 방식</h2>
<p>풀스택 과정은 강의+실습 / 12개의 스프린트 미션 / 3번의 팀프로젝트로 이루어져 있다.
미션은 강의에 나온 내용들을 활용해 요구사항들을 구현해서 제출하고 멘토님이 리뷰를 남겨주시는 방식이다.
그리고 팀 프로젝트는 3번 모두 코드잇 측에서 준비한 디자인과 기획을 토대로 진행했다.</p>
<h3 id="스프린트-미션">스프린트 미션</h3>
<p>나는 스프린트 미션을 진행하면서 두가지를 지키려 노력했다.</p>
<p>1.정해진 기한 내에 미션을 완성해서 제출할 것
2.멘토님이 주신 피드백을 반드시 반영할 것</p>
<p>첫번째는 부트캠프가 끝나고 되돌아 봤을 때 후회하지 그러기 위해선 일단 마감 기한이 있고 제출 여부를 통해 결과를 알 수 있는 미션을 빠지지 않고 제출하는 것이 중요하다 생각했다. </p>
<p>그렇게 12개의 미션 중 1개의 미션을 제외하고 마감일 안에 모두 완성하여 제출할 수 있었다. (1개의 미션은 개인 사정이 있어서 마감일 이후에 제출하게 된 것이 마음에 많이 걸렸다.)</p>
<p>두번째는 미션을 완료하는 것 만큼이나 멘토님의 피드백을 반영하는 것 역시 중요하다고 생각했는데, 주어진 피드백을 잘 반영하는 것이 실제 업무를 하는데 있어서 가장 중요한 태도 중 하나라고 생각했기 때문이다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/2da79c7b-5920-49c1-b315-352cdbc3e6bc/image.png" alt="">
덕분에 동영 멘토님에게 공개 칭찬을 받을 수 있었다. 리뷰를 반영하려 노력했던 점을 잘 알아주신 것 같아 기분이 좋았다.</p>
<h3 id="팀-프로젝트">팀 프로젝트</h3>
<p>기존에 협업 경험이 없었기 때문에 부트캠프에서 크게 기대했던 부분 중 하나가 팀 프로젝트였다. </p>
<h4 id="프로젝트-전체-흐름-파악">프로젝트 전체 흐름 파악</h4>
<p>총 3개의 프로젝트를 진행했는데 두번째 프로젝트에서 기억에 남는 부분은 팀원 중 한 분이 본인이 맡은 파트에서 기획적으로 이해가 안되는 부분이 있었는데 내가 이해한 부분을 설명해줘서 도움을 준 적이 있다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/65f88f19-24dc-47ea-9e36-5e35c2c06413/image.png" alt=""></p>
<p>나는 공통 컴포넌트 작업을 했기 때문에 모든 페이지를 훑어보면서 프로젝트의 전체적인 흐름을 파악하고 있었는데 이러한 점 덕분에 팀원에게 도움을 줄 수 있었던 것 같다. </p>
<p>그 후 세번째 프로젝트에선 내가 팀장을 맡았는데 이번엔 팀원 모두가 프로젝트의 기획을 파악하는데 중점을 뒀고 이를 위해 1주일이라는 시간 동안 기획을 파악하고 함께 공유하는 시간을 가졌다.</p>
<p>모든 팀원이 프로젝트의 전체 흐름을 파악하는 것이 중요하다고 생각한 이유는 다음과 같다.</p>
<p>내가 생각했을 때 프로젝트가 분업이 아닌 협업이 되기 위해선 작업하는 과정에서 각자가 작업한 부분에 대해 다양한 의견을 주고받는 것이 중요하다고 생각했다. 그 과정에서 작업한 팀원이 놓친 부분을 다른 팀원이 발견할 수도 있고 해결하지 못한 문제를 해결할 수도 있기 때문이다. 그러기 위해선 모든 팀원이 프로젝트의 전체적인 흐름을 파악하는 것이 필요하다고 생각했다. </p>
<p>처음엔 너무 긴 기간을 할애한 것이 아닌가 걱정도 했지만 프로젝트를 진행하면서 다른 팀원들이 작업한 부분에 대해 피드백을 진행하고 팀원들이 겪는 문제들을 함께 해결해보니 충분히 시간을 투자할 가치가 있었다고 느꼈다.</p>
<h4 id="프로그래밍에-대한-정의">프로그래밍에 대한 정의</h4>
<p>프로젝트의 전체 흐름을 파악하면서 또 새롭게 배운 것이 있었다. 프로그래밍을 요청에 대한 응답으로 분석하는 관점이다. 프론트엔드는 사용자의 요청에 따라 거기에 맞는 인터렉션을 응답하고 백엔드는 클라이언트의 요청에 따라 알맞는 데이터를 응답하는 것이기 때문에 요청에 대해 응답한다는 관점에서 본다면 훨씬 이해하기가 쉬웠다. </p>
<p>이러한 관점으로 각 페이지에서 일어나는 요청과 응답을 모두 정리해보고 이를 프론트엔드와 백엔드로 나누었다.</p>
<p>이 프로젝트에서 프론트엔드는 피그마에서 보여지는 것에서 크게 복잡한 인터렉션이 없다보니 정리할 부분이 많지 않았지만 백엔드의 경우 시각적으로 정리된 것이 없기 때문에 특정 페이지에서 어떤 데이터를 요청하는지 정리해보는 것이 굉장히 도움이 됐다.</p>
<blockquote>
<p><a href="https://www.notion.so/1a06152b67c6819daf5ae41ec85062dc?pvs=4">기획 정리 페이지 링크</a></p>
</blockquote>
<p>다른 팀원들에게도 이 방법을 공유했는데 설명이 조금 부족했는지 반응이 애매했다. 가장 친했던 팀원 한명과 이 부분에 대해 얘기해볼 기회가 있었는데 이 팀원은 처음엔 프론트엔드를 정리한 부분만 보고 피그마를 보면 알 수 있는 내용인데 왜 정리를 했을까? 생각했다고 한다.</p>
<p>그러다가 내가 백엔드 부분을 설명하면서 일종의 의사코드라고 설명을 드리니 바로 이해를 하시고 이 방식이 유용한 것 같다고 하셨다. 피어리뷰에도 아래와 같이 남겨주셔서 참 뿌듯했다.
<img src="https://velog.velcdn.com/images/silver_hq/post/b9fa673b-fc21-4794-8674-f44d57b40905/image.png" alt=""></p>
<h4 id="팀원-교육">팀원 교육</h4>
<p>마지막 프로젝트 때 백엔드를 처음 작업해보는 팀원이 있었다. 마지막 프로젝트인 만큼 모든 팀원이 프론트엔드,백엔드를 모두 경험해보는 것이 중요하다고 생각했기 때문에 이 팀원에게 백엔드 교육을 해줬다.</p>
<p>먼저 코드를 작성하지 말고 API가 동작하는 흐름을 글로 정리한 다음 GPT에게 사용하는 기술 스택과 DB 스키마와 함께 정리한 글을 주면서 어떻게 구현하는지 물어보라고 했다.</p>
<p>그렇게 하니 코드를 줬지만 실제로 API를 호출했을 때 예상과 다른 결과가 나왔다. 코드를 살펴보면서 어느 부분이 문제인지 함께 찾아보고 수정하는 식으로 구현했고 그렇게 여러번의 수정을 거쳐서 하나의 API를 구현할 수 있었다.</p>
<p>그 이후에도 막히는 부분이 생겨서 여러번 내게 질문을 하셨는데 그 때 마다 나는 문제를 해결하는 과정을 설명해드리려 노력했다. 지금 구현하는 기능이 어떤 기능인지에 대해 정확하게 파악하는 것과 에러가 어디에서 발생한건지 찾아내는 작업이 주된 내용이었던 것 같다. 그렇게 나중에는 질문 빈도가 줄어들고 스스로 해결할 수 있게 되어서 팀원이 맡은 나머지 API를 프로젝트가 끝날 때까지 구현할 수 있었다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/d532bf94-c7a6-48c7-ab34-38e644057a83/image.png" alt="">
해당 팀원이 남긴 피어 리뷰인데 이 팀원을 돕는데 꽤 많은 시간을 쓰긴 했지만 나는 오히려 적극적으로 물어본 점이 좋았다. 막히는 부분이 있는데 혼자 해결하려다가 시간을 낭비하는 것보단 빠르게 도움을 요청하는 것이 더 낫기 때문이다. </p>
<p>당연히 스스로 문제를 해결하려고 시도해보는 것은 중요하지만, 자신이 문제를 해결하려는 올바른 시도를 하고 있는지, 그냥 머리만 싸매고 끙끙 앓고 있는지 명확하게 알 수 있어야 한다. 내가 아는 선에서 시도를 해보고 막힌다면 도움을 요청하는 게 좋다고 생각한다.</p>
<h2 id="성장">성장</h2>
<p>기술적인 성장에 관해서 어떤 기술을 사용해 봤는지 적을까 생각했지만 하나의 글에 모든 내용을 담기엔 너무 양이 많고 종류가 다양하다고 생각했다. 그래서 구체적인 기술 사용 사례는 개별 글로 작성해보려 한다. </p>
<p>조금 더 포괄적으로 성장한 부분을 적어보자면, 우선 기술 사용의 목적을 파악하려는 자세를 갖게 됐다는 점이다. 해당 기술의 가장 큰 특징, 다른 기술과의 차이점을 알면 그 기술을 왜 사용하는지 알 수 있다.</p>
<p>예를 들어 Next.js를 쓴다면 그냥 React를 사용하는 것과 가장 큰 차이점이라면 서버액션일 것이다. 서버 컴포넌트,서버 사이드 렌더링,미들웨어,API Route가 있는데 Next.js를 쓴다면 이러한 서버액션을 사용하기 위한 것이다. 만약 Next.js를 사용한다면 이러한 서버액션이 필요한지 파악하는 것이 중요하다.</p>
<p>또 앞에서도 적은 내용이지만 프로그래밍을 요청과 응답으로 바라보는 관점을 갖게 된 것 또한 내게 굉장히 큰 성장이었다. 백엔드를 배워보기 전에는 막연히 프론트엔드와는 엄청 다른 영역일 거라 생각했다. </p>
<p>하지만 본질적으로는 요청에 대해 적절한 응답을 하는 점에서 크게 다르지 않다는 것을 알게 됐고 새로운 기술을 배우는데 있어 자신감이 생기게 된 계기였다.</p>
<p>그리고 팀 프로젝트를 진행해보니 내 생각을 다른 팀원에게 잘 전달하는 것이 굉장히 중요하다는 것을 알게 됐다. 프로젝트를 진행하다보면 서로 다른 의견을 갖게되는 경우가 있다. </p>
<p>이 때 의견을 잘 설명하는 것이 중요한데 단지 내 의견을 반영시키기 위해서가 아니라 협업하는 과정에 있어서 원활한 소통을 위해선 각자의 의견의 근거를 명확하게 표현하고 의사결정 과정을 거쳐야 하기 때문이다. 그래야 내 의견에 대한 피드백을 받을 수도 있고 만약 내가 다른 팀원의 의견과 반대되는 의견을 제시한다면 그 근거를 명확하게 설명해야 상대방도 납득할 수 있다.</p>
<h2 id="부트캠프-후기">부트캠프 후기</h2>
<p>내 개인적인 회고를 마치고 혹시나 부트캠프에 대해 궁금한 분들도 있을 것 같아서 간단하게 남겨본다.</p>
<p>부트캠프를 시작하기 전엔 나 역시 부트캠프에 대해 부정적인 입장이었지만 지금 생각해보니 명확한 근거가 없는 의견이었던 것 같다. 지금 내 생각은 결국 자기 하기 나름이라는 것이다.</p>
<p>코드잇 스프린트 부트캠프의 경우 꽤 커리큘럼이 괜찮다고 생각한다. 배우는 기술스택도 좋고 미션을 통해 직접 기술을 사용해 구현해볼 수 있다는 점도 좋았다. 하지만 미션에 관해서 몇가지 아쉬운 점도 있었다.</p>
<p>주중에 실습 시간을 활용해 미션을 진행하는데 1주일 안에 미션을 완성해야하는 경우가 많았다. 하지만 1주일 내내 실습 시간만 있는 건 아니다 보니 미션을 진행할 시간이 생각보다 많지 않다는 것이다. 또 실습 시간에 미션 뿐만 아니라 다른 실습을 진행하다보니 미션을 진행할 시간 자체가 그리 많진 않았다.</p>
<p>물론 실습은 자율적이다 보니 그 시간에 나는 미션을 진행했다. 하지만 많은 수강생들이 미션에 대한 일정 관리를 잘 못해서 제출하지 못하는 경우가 많이 발생했다. 물론 일정은 개개인이 관리를 해야되는 부분이긴 하지만, 이에 대해 좀 더 리마인드를 자주 해주거나 어떤 장치가 있었다면 좋지 않았을까 싶다.</p>
<p>사실상 미션을 진행하고 멘토님들께 받은 리뷰가 굉장히 유익했기 때문에 이를 활용하지 못한 수강생들은 많이 아쉽다는 생각도 들었다.</p>
<p>3번의 팀 프로젝트 역시 많은 도움이 됐지만 몇가지 아쉬운 점도 있었다.
우선 기획에 있어서 실제 서비스라고 생각하면 이게 왜 있지? 싶은 것들이 많이 있었다.</p>
<p>또 이미 준비된 기획을 사용해 개발하는 것 또한 아쉬운 부분이었다. 실제 개발자로 일을 하면 기획이나 디자인을 직접 하지 않기 때문에 주어진 기획을 사용하는 것은 크게 문제되진 않는다고 생각한다. 하지만 매 기수마다 똑같은 주제로 진행을 하기 때문에 내가 이력서에 작성한 프로젝트가 이미 다른 지원자가 제출했던 것과 겹칠수도 있기 때문에 문제가 될 수 있다고 생각한다.</p>
<p>프로젝트를 세번 진행하는 것보다 차라리 하나의 심화된 프로젝트를 팀원들끼리 계획해서 만들었다면 어땠을까라는 생각도 들었다.</p>
<p>또 만약 이 부트캠프를 참여하게 된다면 반드시 프로젝트가 끝나고 바로 포트폴리오를 정리하는 것을 권장한다. 대부분 프로젝트가 끝나고 바로 수업을 진행하다보니 그럴 시간이 없었는데 막상 지나고 정리하려고 보니 뭘 했었는지 잘 기억이 나지 않는다. </p>
<p>좋은 점과 아쉬운 점을 간단하게 요약해보자면 이렇다.</p>
<p>좋은 점</p>
<ol>
<li>커리큘럼이 좋다.(다양한 기술을 경험해볼 수 있다.)</li>
<li>미션을 진행하고 멘토님들께 리뷰를 받는 시스템이 도움이 많이 된다.</li>
<li>협업을 경험할 기회가 있다.</li>
<li>멘토링 시간을 통해 좋은 정보나 노하우를 알 수 있다.</li>
</ol>
<p>아쉬운 점</p>
<ol>
<li>커리큘럼이 처음 배우는 입장에선 빡세다. (프론트엔드던지 백엔드던지 하나는 어느 정도 경험이 있는 분에게 추천)</li>
<li>미션 일정이 꽤 빡빡하다. (일정관리에 많은 신경을 써야 함. 누가 뭐해라 뭐해라 말해주지 않음)</li>
<li>팀 프로젝트의 기획 완성도가 조금 아쉽고 포트폴리오가 중복된다.</li>
<li>부트캠프 측에서 케어가 조금 부족하다는 느낌이 있다.</li>
</ol>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next.js 미들웨어에서 환경변수 사용하기]]></title>
            <link>https://velog.io/@silver_hq/Next.js-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4%EC%97%90%EC%84%9C-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@silver_hq/Next.js-%EB%AF%B8%EB%93%A4%EC%9B%A8%EC%96%B4%EC%97%90%EC%84%9C-%ED%99%98%EA%B2%BD%EB%B3%80%EC%88%98-%EC%82%AC%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Wed, 23 Apr 2025 07:07:30 GMT</pubDate>
            <description><![CDATA[<h2 id="배경">배경</h2>
<p>나는 액세스 토큰 존재 여부를 통해 권한을 체크하는 미들웨어를 구현하고 있다. 만약 액세스 토큰이 있는데 로그인/회원가입 페이지로 이동한다면 다른 페이지로 이동시키고 액세스 토큰이 없는 상태로 로그인이 필요한 페이지로 이동한다면 로그인/회원가입 페이지로 이동시키는 것이다.</p>
<p>하지만 액세스 토큰 존재 여부만으로 권한을 체크한다면 누군가 액세스 토큰을 위조하여 쿠키에 넣었을 경우에 대응할 수 없다. 그래서 토큰에 대한 검증이 필요했고 이를 위해선 미들웨어에서도 백엔드에서 토큰을 생성할 때 사용한 JWT Secret을 사용해 액세스 토큰을 검증해야 했다.</p>
<h2 id="문제">문제</h2>
<pre><code class="language-typescript">const ENCODED_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

try {
      const verified = await jwtVerify(accessToken, ENCODED_SECRET);
      console.log(verified);
    } catch (err) {
      console.log(&#39;err&#39;, err);
      const loginUrl = new URL(&#39;/sign-in&#39;, request.url);
      return NextResponse.redirect(loginUrl);
    }</code></pre>
<p>.env 파일에 JWT Secret을 추가하고 process.env.JWT_SECRET을 통해 환경변수를 호출해서 JWT를 검증하도록 설정했다. 로컬환경에선 정상적으로 동작했지만 배포환경에선 제대로 동작하지 않았다. console.log를 통해 JWT Secret을 확인해봤지만 undefined로 뜨는 것으로 보아 JWT Secret을 읽어오지 못하는 것이 원인이었다.</p>
<h2 id="원인">원인</h2>
<p>.env를 불러오기 위해선 node.js 환경에서 실행되어야 한다. 하지만 AWS Amplify나 Vercel 같은 배포환경에서 Next.js의 미들웨어는 Node.js가 아닌 V8 엔진 기반의 Edge Runtime에서 실행되는데 이 Edge Runtime 환경에선 .env 파일을 조회해서 process.env에 주입하지 않기 때문에 환경변수를 읽어오지 못한 것이다.</p>
<p>로컬 환경에선 정상적으로 작동한 이유 역시 로컬에선 Next.js 서버가 Node.js에서 실행되기 때문이다.</p>
<h2 id="해결방법">해결방법</h2>
<p>기존에 process.env를 통해 환경변수를 조회하는 방식은 런타임에 해당 환경변수를 조회한다.</p>
<pre><code class="language-typescript">
const useEnv =()=&gt; {
    console.log(process.env.NEXT_PUBLIC_ANY_VALUE);
}

useEnv();</code></pre>
<p>위의 코드처럼 환경변수를 조회해서 출력하는 함수가 있다면(예시일 뿐 실제로 이렇게 하면 환경변수로 입력하는 의미가 없어진다.) 해당 함수를 호출할 때 .env파일에 있는 NEXT_PUBLIC_ANY_VALUE 변수를 조회해서 출력하게 되는 것이다.</p>
<p>하지만 next.config 파일에서 env를 설정하면 빌드 타임에 해당 환경변수를 사용하는 모든 곳에 직접 값을 하드코딩하여 빌드하도록 설정할 수 있다.</p>
<pre><code class="language-typescript">
const nextConfig ={
    env: {
    JWT_SECRET: process.env.JWT_SECRET,
  },
}
</code></pre>
<p>이렇게 하면 빌드된 결과는 아래 코드처럼 실제 값이 입력된 코드가 된다.</p>
<pre><code class="language-typescript">// 빌드 전
const ENCODED_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);

// 빌드 후
const ENCODED_SECRET = new
TextEncoder().encode(&#39;실제 JWT_SECRET 값&#39;);</code></pre>
<p>이렇게 빌드할 때 해당 환경변수를 하드코딩하여 빌드하게 되면 .env에서 환경변수를 불러올 수 없는 미들웨어에서도 해당 환경변수를 정상적으로 사용할 수 있게 된다.</p>
<h2 id="보안문제">보안문제?</h2>
<p>이렇게 JWT Secret 값이 하드코딩되면 보안상의 문제가 있진 않을까? 의문이 들 수 있다.
보안상의 문제가 되는 경우는 프론트엔드 번들에 포함되면 브라우저에서 코드를 확인이 가능한 경우다. 이 경우 프론트엔드 번들에는 해당 환경변수가 포함되지 않기 때문에 문제가 되지 않는다.</p>
<h3 id="중요-포인트">중요 포인트</h3>
<p>중요한 점은, 환경변수 앞에 NEXT_PUBLIC을 붙이지 않는 것이다. 붙이게 되면 해당 변수를 사용하지 않더라도 클라이언트 번들에 포함되어서 노출되기 때문이다.</p>
<pre><code class="language-typescript">const nextConfig ={
    env : {
         NEXT_PUBLIC_JWT_SECRET : process.env.JWT_SECRET, // 절대 금지!!
    }
}</code></pre>
<h3 id="클라이언트-모듈에서-import-금지">클라이언트 모듈에서 import 금지</h3>
<p>NEXT_PUBLIC이 붙지 않은 환경변수라도 만약 해당 환경변수를 사용하는 코드를 아래와 같이 클라이언트 모듈에서 import할 경우 환경변수가 하드코딩 되어 클라이언트 번들에 포함되기 때문에 이 역시 주의해야할 부분이다.</p>
<pre><code class="language-typescript">// 서버에서 사용할 함수
import { jwtVerify } from &#39;jose&#39;;

export const verifyToken =(accessToken:string)=&gt; {
  jwtVerify(accessToken,process.env.JWT_SECRET);
}

// 클라이언트 모듈
&#39;use client&#39;

import {verifyToken} from &#39;./verifyToken.ts&#39;;
</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[AWS Route53을 통한 도메인 연결]]></title>
            <link>https://velog.io/@silver_hq/AWS-Route53%EC%9D%84-%ED%86%B5%ED%95%9C-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0</link>
            <guid>https://velog.io/@silver_hq/AWS-Route53%EC%9D%84-%ED%86%B5%ED%95%9C-%EB%8F%84%EB%A9%94%EC%9D%B8-%EC%97%B0%EA%B2%B0</guid>
            <pubDate>Tue, 22 Apr 2025 10:29:31 GMT</pubDate>
            <description><![CDATA[<h2 id="route53">Route53</h2>
<p>Route53은 하나의 도메인으로 다양한 서비스를 통합 관리하고 연결하는 역할을 한다.
나는 프론트엔드와 백엔드에 하나의 도메인을 연결하기 위해 사용하게 됐는데,</p>
<p>프론트엔드는 Amplify에서 기본으로 제공되는 도메인을 사용했었고 백엔드에는 가비아에서 구매한 도메인을 연결했었다. 여기서 가비아에서 구매한 도메인을 프론트엔드에 연결하고 도메인 앞에 api를 붙인 서브도메인을 백엔드에 연결하도록 변경했다.</p>
<h2 id="1호스팅-영역-생성">1.호스팅 영역 생성</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/4b65082b-699e-4a17-9875-5a19cbb3d632/image.png" alt=""></p>
<p>우선 Route53의 호스팅 영역 메뉴로 이동해서 우측 상단에 보면 호스팅 영역 생성 버튼이 있다. 이 버튼을 눌러 호스팅 영역을 생성해준다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/38b3284c-77b4-437f-80c1-bad500d7f2f2/image.png" alt="">
도메인 이름에 사용할 도메인을 입력해주고 생성 버튼을 누른다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/8f71165c-84ae-40e8-91df-e32b8060aece/image.jpg" alt="">
<img src="https://velog.velcdn.com/images/silver_hq/post/06c4aa26-82e4-4fc6-b4f2-8de778d0a124/image.jpg" alt=""></p>
<p>생성된 호스팅 영역으로 들어가면 레코드 이름에 도메인이 입력돼있고 값/트래픽 라우팅 대상에 4개의 값이 표시된다. ns-xxxx.com,ns-xxxx.net 과 같은 형식으로 표시되는데 이를 도메인 관리 페이지에서 네임서버 부분에 4개의 값을 모두 입력해줘야 한다</p>
<h2 id="2aws-amplify에-사용자-지정-도메인-추가">2.AWS Amplify에 사용자 지정 도메인 추가</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/663e05c5-b0cd-42b6-bdac-adb2c9778588/image.jpg" alt=""></p>
<p>Amplify 앱에 들어가 메뉴에서 사용자 지정 도메인을 클릭하고 도메인 추가 버튼을 누른다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/09cd6c46-a340-473b-bdc3-f102619fff22/image.jpg" alt=""></p>
<p>그리고 검색창에 설정하려는 도메인을 입력하고 도메인 가용성 확인 버튼을 누르면 앞서 route53을 통해 네임서버를 만들고 지정했기 때문에 도메인 구성이 가능한 것을 볼 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/b168d5b8-dea9-4d7f-90a7-87a57c85f6b2/image.jpg" alt=""></p>
<p>도메인을 연결하고 Route53으로 돌아가면 이렇게 레코드가 추가된 것을 확인할 수 있고 도메인으로 접속하면 정상적으로 Amplify App에 접속되는 것을 볼 수 있다.</p>
<h2 id="3ec2에-도메인-연결">3.EC2에 도메인 연결</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/5316c1bc-c9bd-4c74-bd99-0138db08c8dd/image.jpg" alt=""></p>
<p>Route53에 백엔드에 접속할 때 사용할 서브 도메인의 레코드를 생성한다. 레코드 목록에서 레코드 생성 버튼을 누르면 위와 같이 레코드 생성 창이 표시되는데 여기에 서브도메인을 레코드 이름 앞에 붙여주고 값에는 EC2의 IP주소를 입력해서 생성한다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/dcde7580-aa4f-4a96-80e8-b0d5a8410af6/image.jpg" alt=""></p>
<p>그리고 nginx에서 포워딩할 주소를 변경하고 SSL을 다시 발급하면 된다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[S3 credential object is not valid 오류 해결]]></title>
            <link>https://velog.io/@silver_hq/S3-credential-object-is-not-valid-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</link>
            <guid>https://velog.io/@silver_hq/S3-credential-object-is-not-valid-%EC%98%A4%EB%A5%98-%ED%95%B4%EA%B2%B0</guid>
            <pubDate>Wed, 05 Mar 2025 05:24:46 GMT</pubDate>
            <description><![CDATA[<h1 id="배경">배경</h1>
<p>팀프로젝트를 진행하면서 S3에서 파일 업로드를 위한 presigned URL을 발급하는 API를 추가했다. 이 API는 기존에 내 개인 프로젝트에서 사용했던 코드를 가져왔는데 동일한 코드인데도 개인 프로젝트에선 발생하지 않던 문제가 발생했다.</p>
<pre><code>Resolved credential object is not valid</code></pre><p>S3 client를 생성할 때 만든 credentials 객체가 올바르지 않다는 메시지였다.</p>
<pre><code class="language-typescript">this.s3Client = new S3Client({
      region: process.env.AWS_REGION,
      credentials: {
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      },
    });</code></pre>
<p>s3Client를 생성할 때 이렇게 AWS계정과 관련된 정보가 필요한데 IAM 유저를 만들어서 s3에 대한 권한을 허용하고 accessKey를 다운받아서 .env 파일에 추가해놓은 상태였다.
하지만 위와 같은 에러가 계속 발생했다. </p>
<h1 id="해결시도">해결시도</h1>
<h2 id="1s3cloudfrontiam-유저-설정-문제">1.S3,cloudfront,IAM 유저 설정 문제?</h2>
<p>먼저 새롭게 S3 버킷과 Cloudfront를 생성하면서 IAM유저 또한 새로 생성했는데 이 과정에서 문제가 발생한건지 확인해봤다.</p>
<p>기존에 정상적으로 작동하던 S3 버킷과 Cloudfront 설정을 확인해보고 IAM 유저 또한 동일하게 S3에 대한 전체 권한을 허용했는지 체크했고 문제가 없는 것으로 확인됐다.</p>
<p>정확한 확인을 위해 기존에 정상적으로 작동하던 프로젝트에서 새로 만든 IAM유저,S3 버킷,Cloudfront를 연동해서 API를 호출했을 땐 문제 없이 정상적으로 URL이 발행되는 것을 확인했다.</p>
<p>그러므로 우선 AWS의 문제가 아닌 내 코드에 문제가 있다는 것을 확인했다.</p>
<h2 id="2-env-파일-문제">2. .env 파일 문제?</h2>
<p>그렇다면 S3 client를 생성할 때 .env에 입력된 값을 사용하는데 .env파일이 문제는 아닐까? 싶었지만, 위에서 시도한 것처럼 다른 프로젝트에서 .env에 액세스 키 등 변수를 변경하고 API를 호출했을 땐 문제가 없었던 것을 보아 .env 파일에 입력된 값에는 문제가 없었다.</p>
<p>그러면 .env를 제대로 불러오지 못하는지 확인하기 위해 console.log를 통해 입력된 환경변수를 출력해봤다. </p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/44d701b6-4b27-44b2-8249-c7d6a1de7586/image.png" alt=""></p>
<p>역시나 .env를 정상적으로 불러오는 것을 확인할 수 있었다.</p>
<h1 id="해결">해결</h1>
<p>결론적으로 문제는 S3 client를 선언할 때 .env파일이 아직 로드되지 않았기 때문이었다.
credentials 에 async를 추가함으로써 이 문제는 해결되었다.</p>
<pre><code class="language-typescript">    this.s3Client = new S3Client({
      region: process.env.AWS_REGION,
      credentials: async () =&gt; ({
        accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
        secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
      }),
    });</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[dotenv-cli를 사용해 .env 파일을 선택해서 실행하자]]></title>
            <link>https://velog.io/@silver_hq/dotenv-cli%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4-.env-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%84%9C-%EC%8B%A4%ED%96%89%ED%95%98%EC%9E%90</link>
            <guid>https://velog.io/@silver_hq/dotenv-cli%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%B4-.env-%ED%8C%8C%EC%9D%BC%EC%9D%84-%EC%84%A0%ED%83%9D%ED%95%B4%EC%84%9C-%EC%8B%A4%ED%96%89%ED%95%98%EC%9E%90</guid>
            <pubDate>Sun, 16 Feb 2025 11:38:27 GMT</pubDate>
            <description><![CDATA[<h2 id="dotenv-cli란">dotenv-cli란?</h2>
<p>dotenv-cli는 환경에 따라 환경변수 파일을 선택해서 명령어를 실행할 수 있도록 해주는 도구이다.
나는 express로 개발한 서버에서 jest를 통해 DB의 CRUD 기능을 테스트하는 과정에서 사용하게 됐다.
이때 실제 DB를 가지고 테스트할 경우 기존 사용자의 데이터가 삭제되거나 변경될 수 있기 때문에 테스트용 DB를 사용했다.
기존에 .env파일에는 실제 사용하는 DB의 URL이 입력되어 있기 때문에 .env.test 파일을 만들어 테스트용 DB의 URL을 입력해서 사용해야 했다.</p>
<h2 id="사용방법">사용방법</h2>
<pre><code class="language-bash">
npm install -D dotenv-cli
</code></pre>
<p>먼저 dotenv-cli를 dev dependencies로 설치한다.</p>
<pre><code class="language-json">// package.json
{
  ...
  &quot;scripts&quot; : {
    &quot;test&quot; : &quot;dotenv -e .env.test jest&quot;
  }
  ...
}
</code></pre>
<p>그리고 package.json에서 다른 환경변수를 적용하고 싶은 명령어 앞에 <code>dotenv -e</code> 와 <code>환경변수 파일명</code>을 입력하고 실행할 명령어를 입력해주면 된다. <code>-e</code>옵션은 특정 .env 파일의 경로를 지정하는 옵션이다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[EC2,RDS 인스턴스 생성부터 초기 설정까지]]></title>
            <link>https://velog.io/@silver_hq/EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1%EB%B6%80%ED%84%B0-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@silver_hq/EC2-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-%EC%83%9D%EC%84%B1%EB%B6%80%ED%84%B0-%EC%B4%88%EA%B8%B0-%EC%84%A4%EC%A0%95%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Fri, 07 Feb 2025 00:52:07 GMT</pubDate>
            <description><![CDATA[<h2 id="ec2-인스턴스-생성">EC2 인스턴스 생성</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/792ea656-6ef0-4f74-bafe-4da67c9d5fce/image.png" alt=""></p>
<p>먼저 EC2인스턴스의 이름을 설정하고 운영 체제를 선택한다. 나는 Amazon Linux를 사용했다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/62bf571a-368b-4973-92bb-9bc170c5531a/image.png" alt=""></p>
<p>인스턴스 유형은 프리 티어로 사용할 수 있는 t2.micro로 설정하고 로그인에 사용할 키페어를 선택하거나 생성해야 한다. 이때 생성한 키페어는 저장해서 보관해둬야 한다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/dfb1d358-98a3-4e60-8547-bbbcc43cb582/image.png" alt=""></p>
<p>네트워크 설정은 이후에 http,https로 접속하기 위해 위와 같이 설정했다.</p>
<h2 id="rds-인스턴스-생성">RDS 인스턴스 생성</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/ead6b9ee-9532-4b47-82b6-3d066c577d2d/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/1c6690e6-c48c-4ba4-a917-98e436232a33/image.png" alt=""></p>
<p>EC2에 연결할 RDS를 생성한다. 나는 postgreSql로 선택했고 템플릿은 프리 티어로 설정했다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/db73e99b-c596-4de4-a502-3d2399e93276/image.png" alt=""></p>
<p>자격 증명 설정은 기본값인 자체 관리를 선택한다. 그리고 암호는 직접 설정하거나 자동으로 생성할 수 있다. 나는 자동 생성을 선택했다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/e9dbb80f-7f30-4f31-ab02-75ef94e0d25f/image.png" alt="">
인스턴스 구성은 기본값이 t4g로 설정돼있는데 이를 t3로 변경했다. </p>
<h3 id="t4g와-t3의-차이점">t4g와 t3의 차이점</h3>
<p>t3 micro는 Intel x86 프로세서를 사용하고 t4g는 ARM 기반의 AWS Graviton2프로세서를 사용한다.
t4g가 가격대비 성능이 더 뛰어나지만 ARM 기반이기 때문에 일부 애플리케이션에선 호환성 검토가 필요할 수 있다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/82a5cfaa-ea83-4e74-8e1a-344569e81957/image.png" alt=""></p>
<p>해당 RDS를 사용하고자하는 EC2와 연결해준다. 이렇게 하면 자동으로 EC2에서 RDS로 접속이 가능하도록 설정된다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/2c9c7a7d-5a00-41cc-98b3-a8c1727d399f/image.png" alt="">
서브넷 그룹은 자동으로 설정하고 VPC 보안 그룹도 새로 생성해준다. 퍼블릭 액세스는 자동으로 아니요가 선택되는데, EC2를 통해서만 접속할 수 있도록 설정된다.
이렇게 옵션을 선택하고 생성을 누른다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/530e80bd-db07-46e3-bfc1-e598041580c4/image.png" alt=""></p>
<p>암호를 자동으로 생성하도록 설정했다면 이런 배너가 표시되는데 우측 버튼을 눌러서 암호를 확인하고 복사해둔다.
이 암호는 이후에 DB에 연결할 때 사용한다.</p>
<p>DB에 연결할 때 URL은 아래 같은 형식으로 사용한다.</p>
<pre><code>&lt;DB엔진&gt;://&lt;사용자이름&gt;:&lt;비밀번호&gt;@&lt;엔드포인트&gt;:&lt;포트&gt;/&lt;데이터베이스이름&gt;</code></pre><h2 id="nvm-설치">nvm 설치</h2>
<pre><code class="language-bash">curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash

source ~/.bashrc</code></pre>
<h3 id="source-bashrc-명령어">source ~/.bashrc 명령어</h3>
<p>이는 수정된 .bashrc 파일의 변경사항을 현재 실행중인 셸 세션에 바로 적용하기 위한 명령어다.
.bashrc를 수정하면 바로 적용되지 않고 터미널을 새로 열어야하는데 source명령어를 사용하면 터미널을 다시 열지 않고 바로 적용할 수 있다.</p>
<h2 id="nodejs-lts-설치">node.js lts 설치</h2>
<pre><code class="language-bash">
nvm install --lts
</code></pre>
<h2 id="git-설치">git 설치</h2>
<pre><code class="language-bash">
sudo yum install git
</code></pre>
<h2 id="pm2-설치">pm2 설치</h2>
<pre><code class="language-bash">npm install -g pm2</code></pre>
<h2 id="nginx-설치">nginx 설치</h2>
<pre><code class="language-bash">
sudo yum install nginx
</code></pre>
<h2 id="nginx-설정">nginx 설정</h2>
<pre><code class="language-bash">sudo nano /etc/nginx/conf.d/default.conf</code></pre>
<pre><code class="language-bash">server {
    listen 80;
    server_name your-domain.com;

    location / {
        proxy_pass http://localhost:port;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection &#39;upgrade&#39;;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
    }
}</code></pre>
<h2 id="nginx-문법-체크재시작">nginx 문법 체크,재시작</h2>
<pre><code class="language-bash">sudo nginx -t
sudo systemctl restart nginx</code></pre>
<h2 id="타입스크립트-설치사용했을-경우">타입스크립트 설치(사용했을 경우)</h2>
<pre><code class="language-bash">
npm install -g typescript
</code></pre>
<h2 id="저장소-clone-및-설치">저장소 Clone 및 설치</h2>
<pre><code class="language-bash">
git clone 저장소.git
cd 폴더명
npm install
nano .env
</code></pre>
<p>저장소를 클론받은 다음 폴더로 이동해서 npm install 후 nano .env를 통해 .env파일을 설정한다.</p>
<h2 id="pm2로-시작">pm2로 시작</h2>
<pre><code class="language-bash">pm2 start app.js</code></pre>
<h2 id="서버-접속-확인">서버 접속 확인</h2>
<p>EC2 대시보드에서 인스턴스를 클릭하면 퍼블릭 IPv4주소를 복사하고 이를 브라우저에 입력하면 EC2에 배포된 서버에 접속할 수 있다.</p>
<h2 id="ssl-적용을-위한-certbot-설치선택">ssl 적용을 위한 certbot 설치(선택)</h2>
<pre><code class="language-bash">sudo yum install -y certbot python3-certbot-nginx</code></pre>
<h2 id="ssl-인증서-발급선택">ssl 인증서 발급(선택)</h2>
<pre><code class="language-bash">sudo certbot --nginx -d yourdomain.com -d www.yourdomain.com</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[prisma 복합키를 사용한 delete]]></title>
            <link>https://velog.io/@silver_hq/prisma-%EB%B3%B5%ED%95%A9%ED%82%A4%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-delete</link>
            <guid>https://velog.io/@silver_hq/prisma-%EB%B3%B5%ED%95%A9%ED%82%A4%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%9C-delete</guid>
            <pubDate>Fri, 27 Dec 2024 05:17:31 GMT</pubDate>
            <description><![CDATA[<pre><code>
model Favorite {
  id        Int     @id @default(autoincrement())
  userId    Int
  user      User    @relation(fields: [userId], references: [id], onDelete: Cascade)
  productId Int
  product   Product @relation(fields: [productId], references: [id], onDelete: Cascade)

  @@unique([userId, productId])
}
</code></pre><p>상품 좋아요를 저장하는 데이터베이스 모델이 있을 때, @@unique를 통해 userId와 productId 쌍이 중복되지 않도록 설정했다.
@@unique를 설정하고 prisma generate를 할 경우 복합키가 함께 생성되는데 unique에 입력된 순서대로 _를 통해 구분된다.</p>
<pre><code class="language-typescript">
  async deleteFavorite(productId: number, userId: number) {
    await prismaClient.favorite.delete({
      where: {
        userId_productId: {
          userId,
          productId,
        },
      },
    });
  }
</code></pre>
<p>이처럼 where에 userId_productId라는 복합키를 통해 삭제가 가능하다. </p>
<p>이러한 복합키를 사용하는 이유는 동일한 사용자가 동일한 상품에 중복으로 좋아요를 할 수 없도록 하고 데이터베이스 레벨에서 무결성을 보장하기 위함이다. </p>
<p>이때 복합키를 where조건으로 사용할 때 모든 userId,productId가 모두 제공되어야 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Javascript var,let,const의 차이점]]></title>
            <link>https://velog.io/@silver_hq/Javascript-varletconst%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</link>
            <guid>https://velog.io/@silver_hq/Javascript-varletconst%EC%9D%98-%EC%B0%A8%EC%9D%B4%EC%A0%90</guid>
            <pubDate>Sun, 06 Oct 2024 09:57:45 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/silver_hq/post/aa1c5a39-a1f1-4a1e-98ff-db5b5cc110db/image.png" alt=""></p>
<p>Javascript에는 변수를 선언하는 방법이 세가지가 있다. <code>var</code>와 <code>let</code>,<code>const</code>가 있는데 <code>let</code>,<code>const</code>는 ES6에 처음 나온 변수 선언 키워드로 기존의 <code>var</code> 키워드가 가지고 있는 몇가지 문제점들을 해결하기 위해 등장했다. 어떤 문제점과 차이가 있는지 알아보려 한다.</p>
<h2 id="1스코프">1.스코프</h2>
<p>우선 <code>var</code>,<code>let</code>,<code>const</code>의 차이점을 알아보기 전에 스코프에 대해 알 필요가 있다.
스코프는 쉽게 말해 변수를 참조할 수 있는 범위를 말한다. 스코프의 종류는 <code>전역스코프</code>,<code>함수스코프</code>,<code>블록스코프</code>,<code>렉시컬스코프</code>,<code>모듈스코프</code>,<code>eval스코프</code>가 있다. 하지만 이 글에선 <code>전역</code>,<code>함수</code>,<code>블록스코프</code>를 통해서만 설명하려 한다.</p>
<pre><code class="language-javascript">
const a = 1;
console.log(a);

function b() {
  console.log(a);
  const b = 2;
  function(c) {
    console.log(b);
    const c = 3;
  }
  c();
}

for (let i=0;i&lt;5;i++) {
  console.log(a+i);
}
</code></pre>
<p>이와 같은 코드가 있을 때 <code>a</code>는 <code>전역스코프</code>에 속하며 <code>b</code>와 <code>c</code>는 <code>함수스코프</code>, for 문의 <code>i</code>는 <code>블록스코프</code>에 속한다.
<code>전역스코프</code>에서 선언된 <code>a</code>는 <code>함수스코프</code>,<code>블록스코프</code> 어느 곳에서든 참조가 가능하다. <code>함수스코프</code> 안에 있는 변수는 <code>함수스코프</code> 내에서만 참조가 가능한데, 또 함수 내에 중첩되어 있는 함수 <code>c</code>에서 선언한 변수는 외부에 있는 함수인 <code>b</code>에선 참조가 불가능하다. 여기까진 <code>var</code>와 <code>const</code>,<code>let</code>이 동일하다.</p>
<h2 id="2블록스코프">2.블록스코프</h2>
<p>하지만 <code>if문</code>,<code>for문</code> 등과 같은 문법 내에서 선언된 변수는 기존의 <code>var</code>키워드로 선언할 경우 외부에서 참조가 가능했다. </p>
<pre><code class="language-javascript">
for(var i=0;i&lt;5;i++) {
    const a = i;
}
console.log(i);

// 출력결과:
// 5
</code></pre>
<p>그 이유는 <code>var</code>키워드는 <code>블록스코프</code>를 지원하지 않기 때문이다. 만약 <code>if문</code>,<code>for문</code>등과 같은 블록 안에서 <code>var</code>키워드로 변수가 선언될 경우 이는 해당 블록과 가장 가까운 외부의 함수 또는 전역 스코프에 포함된다. </p>
<p><code>let</code>과 <code>const</code>는 이러한 문제를 해결해서 블록 내에 있는 변수를 외부에서 참조할 수 없도록 <code>블록스코프</code>를 지원한다.</p>
<h2 id="3호이스팅">3.호이스팅</h2>
<pre><code class="language-javascript">console.log(a);
var a = 1;

// 출력 결과:
// undefined</code></pre>
<pre><code class="language-javascript">console.log(a);
const a = 1;

// 출력 결과:
// Uncaught ReferenceError: a is not defined</code></pre>
<p>자바스크립트 코드는 몇가지 특별한 상황을 제외하면 위에서 아래로 순서대로 실행된다. 그렇기 때문에 첫번째 코드처럼 <code>a</code>가 선언되기 전에 참조되면 에러가 발생할 것으로 예상되지만 결과는 <code>undefined</code>가 출력된다.</p>
<p>이와 같이 변수가 선언되기 전에 참조가 가능한 현상을 호이스팅이라 한다. let과 const는 이러한 호이스팅 현상을 해결해서 아래 코드처럼 <code>const</code>로 선언한 변수를 먼저 참조할 경우 에러가 발생한다.
(정확히 말하자면 <code>let</code>과 <code>const</code> 역시 호이스팅되지만 참조 시 에러가 발생한다는 말이 맞다.)</p>
<h2 id="4-호이스팅이-발생하는-원리">4. 호이스팅이 발생하는 원리</h2>
<p>그렇다면 호이스팅이 발생하는 원리는 무엇일까? 이는 자바스크립트 엔진이 자바스크립트 코드를 실행하는 과정과 관련이 있다. 자바스크립트 엔진은 코드를 실행할 때 두가지 과정을 거친다. </p>
<ol>
<li>코드 평가</li>
<li>코드 실행</li>
</ol>
<p>코드 평가란 코드에서 선언된 변수나 함수등의 식별자를 선언하는 과정을 거친다.
그리고 코드 실행 과정에서 변수들에 값을 할당하거나 함수를 실행하는 등의 과정을 거치는 것이다.</p>
<p>하지만 <code>var</code>키워드로 선언된 변수는 코드 평가 과정에서 식별자를 선언할 뿐 아니라 <code>undefined</code>가 할당되기 때문에 실행 단계에서 아직 값이 할당되지 않아도 참조했을 때 에러가 발생하지 않는 것이다. 이것이 호이스팅된 변수를 참조했을 때 <code>undefined</code>가 반환되는 이유이다.</p>
<p><code>let</code>,<code>const</code>키워드로 선언한 변수는 코드 평가 과정에선 동일하게 식별자가 선언되지만 여기에 undefined를 할당하지 않고 코드 실행 과정에서 코드에 따라 값이 할당된다. 이를 <code>TDZ(일시적 사각지대)</code>라 부른다.</p>
<h2 id="5재선언">5.재선언</h2>
<p><code>var</code> 키워드의 특징 중 하나로 동일한 변수명으로 재선언이 가능하다는 점이다.</p>
<pre><code class="language-javascript">
var a = 1;
var a = 2;
console.log(a);

// 출력 결과:
// 2

let b = 1;
let b = 2;
console.log(b);


// 출력 결과:
// Uncaught SyntaxError: Identifier &#39;b&#39; has already been declared</code></pre>
<p>첫번째 코드의 결과를 보면 알 수 있듯이 <code>var</code> 키워드로 선언한 <code>a</code>변수는 또 다시 선언했을 때 에러가 발생하지 않고 정상적으로 2가 할당되는 것을 알 수 있다.</p>
<p>하지만 <code>let</code> 키워드로 <code>b</code>라는 이름의 변수를 선언한 다음 다시 <code>b</code>라는 이름으로 변수를 선언할 경우 에러가 발생한다.</p>
<p>위와 같이 코드가 짧다면 동일한 변수명으로 선언할 경우 알아차리기 쉽지만 코드가 길어진다면 이를 확인하기 어렵다. 만약 <code>a</code>변수를 단일한 목적으로 사용하고 값을 재할당하기 위한 작업이었다면 문제가 생기지 않지만, 기존에 <code>a</code>라는 변수를 사용하는 기능1이 있는 상태에서 새로운 기능2에서 사용하기 위해 변수 <code>a</code>를 새로 선언하고 값을 할당한다면 개발자가 의도한 바와 다르게 작동할 가능성이 크다.</p>
<p>재선언이 가능할 경우 이러한 문제가 있기 때문에 <code>let</code>과 <code>const</code>는 재선언이 불가능하게 만들어졌다.</p>
<h2 id="6상수와-변수-구분">6.상수와 변수 구분</h2>
<p>프로그램이 작동하는 중 값이 변경되면 안되는 경우가 있다. 하지만 <code>var</code> 키워드로 선언한 변수에는 값을 재할당하는 것을 막을 수 없는데, 이를 위해 값을 재할당하는 것이 가능한 변수를 생성하는 <code>let</code>키워드와 처음 할당된 값을 재할당할 수 없는 상수를 만드는 <code>const</code>키워드를 통해 변수와 상수 선언 방법을 구분했다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[CSS nth-of-type 과 nth-child의 차이]]></title>
            <link>https://velog.io/@silver_hq/CSS-nth-of-type-%EA%B3%BC-nth-child%EC%9D%98-%EC%B0%A8%EC%9D%B4</link>
            <guid>https://velog.io/@silver_hq/CSS-nth-of-type-%EA%B3%BC-nth-child%EC%9D%98-%EC%B0%A8%EC%9D%B4</guid>
            <pubDate>Wed, 02 Oct 2024 06:17:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/silver_hq/post/8846c9a1-f2fe-44c7-aee7-991e0cb2d235/image.png" alt=""></p>
<p><code>nth-of-type</code>과 <code>nth-child</code> 속성은 여러개의 동일한 태그에 대해서 특정한 순서의 태그에 스타일을 적용하고 싶을 때 사용한다.
이때 두가지 속성의 공통점은, 속성 앞에 오는 요소를 갖고 있는 부모 태그 내에서 해당되는 요소를 선택한다는 점이다.</p>
<h2 id="nth-of-type">nth-of-type</h2>
<p><code>selector:nth-of-type(n)</code>은 부모태그 내에 있는 <code>selector</code>요소들 중 n번째 요소를 선택한다. 이때 n번째는 찾고자하는 요소들 내에서 계산된다.
아래와 같이 중간에 <code>span</code>태그가 있더라도 <code>div</code>태그 중에서 첫번째와 두번째 요소를 선택해 스타일을 적용한다.</p>
<p>!codepen[Silver-H/embed/YzmwvOX?default-tab=css%2Cresult]</p>
<h2 id="nth-child">nth-child</h2>
<p><code>selector:nth-child(n)</code>은 부모태그 내에 있는 <code>selector</code>요소들 중 n번째에 <code>selector</code>요소가 있다면 이 요소를 선택한다. 
아래와 같이 <code>div:nth-child(2)</code>의 경우 <code>div</code>태그 중 두번째를 찾는 것이 아니라, <code>div</code>태그가 속한 부모 요소에 속한 모든 태그들 중 두번째에 <code>div</code>태그가 있을 경우 이 태그에 스타일을 적용하는 것이다.
부모요소에 <code>span</code>태그가 하나 있지만 <code>span:nth-child(2)</code>에 적용한 스타일이 적용되는 것을 확인할 수 있다.</p>
<p>!codepen[Silver-H/embed/wvVMXYe?default-tab=css%2Cresult]</p>
<h2 id="nth-of-typenth-child로-클래스를-선택한다면">nth-of-type,nth-child로 클래스를 선택한다면?</h2>
<p>앞선 코드들은 태그이름을 사용해 n번째 요소를 선택했다. 하지만 만약 클래스를 사용해 n번째 요소를 선택한다면 해당 클래스를 가진 요소 중 n번째 요소를 선택할 수 있을까?</p>
<p>아래 결과를 보면 알다시피 그렇지 않다. 그 이유는 &quot;target&quot;이라는 클래스를 통해 요소를 선택했지만 형제들 중 몇번째인지 계산할 요소를 정하는 기준은 <code>태그</code>이기 때문이다.</p>
<p><code>.target</code>클래스 명을 가진 요소를 선택했지만 이 요소는 <code>div</code> 태그이기 때문에 <strong>선택된 요소와 형제관계인 <code>div</code>태그들 중 2번째가 선택되어 2번째 <code>div</code>에 스타일이 적용된 것이다.</strong>
하지만 만약 <code>nth-of-type(1)</code>을 하면 첫번째 <code>div</code>요소에 클래스가 적용되지 않았기 때문에 스타일이 적용되지 않는다.
아래 결과를 보면 <code>nth-of-type</code>과 <code>nth-child</code> 모두에 적용되는 규칙임을 알 수 있다.</p>
<p>!codepen[Silver-H/embed/Yzmwvdy?default-tab=css%2Cresult]</p>
<h2 id="nth-of-typenth-child의-매개변수값">nth-of-type,nth-child의 매개변수값</h2>
<p>nth-of-type(n),nth-child(n)과 같은 형태로 n번째 요소를 지정할 수 있는데, 여기에 넣을 수 있는 값은 다음과 같다.</p>
<h3 id="1양의-정수">1.양의 정수</h3>
<p>앞선 예제와 같이 양의 정수 값을 통해 n번째 요소를 선택할 수 있다.</p>
<h3 id="2oddeven-키워드">2.<code>odd</code>,<code>even</code> 키워드</h3>
<p>홀수와 짝수 단위로 선택할 수 있는 키워드이다. odd를 입력하면 홀수가 적용되고 even을 입력하면 짝수가 적용된다.</p>
<h3 id="3anb">3.An+B</h3>
<p>만약 3의 배수를 적용하고 싶다면 3n을 입력하고, 5의 배수-1번째 요소를 선택하고 싶다면 5n-1과 같이 입력한다.</p>
<p>!codepen[Silver-H/embed/qBebYmp?default-tab=css%2Cresult]</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[브라우저 작동 원리]]></title>
            <link>https://velog.io/@silver_hq/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%AC</link>
            <guid>https://velog.io/@silver_hq/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%9E%91%EB%8F%99-%EC%9B%90%EB%A6%AC</guid>
            <pubDate>Sun, 29 Sep 2024 14:29:03 GMT</pubDate>
            <description><![CDATA[<p>우리가 웹사이트를 방문할 때 사용하는 브라우저는 어떤 원리로 작동할까?</p>
<h2 id="요청과-응답">요청과 응답</h2>
<p>우선 사용자가 브라우저의 주소창에 URL을 입력하면 DNS를 통해 IP주소로 변환하고 이 IP주소에 해당하는 서버에 요청을 전송한다. 이때 URL이 <a href="http://www.naver.com%EA%B3%BC">www.naver.com과</a> 같이 스킴과 호스트로만 이루어진 경우 암묵적으로 index.html파일을 요청하게 된다.</p>
<p>서버는 브라우저가 요청한 html을 바이트 형태로 메모리에 저장하고 메모리에 저장된 바이트를 네트워크 통신을 통해 응답한다.
이때 html파일을 작성할 때 <code>&lt;meta&gt;</code>태그의 charset 어트리뷰트에 지정된 인코딩 방식을 통해 문자열의 형태로 변환된다.</p>
<h2 id="html-파싱">HTML 파싱</h2>
<h3 id="토크나이징">토크나이징</h3>
<p>인코딩을 통해 문자열 형태로 변환된 HTML 파일을 문법적 의미를 갖는 최소한의 단위인 토큰으로 분해한다. 이를 토크나이징이라 한다. </p>
<h3 id="dom-트리-생성">DOM 트리 생성</h3>
<p>앞서 토크나이징을 통해 생성된 토큰을 객체로 변환하여 노드를 생성한다. 노드의 종류로는 문서 노드,요소 노드,어트리뷰트 노드,텍스트 노드가 있다. 
이렇게 생성된 노드는 각각 중첩관계를 갖는다. </p>
<pre><code class="language-html">&lt;div&gt;
  &lt;span&gt;안녕하세요.&lt;/span&gt;
&lt;/div&gt;
</code></pre>
<p>이와 같이 <code>div</code>태그 안에 <code>span</code>태그가 있고 그 안에 <code>안녕하세요.</code>라는 텍스트가 있다면, <code>div</code>요소 노드 안에 <code>span</code> 요소 노드가 있고 그 안에는 <code>안녕하세요.</code>라는 텍스트 노드가 중첩되는 것이다.</p>
<h2 id="css-파싱">CSS 파싱</h2>
<p>이렇게 DOM 트리를 구성하는 과정에서 CSS를 연결하는 <code>link</code> 태그나 <code>style</code>태그를 만나면 DOM 생성을 일시 중단한다. 그리고 <code>link</code> 태그의 <code>href</code> 어트리뷰트에 지정된 CSS 파일을 서버로부터 불러오고 HTML을 파싱하는 것과 동일한 과정을 거쳐 스타일이 적용되는 구조를 나타내는<code>CSSOM 트리</code>를 생성한다.</p>
<h2 id="렌더링">렌더링</h2>
<h3 id="렌더트리-생성">렌더트리 생성</h3>
<p>앞서서 구축한 <code>DOM 트리</code>와 <code>CSSOM 트리</code>를 결합해 렌더트리를 생성한다. 렌더 트리는 페이지에 표시될 내용만 포함된다. <code>display:none</code>과 같은 속성이 적용된 태그나 <code>&lt;head&gt;</code>태그와 그 안에 있는 태그들은 <code>렌더트리</code>에 포함되지 않는다.</p>
<h3 id="레이아웃">레이아웃</h3>
<p>각 요소의 크기와 위치를 계산하는 레이아웃 작업이 이루어진다. 이때 마진,패딩,보더 등을 통해 박스 모델이 계산된다. 그리고 %나 rem,em과 같은 상대적인 값이 절대적인 픽셀로 변경된다.</p>
<h3 id="페인트">페인트</h3>
<p>그 후 실제 픽셀을 화면에 그리는 페인트 작업이 이루어진다. 레이아웃 단계에서 계산된 위치와 크기를 통해 요소를 렌더링한다.</p>
<h3 id="리플로우리페인트">리플로우/리페인트</h3>
<p>레이아웃 작업을 다시 하는 것을 리플로우라 한다. 리플로우가 일어나게 만드는 요소는 다음과 같다.</p>
<ol>
<li>요소 추가,제거,업데이트와 같은 DOM 조작</li>
<li>CSS 속성 변경 : width,height,margin,padding,border,position,top,left,right,bottom,display,float,overflow 등과 같은 속성이 변경될 경우 이루어진다.</li>
<li>브라우저의 창 크기가 변경되는 경우</li>
<li>폰트 변경 : font-family,font-size,font-weight 등</li>
<li>의사클래스 활성화 : :hover,:active,:focus 등</li>
<li>스크롤 : position:fixed가 있는 경우 적용된다.</li>
</ol>
<p>페인트 작업을 다시 하는 것을 리페인트라 한다. 리페인트가 일어나게 만드는 요소는 다음과 같다.</p>
<ol>
<li>가시성 변경 : Opacity,visibility</li>
<li>배경 변경 : background-color,background-image,background-position 등</li>
<li>외곽선 변경 : outline</li>
<li>색상 변경 : color</li>
<li>텍스트 변경 : text-decoration,text-shadow</li>
</ol>
<p>리플로우는 항상 리페인트를 동반한다. 하지만 리페인트는 항상 리플로우를 필요로 하지는 않는다. 또 리플로우는 요소의 위치와 간격을 다시 계산해야 하기 때문에 리페인트보다 더 많은 비용이 든다. 그렇기 때문에 성능 최적화를 위해 리플로우를 최소화하고 필요할 경우 리페인트만 발생하도록 하는 것이 좋다.</p>
<h2 id="javascript-파싱">Javascript 파싱</h2>
<p>CSS를 읽는 과정과 동일하게 렌더링엔진이 HTML파일을 읽어나가다 <code>&lt;script&gt;</code>태그를 만나면 DOM 생성을 일시 중단하고 <code>&lt;script&gt;</code> 태그의 <code>src</code> 어트리뷰트에 지정된 자바스크립트 파일을 서버에 요청하여 로드한다. 그리고 제어권을 자바스크립트 엔진에게 넘긴다. 자바스크립트의 파싱과 실행이 종료되면 다시 렌더링 엔진에게 제어권이 돌아오고 HTML 파싱이 중단된 지점부터  다시 DOM을 생성해 나간다. </p>
<p>이는 <code>&lt;script&gt;</code>태그가 body 태그 내에 들어가는 이유이기도 하다. 만약 자바스크립트 코드에서 아직 로드되지 않은 DOM 요소를 제어할 경우 오류가 발생하는데 이를 막기 위해 <code>&lt;script&gt;</code>태그는 항상 <code>body</code>의 가장 마지막 부분인 <code>&lt;/body&gt;</code>태그 직전에 작성해야 한다.</p>
<p>자바스크립트 엔진은 코드를 해석해서 AST(abstract syntax tree)를 생성한다. 그리고 이를 기반으로 인터프리터가 실행할 수 있는 중간 코드인 바이트 코드를 생성 및 실행한다.</p>
<p>1.자바스크립트 코드가 주어진다.
2.단순한 텍스트 상태의 코드를 문법적 의미를 갖는 최소 단위인 토큰으로 나눈다.
3.나누어진 토큰에 문법적 의미와 구조를 반영한 트리인 AST를 생성한다.
4.AST를 바이트코드 생성기를 통해 바이트코드로 변환한다.
5.변환된 바이트코드를 인터프리터가 실행시킨다.</p>
<p>출처 - Modern Javascript Deep Dive</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Storybook Preview의 decorators로 Provider component 적용하기]]></title>
            <link>https://velog.io/@silver_hq/Storybook-Preview%EC%9D%98-decorators%EB%A1%9C-%EB%AA%A8%EB%93%A0-Story%EC%97%90-Provider-component-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@silver_hq/Storybook-Preview%EC%9D%98-decorators%EB%A1%9C-%EB%AA%A8%EB%93%A0-Story%EC%97%90-Provider-component-%EC%A0%81%EC%9A%A9%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sun, 22 Sep 2024 15:59:23 GMT</pubDate>
            <description><![CDATA[<p>Storybook을 작성하던 중 recoil,react-router-dom,tanstack-query와 같이 provider component로 감싸야하는 컴포넌트의 스토리를 작성하게 됐다.</p>
<p>처음엔 각 스토리의 decorators에서 필요한 프로바이더 컴포넌트로 Story를 감싸도록 설정했다. 하지만 작성하다보니 이러한 작업이 중복되는 컴포넌트들이 존재했다. 그래서 모든 Story에 대해 decorators를 적용할 방법을 찾아봤고, Storybook 폴더의 Preview파일을 통해 설정할 수 있다는 것을 알게 됐다.</p>
<h2 id="previewts를-previewtsx로-변경">Preview.ts를 Preview.tsx로 변경</h2>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/3902363e-3536-4b29-bf7d-072ebfdfdeb0/image.png" alt="">
우선 Preview 파일에서 decorators에서 tsx 문법을 사용하려면 Preview.ts 파일을 Preview.tsx로 파일명을 변경해야 한다.</p>
<p><img src="https://velog.velcdn.com/images/silver_hq/post/0ceebb98-7b3a-428e-9030-7a1b9776bd83/image.jpg" alt=""></p>
<p>이 과정에서 위와 같은 오류가 발생했는데, Storybook이 preview파일의 확장자가 tsx로 설정되면 읽지 못하는 건가? 하고 Storybook의 빌드옵션을 변경해보았지만 해결되지 않았다. 알고보니 <strong>파일 확장자를 변경한 다음 storybook 개발 서버를 다시 실행하지 않아서 생긴 문제였다.</strong></p>
<p>Webpack이 처음 Storybook 개발 서버를 실행할 당시의 파일명을 읽어서 해당 파일들의 변경사항을 감지해 업데이트한다. 
하지만 파일명이 변경되면 개발 서버를 실행할 때와 파일명이 달라지기 때문에 서버를 새로 실행해야하는 것이다.</p>
<h2 id="decorators-설정">decorators 설정</h2>
<p>decorators필드는 리액트 컴포넌트를 반환하는 콜백함수를 입력받는다. 콜백함수의 파라미터로는 decorators를 통해 감싸고자하는 Story컴포넌트와 args,argTypes,globals,parameters등의 context를 입력받는다.</p>
<p>Preview 파일에선 모든 story에 대한 decorators를 설정할 것이기 때문에 별도의 context는 입력하지 않고 개별 Story에서 입력해준다.</p>
<pre><code class="language-tsx">const preview : Preview = {
  ...
decorators : [
      (Story)=&gt; {
      return (
        &lt;Component&gt;
          &lt;Story/&gt;
        &lt;/Component&gt;
      )
    },
  ]
}</code></pre>
<p>하나의 콜백함수를 입력할 수도 있지만 이처럼 배열형태로 입력할 수 있는데, 배열에서 뒤에 있는 요소가 앞에 있는 요소를 감싸는 형태로 이루어진다. 예를들어,</p>
<pre><code class="language-tsx">
decorators : [
      (Story)=&gt; {
      return (
        &lt;section&gt;
          &lt;Story/&gt;
        &lt;/section&gt;
      )
    },
  (Story)=&gt; {
      return (
        &lt;main&gt;
          &lt;Story/&gt;
        &lt;/main&gt;
      )
  },
  ]</code></pre>
<p>이와 같이 작성하면 </p>
<pre><code class="language-html">&lt;main&gt;
  &lt;section&gt;
    &lt;Story/&gt;
  &lt;section/&gt;
&lt;main/&gt;</code></pre>
<p>이와 같이 main 태그 안에 section 태그가 있고 그 안에 Story 컴포넌트가 감싸지는 형태로 렌더링 된다.</p>
<p>내가 사용한 기술 스택에서 필요로 하는 provider 컴포넌트를 아래와 같이 작성했다.</p>
<pre><code class="language-tsx">const preview: Preview = {
  parameters: {
    controls: {
      matchers: {
        color: /(background|color)$/i,
        date: /Date$/i,
      },
    },
  },
  decorators: [
    (Story) =&gt; {
      return (
        &lt;QueryClientProvider client={queryClient}&gt;
          &lt;MemoryRouter initialEntries=&quot;{[/]}&quot;&gt;
            &lt;RecoilRoot&gt;
              &lt;ThemeProvider theme={defaultTheme}&gt;
                &lt;GlobalStyle /&gt;
                &lt;Story /&gt;
              &lt;/ThemeProvider&gt;
            &lt;/RecoilRoot&gt;
          &lt;/MemoryRouter&gt;
        &lt;/QueryClientProvider&gt;
      );
    },
  ],
};
</code></pre>
<h2 id="recoilstate의-값이-필요한-경우">recoilState의 값이 필요한 경우</h2>
<p>Preview.tsx 파일에서 decorators를 통해 reocilRoot를 설정했지만, 실제로 recoilState를 사용해서 렌더링하는 컴포넌트가 있어서 이에 대한 추가 설정이 필요했다.
이는 아래 코드처럼 RecoilRoot로 감쌀 때 initializeState를 통해 컴포넌트에 사용되는 recoilState에 직접 값을 할당할 수 있다.</p>
<pre><code class="language-tsx">
decorators: [
    (Story) =&gt; {
      return (
        &lt;RecoilRoot
          initializeState={({ set }) =&gt; {
            set(shoppingCartAtom, shoppingCartProducts);
          }}
        &gt;
          &lt;Container&gt;
            &lt;ShoppingCartContainer&gt;
              &lt;ShoppingCart&gt;&lt;/ShoppingCart&gt;
              &lt;Story /&gt;
            &lt;/ShoppingCartContainer&gt;
          &lt;/Container&gt;
        &lt;/RecoilRoot&gt;
      );
    },
  ],
</code></pre>
<h2 id="tanstack-query-인스턴스를-사용하는-경우">Tanstack-query 인스턴스를 사용하는 경우</h2>
<p>Tanstack-query provider로 모든 스토리를 감싸주었지만, query 인스턴스를 통해 렌더링이 진행되는 컴포넌트의 경우 api호출이 발생하는데 실제 애플리케이션 환경과 다르기 때문에 정상적으로 데이터를 불러오지 못한다. 이를 해결하기 위해선 api 호출을 mocking해야하는데 이러한 작업을 도와주는 MSW라는 라이브러리를 사용해야 한다. 이는 E2E테스트를 진행할 때도 사용할 수 있을 거라 예상되서 다음 글은 MSW를 배워서 문제를 해결하고 이를 정리해서 글을 써보려 한다.</p>
<h2 id="react-router-dom-memoryrouter">react-router-dom memoryRouter</h2>
<p>react-router-dom의 useNavigate,link등과 같은 기능을 사용하는 Story를 작성할 땐 라우터 컨텍스트로 감싸져 있어야 한다. 하지만 기존에 RouterProvider는 router를 입력해서 url에 따라 페이지를 보여주는데, Storybook 환경에선 이러한 작업이 제한적이다. </p>
<p>이를 해결하기 위해 memoryRouter로 Story를 감싸줬다. memoryRouter는 브라우저 환경과 무관하게 메모리 내에서 경로를 관리하는 router이다. 주로 테스트와 같이 브라우저가 없거나 주소를 사용할 수 없는 경우에 사용할 수 있다. </p>
]]></description>
        </item>
    </channel>
</rss>