<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>choi-geon.log</title>
        <link>https://velog.io/</link>
        <description>개발이 즐거운 백엔드 개발자 </description>
        <lastBuildDate>Thu, 15 Jan 2026 02:44:43 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>choi-geon.log</title>
            <url>https://velog.velcdn.com/images/cchoi-geon/profile/41c61d50-af40-4154-8a02-48354f61d2b3/social_profile.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. choi-geon.log. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/cchoi-geon" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[RORO 패턴]]></title>
            <link>https://velog.io/@cchoi-geon/RORO-%ED%8C%A8%ED%84%B4</link>
            <guid>https://velog.io/@cchoi-geon/RORO-%ED%8C%A8%ED%84%B4</guid>
            <pubDate>Thu, 15 Jan 2026 02:44:43 GMT</pubDate>
            <description><![CDATA[<h2 id="roro-패턴이란">RORO 패턴이란?</h2>
<p><strong>RORO = Receive an Object, Return an Object</strong></p>
<ul>
<li>함수의 파라미터를 여러 개 나열하지 않고, 하나의 객체(Object)로 받고, 반환값도 객체(Object)로 반환하는 패턴</li>
</ul>
<h3 id="왜-쓰는가">왜 쓰는가?</h3>
<ol>
<li>가독성 증가</li>
<li>파라미터 순서 의존 제거</li>
<li>확장에 강함</li>
<li>TypeScript와 매우 궁합이 좋음</li>
</ol>
<p>❌ 안 좋은 예</p>
<pre><code class="language-ts">findVenueParticipationByUserAndDate(
  venueId: number,
  userId: number,
  scheduledDate: string,
) </code></pre>
<p>⭕ RORO 적용 예</p>
<pre><code class="language-ts">findVenueParticipationByUserAndDate({
  venueId,
  userId,
  scheduledDate,
}: FindVenueParticipationByUserAndDateParams)</code></pre>
<hr>
<h2 id="roro-패턴-적용해보기">RORO 패턴 적용해보기</h2>
<h3 id="기존-호출-코드">기존 호출 코드</h3>
<pre><code class="language-ts">const participation =
      await this.venueParticipationRepository.findVenueParticipationByUserAndDate(
        venueId,
        userId,
        scheduledDate,
      );

...

async findVenueParticipationByUserAndDate(
  venueId: number,
  userId: number,
  scheduledDate: string,
) {
  return await this.venueParticipationRepository.findOne({
    where: {
      venue: {
        id: venueId,
      },
      user: {
        id: userId,
      },
      scheduledDate: new Date(scheduledDate),
    },
  });
}</code></pre>
<h3 id="roro-패턴-적용-후-호출-코드">RORO 패턴 적용 후 호출 코드</h3>
<pre><code class="language-ts">const participation =
      await this.venueParticipationRepository.findVenueParticipationByUserAndDate({
        venueId,
        userId,
        scheduledDate,
      });

...

interface FindVenueParticipationByUserAndDateParams {
  venueId: number;
  userId: number;
  scheduledDate: string;
}

async findVenueParticipationByUserAndDate({
  venueId,
  userId,
  scheduledDate,
}: FindVenueParticipationByUserAndDateParams) {
  return await this.venueParticipationRepository.findOne({
    where: {
      venue: {
        id: venueId,
      },
      user: {
        id: userId,
      },
      scheduledDate: new Date(scheduledDate),
    },
  });
}
</code></pre>
<hr>
<h2 id="네이밍-규칙-추천">네이밍 규칙 추천</h2>
<table>
<thead>
<tr>
<th>목적</th>
<th>추천 네이밍</th>
</tr>
</thead>
<tbody><tr>
<td>Repository 입력</td>
<td><code>~Params</code></td>
</tr>
<tr>
<td>Service 입력</td>
<td><code>~Command</code></td>
</tr>
<tr>
<td>조회 조건</td>
<td><code>~Query</code></td>
</tr>
<tr>
<td>반환값</td>
<td><code>~Result</code></td>
</tr>
</tbody></table>
<h3 id="예시">예시</h3>
<pre><code class="language-ts">SaveClusterCommentParams
FindClusterCommentsQuery
CreateClusterCommentResult</code></pre>
<hr>
<h2 id="언제-roro를-쓰는-것이-좋을까">언제 RORO를 쓰는 것이 좋을까?</h2>
<p>✔ 파라미터가 <strong>3개 이상일 경우</strong>
✔ 나중에 <strong>확장될 가능성 있을 경우</strong>
✔ TypeScript 사용 중일 경우</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(AWS) AWS Lightsail 메모리 이슈]]></title>
            <link>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%9D%B4%EC%8A%88</link>
            <guid>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-%EB%A9%94%EB%AA%A8%EB%A6%AC-%EC%9D%B4%EC%8A%88</guid>
            <pubDate>Thu, 04 Sep 2025 14:25:33 GMT</pubDate>
            <description><![CDATA[<h1 id="1-문제-상황-설명">1) 문제 상황 설명</h1>
<ul>
<li><p><strong>증상</strong>: <code>nest build</code> 중 아래와 같은 OOM 오류로 프로세스 종료</p>
<pre><code>FATAL ERROR: Ineffective mark-compacts near heap limit
Allocation failed - JavaScript heap out of memory</code></pre></li>
<li><p><strong>환경</strong>: AWS Lightsail (RAM 512MB / vCPU 2 / SSD 20GB)</p>
</li>
<li><p><strong>원인</strong>: TypeScript 타입체크+트랜스파일 단계에서 <strong>Node.js 힙 사용량이 시스템 메모리 한도(≈512MB)와 스왑을 넘어섬</strong> → GC가 메모리 회수 불가 → <strong>heap out of memory</strong> 발생.</p>
</li>
</ul>
<hr>
<h1 id="2-내가-사용한-해결-방법">2) 내가 사용한 해결 방법</h1>
<h2 id="2-1-인스턴스-업그레이드">2-1. 인스턴스 업그레이드</h2>
<ul>
<li>Lightsail 사양을 상향(<strong>≥2GB RAM 권장</strong>)하여 빌드 메모리 여유를 확보.</li>
<li>업그레이드 후 <strong>빌드가 정상 완료</strong>됨.</li>
</ul>
<h2 id="2-2-swc-기반-빌드로-전환속도메모리-개선">2-2. SWC 기반 빌드로 전환(속도/메모리 개선)</h2>
<ul>
<li><p>목적: 빌드 속도와 메모리 사용량을 줄여 <strong>개발 및 CI 안정화</strong>.</p>
</li>
<li><p>적용 사항:</p>
<ol>
<li><p>패키지 설치</p>
<pre><code class="language-bash">npm i -D @swc/cli @swc/core</code></pre>
</li>
<li><p><code>nest-cli.json</code></p>
<pre><code class="language-json">{ &quot;compilerOptions&quot;: { &quot;builder&quot;: &quot;swc&quot; } }</code></pre>
<p>(또는 <code>nest build --builder swc</code>, <code>nest start -b swc</code>)</p>
</li>
<li><p>타입체크 필요 시 병렬 수행</p>
<ul>
<li><p>CLI: <code>nest start -b swc --type-check</code></p>
</li>
<li><p>설정:</p>
<pre><code class="language-json">{ &quot;compilerOptions&quot;: { &quot;builder&quot;: &quot;swc&quot;, &quot;typeCheck&quot;: true } }</code></pre>
</li>
</ul>
</li>
<li><p><code>.swcrc</code>(선택)</p>
<pre><code class="language-json">{
  &quot;sourceMaps&quot;: true,
  &quot;jsc&quot;: { &quot;parser&quot;: { &quot;syntax&quot;: &quot;typescript&quot;, &quot;decorators&quot;: true, &quot;dynamicImport&quot;: true } },
  &quot;minify&quot;: false
}</code></pre>
</li>
<li><p>TS 옵션 권장: <code>skipLibCheck: true</code>, <code>incremental: true</code></p>
</li>
</ol>
</li>
</ul>
<hr>
<h1 id="3-swc-빌드-시-새로-발생했던-의존성-문제">3) (SWC 빌드 시) 새로 발생했던 의존성 문제</h1>
<ul>
<li><p><strong>에러</strong>:</p>
<pre><code>ReferenceError: Cannot access &#39;Product&#39; before initialization</code></pre></li>
<li><p><strong>상황</strong>: 엔티티 간 <strong>양방향 정적 import</strong>(순환 참조) + 데코레이터 메타데이터 조합.</p>
</li>
<li><p><strong>원인 분석</strong>:</p>
<ul>
<li><code>tsc</code>는 CommonJS 변환에서 순환 참조를 비교적 “관대하게” 처리하는 반면,</li>
<li><code>swc</code>는 초기화 순서에 엄격하여 <strong>아직 초기화 전 클래스에 접근</strong>하면 <code>ReferenceError</code> 발생.</li>
<li>TypeORM의 엔티티 양방향 관계(예: <code>Product</code> ↔ <code>ProductMapping</code>)에서 자주 노출.</li>
</ul>
</li>
</ul>
<p>예)</p>
<pre><code class="language-ts">// product.entity.ts
import { ProductMapping } from &#39;./product_mapping.entity&#39;;
@OneToMany(() =&gt; ProductMapping, m =&gt; m.product) mappings: ProductMapping[];

// product_mapping.entity.ts
import { Product } from &#39;./product.entity&#39;;
@ManyToOne(() =&gt; Product, p =&gt; p.mappings) product: Product;</code></pre>
<hr>
<h1 id="4-문제-해결-방법-swc-의존성초기화-이슈">4) 문제 해결 방법 (SWC 의존성/초기화 이슈)</h1>
<h2 id="4-1-지연-평가lazy로-순환-절단">4-1. 지연 평가(Lazy)로 순환 절단</h2>
<ul>
<li><strong>정적 import 제거</strong> → <strong>런타임 <code>require</code> + 람다</strong>로 참조 지연:</li>
</ul>
<pre><code class="language-ts">// BEFORE
@ManyToOne(() =&gt; Product, p =&gt; p.mappings)

// AFTER
@ManyToOne(() =&gt; (require(&#39;./product.entity&#39;).Product), p =&gt; p.mappings)</code></pre>
<ul>
<li>동일 패턴을 모든 순환 관계에 적용(<code>Shop</code>, <code>User</code>, <code>Review</code>, <code>Image</code> 등).</li>
</ul>
<h2 id="4-2-양방향-축소--조인-엔티티로-단순화">4-2. 양방향 축소 &amp; 조인 엔티티로 단순화</h2>
<ul>
<li><p><code>Product</code> ↔ <code>Shop</code>의 <code>ManyToMany</code> 제거.</p>
</li>
<li><p>**명시적 조인 엔티티(<code>product_mapping</code>)**로만 연결:</p>
<ul>
<li>중복 관계 제거, 순환 위험 해제, 확장 속성(예: 가격/상태) 부여 용이.</li>
</ul>
</li>
</ul>
<h2 id="4-3-타입-의존성-최소화">4-3. 타입 의존성 최소화</h2>
<ul>
<li><p><strong>type-only import</strong>로 런타임 의존성 제거:</p>
<pre><code class="language-ts">import type { Product } from &#39;./product.entity&#39;;</code></pre>
</li>
<li><p>관계 필드 타입은 <strong>필요 시 <code>any</code>/<code>any[]</code>로 완화</strong>
(런타임에는 데코레이터 메타데이터가 타입 정보를 제공하므로 동작에 문제 없음. 서비스/DTO 레이어에서 타입 안전성 보완).</p>
</li>
</ul>
<h2 id="4-4-ts빌드-설정-점검">4-4. TS/빌드 설정 점검</h2>
<ul>
<li><p><code>tsconfig.json</code></p>
<pre><code class="language-json">{ &quot;compilerOptions&quot;: { &quot;experimentalDecorators&quot;: true, &quot;emitDecoratorMetadata&quot;: true } }</code></pre>
</li>
<li><p><code>.swcrc</code>에서 <code>decorators: true</code> 확인.</p>
</li>
<li><p>경로 별칭(tsconfig <code>paths</code>)을 사용 중이면 <strong>해결 순서가 순환을 강화하지 않도록</strong> import 정리.</p>
</li>
</ul>
<h2 id="4-5-파일별-핵심-변경-요약">4-5. 파일별 핵심 변경 요약</h2>
<ul>
<li><p><strong><code>product.entity.ts</code></strong>: 다른 엔티티에 대한 직접 관계 제거(필요 최소 컬럼만 유지).</p>
</li>
<li><p><strong><code>product_mapping.entity.ts</code></strong>:</p>
<pre><code class="language-ts">@ManyToOne(() =&gt; (require(&#39;./product.entity&#39;).Product), ...) 
@ManyToOne(() =&gt; (require(&#39;./shop.entity&#39;).Shop), ...)
// FK 컬럼 유지, onDelete: &#39;CASCADE&#39;</code></pre>
</li>
<li><p><strong><code>shop.entity.ts</code></strong>: <code>Product</code>와의 직접 ManyToMany 제거. 기존 OneToMany(운영시간/리뷰/매핑)는 유지.</p>
</li>
<li><p><strong><code>operating-hours.entity.ts</code></strong>:
<code>@ManyToOne(() =&gt; (require(&#39;./shop.entity&#39;).Shop), ...)</code></p>
</li>
<li><p><strong><code>review.entity.ts</code></strong>:
<code>User</code>, <code>Shop</code>, <code>Image</code>에 대해 <code>require</code> 지연 + 관계 타입 완화.</p>
</li>
<li><p><strong><code>image.entity.ts</code>, <code>user.entity.ts</code>, <code>wishlist.entity.ts</code>, <code>region.entity.ts</code>, <code>submit-user.entity.ts</code></strong>:
모두 동일하게 <strong><code>require</code> 지연 참조</strong>와 <strong>타입 완화</strong> 적용.</p>
</li>
</ul>
<h2 id="결론">결론</h2>
<ul>
<li><strong>메모리 부족 문제</strong>는 인스턴스 업그레이드로 즉시 해소.</li>
<li><strong>SWC 전환</strong>으로 빌드 성능이 향상되었으나, <strong>엔티티 순환 참조로 인한 초기화 에러</strong>가 발생.</li>
<li>이를 <strong>지연 평가(require) 패턴</strong>, <strong>양방향 관계 축소/조인 엔티티화</strong>, <strong>type-only import/타입 완화</strong>, <strong>데코레이터/메타데이터 설정 유지</strong>로 해결하여 <strong>SWC 빌드 안정화</strong>를 달성했습니다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[회사에서 사용했던 방법 - 코드 우선]]></title>
            <link>https://velog.io/@cchoi-geon/%ED%9A%8C%EC%82%AC%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%96%88%EB%8D%98-%EB%B0%A9%EB%B2%95-%EC%BD%94%EB%93%9C-%EC%9A%B0%EC%84%A0</link>
            <guid>https://velog.io/@cchoi-geon/%ED%9A%8C%EC%82%AC%EC%97%90%EC%84%9C-%EC%82%AC%EC%9A%A9%ED%96%88%EB%8D%98-%EB%B0%A9%EB%B2%95-%EC%BD%94%EB%93%9C-%EC%9A%B0%EC%84%A0</guid>
            <pubDate>Thu, 28 Aug 2025 15:33:33 GMT</pubDate>
            <description><![CDATA[<h1 id="코드-우선-방식-경험-정리">코드 우선 방식 경험 정리</h1>
<h2 id="1-내가-사용한-방법">1. 내가 사용한 방법</h2>
<ul>
<li>백엔드는 <strong>코드 우선(Code-First)</strong> 방식으로 개발하고, <strong>완료된 API를 Swagger에 반영</strong>하는 형태로 진행.</li>
<li>프론트엔드(Flutter)는 <strong>UI 화면을 먼저 제작</strong>한 뒤, 각 페이지에서 필요한 데이터는 <strong>텍스트 하드코딩</strong>으로 채워 넣음.</li>
<li>이후 실제 API가 나오면 하드코딩 데이터를 제거하고 API 연동 코드로 교체.</li>
<li>데이터 처리를 위해 <strong>UI 기준으로 모델 Class</strong>를 만들고 사용했음.</li>
</ul>
<hr>
<h2 id="2-문제점">2. 문제점</h2>
<ol>
<li><strong>하드코딩 의존성</strong><ul>
<li>초기 화면 제작을 위해 데이터 하드코딩을 많이 사용하다 보니, 실제 API를 붙일 때 교체 과정이 복잡하고 실수가 잦았음.</li>
</ul>
</li>
<li><strong>API 정의 완료 시점 지연</strong><ul>
<li>API 정의가 백엔드 개발 완료 후에야 Swagger로 공유되었기 때문에, 프론트는 UI를 먼저 개발한 다음에 API를 붙일 수 있었음.</li>
</ul>
</li>
<li><strong>UI 중심 모델과 API Response 불일치</strong><ul>
<li>Flutter에서 UI를 기준으로 모델 Class를 설계했기 때문에, 실제 서버 API Response와 구조가 달라 오류가 발생함.</li>
<li>필요 없는 데이터가 포함되거나, 필요한 데이터가 누락되는 문제가 자주 생겼음.</li>
</ul>
</li>
</ol>
<hr>
<h2 id="3-해결-방법">3. 해결 방법</h2>
<ol>
<li><strong>API 우선 개발하기</strong><ul>
<li>백엔드 구현 전에 최소한의 API 스펙(OpenAPI 문서)을 먼저 정의하고 공유.</li>
<li>이를 기반으로 프론트/백엔드가 병렬 개발 → 하드코딩 의존 줄이기.</li>
</ul>
</li>
<li><strong>코드 생성(Codegen) 도입</strong><ul>
<li>OpenAPI 문서를 기반으로 <strong>Flutter용 모델 Class와 API Client 자동 생성</strong>.</li>
<li>수동으로 UI 전용 모델을 만들지 않고, API Response 스펙을 기준으로 사용.</li>
</ul>
</li>
<li><strong>Mock 서버 활용</strong><ul>
<li>하드코딩 대신 <strong>Prism Mock, MSW(Mock Service Worker), Flutter용 Fake Repository</strong> 등을 이용.</li>
<li>프론트는 항상 “API 레이어”를 통해 데이터를 가져오게 만들고, 실제 API가 나오면 엔드포인트만 교체.</li>
</ul>
</li>
</ol>
<h2 id="느낀점">느낀점</h2>
<p>첫 프론트엔드 작업이다 보니 시행착오가 많았다. 특히 UI에 보이는 데이터를 기준으로 모델 Class를 설계한 것이 가장 큰 실수였다. 처음부터 Swagger에 정의된 API 스펙을 참고했다면 실수도 줄고 개발 속도도 더 빨랐을 텐데, “UI를 빨리 완성하고 싶다”는 마음에 무리하게 하드코딩에 의존했던 것 같다.
또한 <em>“실제 API가 나오면 그때 바꾸면 되겠지”</em>라는 막연한 생각으로 접근했지만, 그 결과 교체 과정에서 많은 오류와 비효율이 발생했다. 결국 요구사항에 맞는 UI만 우선 구현하고 뒤에서 맞추려다 보니 후폭풍이 컸다.
이번 경험을 통해 앞으로는 하드코딩을 최소화하되 필요한 경우에는 단순 UI 확인용으로만 제한적으로 사용해야 한다는 점을 깨달았다. 실제 API가 준비되면 바로 연동할 수 있도록, 중요 UI와 API 기반 구조를 함께 고려하며 개발하는 것이 더 효율적이라는 것을 배웠다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(AWS) S3 Public Bucket  문제점 ]]></title>
            <link>https://velog.io/@cchoi-geon/AWS-S3-Public-Bucket-%EB%AC%B8%EC%A0%9C%EC%A0%90</link>
            <guid>https://velog.io/@cchoi-geon/AWS-S3-Public-Bucket-%EB%AC%B8%EC%A0%9C%EC%A0%90</guid>
            <pubDate>Mon, 18 Aug 2025 09:17:59 GMT</pubDate>
            <description><![CDATA[<h1 id="s3-public-bucket-사용-시-문제점-정리">S3 Public Bucket 사용 시 문제점 정리</h1>
<h2 id="1-보안-위험">1. 보안 위험</h2>
<ul>
<li><strong>누구나 접근 가능</strong>: URL만 알면 로그인/권한 검증 없이 모든 사람이 이미지에 접근할 수 있음.</li>
<li><strong>데이터 유출</strong>: 리뷰 이미지에 개인정보·민감한 데이터가 포함될 경우, 외부 유출 위험이 큼.</li>
<li><strong>링크 유출 회수 불가</strong>: 한 번 노출된 퍼블릭 URL은 삭제 전까지 계속 유효 → 접근 통제 불가능.</li>
</ul>
<hr>
<h2 id="2-비용운영-문제">2. 비용/운영 문제</h2>
<ul>
<li><strong>무단 트래픽 발생</strong>: 다른 사이트에서 이미지 핫링킹이 가능 → 불필요한 S3 egress 비용 발생.</li>
<li><strong>트래픽 폭주 대응 어려움</strong>: 퍼블릭으로 열려 있으면 DDoS나 무단 다운로드에도 그대로 노출.</li>
<li><strong>캐시 제어 한계</strong>: 버킷에서 내려주는 응답 헤더 제어가 제한적이라, 캐시 정책 관리가 어렵다.</li>
</ul>
<hr>
<h2 id="3-관리-및-확장성-부족">3. 관리 및 확장성 부족</h2>
<ul>
<li><strong>권한 제어 불가</strong>: 사용자별/권한별로 접근 허용 범위를 설정할 수 없음.</li>
<li><strong>접근 로그/감사 어려움</strong>: Presigned URL이나 CloudFront와 달리, “누가 접근했는지” 추적이 힘듦.</li>
<li><strong>운영 정책 위반 위험</strong>: 보안 규정을 지켜야 하는 서비스(예: 개인정보 처리, 기업 규정)에서는 Public Bucket이 문제 소지가 큼.</li>
</ul>
<hr>
<h1 id="각-문제에-대한-해결-방법">각 문제에 대한 해결 방법</h1>
<h2 id="1-보안-위험-1">1. 보안 위험</h2>
<h3 id="누구나-접근-가능">누구나 접근 가능</h3>
<p><strong>해결</strong>:</p>
<ul>
<li>S3 <strong>모든 퍼블릭 액세스 차단(Block Public Access ON)</strong>, ACL 비활성/<code>Bucket owner enforced</code>.</li>
<li>공개 정책/퍼블릭 ACL 제거 → <strong>직접 URL 접근 불가</strong>.</li>
<li>오직 <strong>서버가 발급한 Presigned GET/PUT</strong>(짧은 TTL)으로만 접근.</li>
</ul>
<h3 id="데이터-유출민감-이미지-포함-가능">데이터 유출(민감 이미지 포함 가능)</h3>
<p><strong>해결(완화)</strong>:</p>
<ul>
<li>Presigned <strong>짧은 TTL(예: 3~5분)</strong> + <strong>서버 인증/인가 통과 후 발급</strong>.</li>
<li><strong>키 설계</strong>: <code>users/{uuid}/avatar/...</code>, <code>review/{date}/...</code> 처럼 <strong>소유자/용도별 prefix</strong>.</li>
<li><strong>prefix 검증</strong>: <code>PATCH /users/me</code> 등에서 전달된 <code>profileImgKey</code>가 <strong>내 prefix로 시작하는지</strong> 확인.</li>
<li><strong>MIME 화이트리스트</strong>(예: <code>image/jpeg/png/webp</code>) + (선택) 업로드 후 <strong>HEAD 검사/AV 스캔/리사이즈</strong>.</li>
</ul>
<h3 id="링크-유출-회수-불가">링크 유출 회수 불가</h3>
<p><strong>해결</strong>:</p>
<ul>
<li>Presigned는 <strong>만료되면 자동 무효</strong> → 유출 창구를 시간으로 제한.</li>
<li>즉시 차단이 필요하면 <strong>객체 삭제</strong> 또는 <strong>키 변경</strong>.</li>
<li>(규모↑ 시) CloudFront <strong>Signed Cookie/URL</strong>로 세션 단위 통제도 가능.</li>
</ul>
<hr>
<h2 id="2-비용운영-문제-1">2. 비용/운영 문제</h2>
<h3 id="무단-트래픽핫링킹로-egress-비용-증가">무단 트래픽(핫링킹)로 egress 비용 증가</h3>
<p><strong>해결</strong>:</p>
<ul>
<li>버킷 Private 전환 → <strong>퍼블릭 핫링킹 원천 차단</strong>.</li>
<li>Presigned는 서버 통과 후 발급되므로 <strong>레이트 리밋/WAF</strong>로 제어 가능.</li>
</ul>
<h3 id="트래픽-폭주-대응-어려움">트래픽 폭주 대응 어려움</h3>
<p><strong>해결</strong>:</p>
<ul>
<li>Presigned 발급 API에 <strong>레이트 리밋/토큰 버킷</strong> 적용.</li>
<li>업로드/뷰 모두 <strong>파일 크기 제한</strong> 및 <strong>동시 업로드 제한</strong>.</li>
</ul>
<hr>
<h2 id="3-관리-및-확장성-부족-1">3. 관리 및 확장성 부족</h2>
<h3 id="권한-제어-불가">권한 제어 불가</h3>
<p><strong>해결</strong>:</p>
<ul>
<li><p><strong>DB에는 key만 저장</strong>, URL 저장 금지.</p>
</li>
<li><p>접근은 항상 <strong>서버 인증 → 소유자 확인 → Presigned 발급</strong> 순서.</p>
</li>
<li><p><strong>IAM 최소 권한</strong>(경로 스코프) 부여:</p>
<pre><code class="language-jsx">  {
    &quot;Effect&quot;: &quot;Allow&quot;,
    &quot;Action&quot;: [&quot;s3:GetObject&quot;,&quot;s3:PutObject&quot;,&quot;s3:DeleteObject&quot;],
    &quot;Resource&quot;: [
      &quot;arn:aws:s3:::&lt;BUCKET&gt;/users/*&quot;,
      &quot;arn:aws:s3:::&lt;BUCKET&gt;/review/*&quot;
    ]
  }
</code></pre>
</li>
<li><p>리소스 스코프형 API:
<code>POST /users/me/avatar/presigned</code> → <code>PATCH /users/me/avatar</code> → <code>GET /users/me/avatar</code>.</p>
</li>
</ul>
<h3 id="접근-로그감사-어려움">접근 로그/감사 어려움</h3>
<p><strong>해결</strong>:</p>
<ul>
<li><strong>애플리케이션 로그</strong>에 Presigned 발급 이벤트 기록(누가/어떤 key/TTL/IP).</li>
<li>S3 <strong>CloudTrail Data Events</strong>(GetObject/PutObject) 또는 <strong>Server Access Logs</strong> 활성화.</li>
<li>(CDN 사용 시) CloudFront <strong>액세스 로그</strong>로 조회 트래킹.</li>
</ul>
<h3 id="운영-정책-위반-위험보안-규정">운영 정책 위반 위험(보안 규정)</h3>
<p><strong>해결</strong>:</p>
<ul>
<li><p><strong>기본 암호화</strong>(SSE-S3 또는 KMS) 활성화.</p>
</li>
<li><p><strong>Lifecycle 정책</strong>으로 오래된 원본 <strong>IA/Glacier</strong> 이동.</p>
</li>
<li><p>파일명/메타에 <strong>PII 포함 금지</strong>, 업로드 후 <strong>정책 기반 검사</strong>(필요 시).</p>
</li>
<li><p>버킷 정책에 <strong>HTTPS 강제 Deny</strong> 추가:</p>
<pre><code class="language-jsx">  {
    &quot;Effect&quot;: &quot;Deny&quot;,
    &quot;Principal&quot;: &quot;*&quot;,
    &quot;Action&quot;: &quot;s3:*&quot;,
    &quot;Resource&quot;: [&quot;arn:aws:s3:::&lt;BUCKET&gt;&quot;,&quot;arn:aws:s3:::&lt;BUCKET&gt;/*&quot;],
    &quot;Condition&quot;: {&quot;Bool&quot;: {&quot;aws:SecureTransport&quot;: &quot;false&quot;}}
  }
</code></pre>
</li>
</ul>
<h1 id="s3-private-설정">S3 Private 설정</h1>
<ul>
<li>모든 퍼블릭 액세스 차단을 선택해서 만든다.
<img src="https://velog.velcdn.com/images/cchoi-geon/post/efb54451-1037-4585-94c2-c81150c9b127/image.png" alt=""></li>
</ul>
<hr>
<h1 id="i-am-설정">I AM 설정</h1>
<ul>
<li><a href="https://velog.io/@cchoi-geon/AWS-S3-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EA%B6%8C%ED%95%9C-%EC%84%A4%EC%A0%95">https://velog.io/@cchoi-geon/AWS-S3-%EC%83%9D%EC%84%B1-%EB%B0%8F-%EA%B6%8C%ED%95%9C-%EC%84%A4%EC%A0%95</a> 에서 s3 권한만 갖은 사용자 생성하기를 참고하면 된다.</li>
</ul>
<hr>
<h1 id="코드-설정">코드 설정</h1>
<h2 id="1-dto">1) DTO</h2>
<pre><code class="language-tsx">// dto/profile.dto.ts
import { ApiProperty, ApiPropertyOptional } from &#39;@nestjs/swagger&#39;;
import { IsIn, IsNotEmpty, IsOptional, IsString, Matches } from &#39;class-validator&#39;;
import { Transform } from &#39;class-transformer&#39;;

export class ProfileImagePresignedDTO {
  @ApiProperty({
    description: &#39;사용자가 업로드하려는 원본 파일명&#39;,
    example: &#39;profile.png&#39;,
  })
  @IsString()
  @IsNotEmpty()
  originalName: string;

  @ApiProperty({
    description: &#39;콘텐츠 타입(MIME). 허용되는 이미지 타입만 전달&#39;,
    enum: [&#39;image/jpeg&#39;, &#39;image/png&#39;, &#39;image/webp&#39;],
    example: &#39;image/png&#39;,
  })
  @IsString()
  @IsIn([&#39;image/jpeg&#39;, &#39;image/png&#39;, &#39;image/webp&#39;])
  contentType!: string;
}

export class PreSignedURLResponseDto {
  @ApiProperty({ description: &#39;DB에 저장할 S3 Key&#39;, example: &#39;users/&lt;uuid&gt;/avatar/20250818/xxxx.webp&#39; })
  key!: string;

  @ApiProperty({ description: &#39;S3로 직접 PUT할 Presigned URL&#39;, example: &#39;https://...X-Amz-Expires=300...&#39; })
  url!: string;

  @ApiProperty({ description: &#39;유효기간(초)&#39;, example: 300 })
  expiresIn!: number;

  constructor(args: { key: string; url: string; expiresIn: number }) {
    Object.assign(this, args);
  }
}

export class PresignedUserProfileImgResponseDto {
  @ApiPropertyOptional({ description: &#39;프로필 이미지(표시용) Presigned GET URL&#39;, example: &#39;https://...X-Amz-Expires=300...&#39; })
  profileImg?: string | null;

  @ApiProperty({ description: &#39;유효기간(초)&#39;, example: 300 })
  expiresIn!: number;

  constructor(profileImg: string | null, expiresIn: number) {
    this.profileImg = profileImg;
    this.expiresIn = expiresIn;
  }
}

export class UpdateProfileDto {
  @ApiPropertyOptional({ example: &#39;소소한유저&#39; })
  @IsOptional()
  @Transform(({ value }) =&gt; (typeof value === &#39;string&#39; ? value.trim() : value))
  @Matches(/^[^\\x00-\\x1F\\x7F]*$/, { message: &#39;제어 문자를 포함할 수 없습니다.&#39; })
  @Matches(/^(?!.*(&lt;script|&lt;\\/script&gt;|&lt;iframe|on\\w+=|javascript:|eval\\()).*$/i, { message: &#39;스크립트 또는 악성 코드를 포함할 수 없습니다.&#39; })
  @Matches(/^[A-Za-z0-9가-힣 _-]{2,20}$/, { message: &#39;닉네임은 2~20자&#39; })
  nickName?: string | null;

  @ApiPropertyOptional({ description: &#39;업로드 완료된 S3 Key&#39;, example: &#39;users/&lt;uuid&gt;/avatar/20250818/xxxx.webp&#39; })
  @IsOptional()
  profileImgKey?: string | null;
}
</code></pre>
<hr>
<h2 id="2-aws-service">2) AWS Service</h2>
<pre><code class="language-tsx">// aws/aws.service.ts
import {
  S3Client,
  PutObjectCommand,
  GetObjectCommand,
  HeadObjectCommand,
  DeleteObjectCommand,
} from &#39;@aws-sdk/client-s3&#39;;
import { getSignedUrl } from &#39;@aws-sdk/s3-request-presigner&#39;;
import { Injectable, BadRequestException, NotFoundException } from &#39;@nestjs/common&#39;;
import { ConfigService } from &#39;@nestjs/config&#39;;
import { randomUUID } from &#39;crypto&#39;;

const ALLOWED_IMAGE_MIME = [&#39;image/jpeg&#39;,&#39;image/png&#39;,&#39;image/webp&#39;]; // Common 폴더에서 관리

@Injectable()
export class AwsService {
  private readonly s3: S3Client;
  private readonly bucket: string;
  private readonly region: string;

  constructor(private readonly config: ConfigService) {
    this.region = this.config.get&lt;string&gt;(&#39;AWS_REGION&#39;)!;
    this.bucket = this.config.get&lt;string&gt;(&#39;AWS_BUCKET_NAME&#39;)!;
    this.s3 = new S3Client({
      region: this.region,
      credentials: {
        accessKeyId: this.config.get&lt;string&gt;(&#39;AWS_ACCESS_KEY&#39;)!,
        secretAccessKey: this.config.get&lt;string&gt;(&#39;AWS_SECRET_ACCESS_KEY&#39;)!,
      },
    });
  }

  /** 프로필 이미지 전용 Key 생성 */
  private generateReviewKey(originalName: string) {
    const safe = originalName.replace(/[^\w.\-]/g, &#39;_&#39;);
    const yyyy = new Date().toISOString().slice(0, 10).replace(/-/g, &#39;&#39;);
    return `review/${yyyy}/${Date.now()}-${safe}`;
  }

  /** PUT용 Presigned (키 지정) */
  async getPresignedPutUrlByKey(key: string, contentType: string, ttlSec = 300) {
    if (!ALLOWED_IMAGE_MIME.includes(contentType)) {
      throw new BadRequestException(`Unsupported contentType: ${contentType}`);
    }
    const cmd = new PutObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ContentType: contentType,
    });
    const url = await getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
    return { key, url, expiresIn: ttlSec };
  }

  /** GET용 Presigned (표시) */
  async getPresignedGetUrlByKey(key: string, ttlSec = 300, disposition: string = &#39;inline&#39;) {
    const cmd = new GetObjectCommand({
      Bucket: this.bucket,
      Key: key,
      ResponseContentDisposition: disposition,
    });
    return getSignedUrl(this.s3, cmd, { expiresIn: ttlSec });
  }

  /** 존재 확인(선택) */
  async assertObjectExists(key: string) {
    try {
      await this.s3.send(new HeadObjectCommand({ Bucket: this.bucket, Key: key }));
    } catch {
      throw new NotFoundException(&#39;S3 object not found&#39;);
    }
  }

  async deleteByKey(key: string) {
    await this.s3.send(new DeleteObjectCommand({ Bucket: this.bucket, Key: key }));
  }
}
</code></pre>
<hr>
<h2 id="3-users-service">3) Users Service</h2>
<pre><code class="language-jsx">// users/users.service.ts
import { ConflictException, Injectable, NotFoundException } from &#39;@nestjs/common&#39;;
import { AwsService } from &#39;../aws/aws.service&#39;;
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from &#39;./dto/profile.dto&#39;;

@Injectable()
export class UsersService {
  constructor(
    private readonly aws: AwsService,
    // private readonly userRepository: UserRepository, // 실제 구현 주입
  ) {}

  /** 업로드용 Presigned 발급 */
  async createProfileImagePresignedUrl(profileImagePresignedDTO: ProfileImagePresignedDTO, uuid: string) {
    const { originalName, contentType } = profileImagePresignedDTO;
    const result = await this.awsService.getPresignedPutUrl(originalName, contentType);

    return new PreSignedURLResponsesDTO(result);
  }

  /** 프로필 정보 업데이트(닉네임/프로필 이미지 키) */
  async updateUserProfile(update: UpdateProfileDto, uuid: string) {
    const { nickName, profileImgKey } = update;

    if (nickName) {
      const exists = await this.userRepository.findUserByNickName(nickName);
      if (exists) throw new ConflictException(&#39;Nickname already exists.&#39;);
      const r = await this.userRepository.updateNickName(uuid, nickName);
      if (r.affected === 0) throw new NotFoundException(&#39;Fail update nickname&#39;);
    }

    if (profileImgKey) {
      // (선택) 실제 존재 확인
      await this.aws.assertObjectExists(profileImgKey);
      const updateUrl = await this.userRepository.updateUserPhotoUrl(uuid, profileImgKey);
      if (updateUrl.affected == 0) throw new NotFoundException(&#39;Fail update profileImg&#39;);
    }
  }

  /** 표시용 Presigned GET */
  async getPresignedURLUserProfileImg(uuid: string) {
    const user = await this.userRepository.findUserByUUID(uuid);
    if (!user) throw new NotFoundException(&#39;Not Found User&#39;);

    const expiresIn = 300;
    const url = await this.aws.getPresignedGetUrlByKey(user.photoUrl, expiresIn, &#39;inline&#39;);
    return new PresignedUserProfileImgResponseDto(url, expiresIn);
  }
}
</code></pre>
<hr>
<h2 id="4-users-controller">4) Users Controller</h2>
<pre><code class="language-jsx">// users/users.controller.ts
import { Body, Controller, Get, Patch, Post, UseGuards } from &#39;@nestjs/common&#39;;
import { ApiBearerAuth, ApiExtraModels, ApiOkResponse, ApiOperation, getSchemaPath } from &#39;@nestjs/swagger&#39;;
import { JwtAuthGuard } from &#39;../auth/jwt.guard&#39;;
import { GetUUID } from &#39;../common/decorators/get-uuid.decorator&#39;;
import { UsersService } from &#39;./users.service&#39;;
import { ProfileImagePresignRequestDto, PreSignedURLResponseDto, PresignedUserProfileImgResponseDto, UpdateProfileDto } from &#39;./dto/profile.dto&#39;;
import { SuccessResponseDTO, SuccessNoResultResponseDTO } from &#39;../common/response/response.dto&#39;;

@Controller(&#39;users&#39;)
export class UsersController {
  constructor(private readonly users: UsersService) {}

  @Patch(&#39;/me&#39;)
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @ApiOperation({ summary: &#39;프로필 정보 수정(닉네임/프로필 이미지 키)&#39; })
  @ApiOkResponse({ description: &#39;프로필 정보 수정 성공&#39;, type: SuccessNoResultResponseDTO })
  async updateProfile(@GetUUID() uuid: string, @Body() dto: UpdateProfileDto) {
    await this.users.updateUserProfile(dto, uuid);
    return new SuccessNoResultResponseDTO();
  }

  @Post(&#39;/me/avatar/presigned&#39;)
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @ApiExtraModels(SuccessResponseDTO, PreSignedURLResponseDto)
  @ApiOperation({ summary: &#39;프로필 이미지 업로드용 Presigned(PUT) 발급&#39; })
  @ApiOkResponse({
    description: &#39;Presigned URL 발급 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        { properties: { result: { $ref: getSchemaPath(PreSignedURLResponseDto) } } },
      ],
    },
  })
  async generateProfileImagePresignedUrl(@GetUUID() uuid: string, @Body() dto: ProfileImagePresignRequestDto) {
    const result = await this.users.createProfileImagePresignedUrl(dto, uuid);
    return new SuccessResponseDTO(result);
  }

  @Get(&#39;/me/avatar&#39;)
  @UseGuards(JwtAuthGuard)
  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @ApiExtraModels(SuccessResponseDTO, PresignedUserProfileImgResponseDto)
  @ApiOperation({ summary: &#39;프로필 이미지 보기(Presigned GET URL 반환)&#39; })
  @ApiOkResponse({
    description: &#39;Presigned URL 발급 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        { properties: { result: { $ref: getSchemaPath(PresignedUserProfileImgResponseDto) } } },
      ],
    },
  })
  async getMyAvatar(@GetUUID() uuid: string) {
    const result = await this.users.getPresignedURLUserProfileImg(uuid);
    return new SuccessResponseDTO(result);
  }
}
</code></pre>
<hr>
<h2 id="5-flow">5) Flow</h2>
<ol>
<li><strong>업로드 준비 (Presigned 발급)</strong><ul>
<li>클라 → <strong>POST <code>/users/me/avatar/presigned</code></strong> (body: <code>{ originalName,contentType }</code>)<ul>
<li>파일 이름: <code>originalName</code></li>
<li>파일 타입: <code>contentType</code></li>
</ul>
</li>
<li>서버:<ul>
<li><code>generateProfileKey</code>로 <strong>내 소유 prefix 키</strong> 생성</li>
<li><code>getPresignedPutUrlByKey(key, contentType, 300)</code>으로 <strong>PUT Presigned URL</strong> 발급</li>
</ul>
</li>
<li>응답: <code>{ key, url, expiresIn }</code></li>
</ul>
</li>
<li><strong>브라우저에서 S3 업로드</strong><ul>
<li>클라 → <strong>PUT <code>url</code></strong> (헤더: <code>Content-Type: &lt;파일 MIME&gt;</code>, 바디: 파일)</li>
<li>버킷 CORS에 <strong>AllowedOrigins=프론트</strong>, <strong>Methods=PUT,GET,HEAD</strong>가 설정되어 있어야 함</li>
</ul>
</li>
<li><strong>프로필에 반영 (DB 저장) (2번에서 Success후)</strong><ul>
<li>클라 → <strong>PATCH <code>/users/me</code></strong> (body: <code>{ profileImgKey, nickName? }</code>)</li>
<li>서버:<ul>
<li><strong>prefix 검증</strong>: <code>profileImgKey</code>가 <code>users/{uuid}/avatar/</code>로 시작하는지 확인</li>
<li>(선택) <code>HeadObject</code>로 <strong>실존 확인</strong></li>
<li>DB에 <strong>photoUrl = key</strong> 저장 (닉네임도 있으면 중복검사 후 업데이트)</li>
<li>(옵션) 기존 아바타 키가 있으면 백그라운드로 <strong>삭제</strong></li>
</ul>
</li>
</ul>
</li>
<li><strong>조회 (표시용 URL 받기)</strong><ul>
<li>클라 → <strong>GET <code>/users/me/avatar</code></strong></li>
<li>서버:<ul>
<li>DB에서 내 <code>photoUrl</code>(=key) 조회</li>
<li><code>getPresignedGetUrlByKey(key, 300, &#39;inline&#39;)</code>으로 <strong>GET Presigned URL</strong> 발급</li>
</ul>
</li>
<li>응답: <code>{ profileImg: &lt;url&gt;, expiresIn: 300 }</code></li>
<li>클라는 <code>&lt;img src={profileImg}&gt;</code>로 렌더링 (만료되면 다시 호출)</li>
</ul>
</li>
</ol>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[(NestJs) DTO 폴더 리팩토링]]></title>
            <link>https://velog.io/@cchoi-geon/NestJs-DTO-%ED%8F%B4%EB%8D%94-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</link>
            <guid>https://velog.io/@cchoi-geon/NestJs-DTO-%ED%8F%B4%EB%8D%94-%EB%A6%AC%ED%8C%A9%ED%86%A0%EB%A7%81</guid>
            <pubDate>Sun, 17 Aug 2025 14:52:56 GMT</pubDate>
            <description><![CDATA[<h2 id="기존-dto">기존 DTO</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/1fec48bc-3d42-4bb1-8833-3482fb9b10fa/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/4d442f03-4c53-4c5b-945f-1eee3da0b779/image.png" alt=""></p>
<ul>
<li>기존 프로젝트에서는 각 도메인별로 하나의 DTO만 두며 사용하고 있었다.</li>
</ul>
<h3 id="문제점">문제점</h3>
<h4 id="단일-책임-원칙srp-위반">단일 책임 원칙(SRP) 위반</h4>
<p>하나의 파일에 Query, Path, Body, Response, 내부용 DTO까지 모두 몰아 넣다 보니 변경 사유가 여러 개가 된다. 그 결과 수정 범위가 불필요하게 커지고, 코드 리뷰도 비효율적으로 진행된다.</p>
<h4 id="이름-충돌네이밍-난맥상">이름 충돌/네이밍 난맥상</h4>
<p>CreateXDto, CreateXRequest, CreateXResponse가 한 파일에서 중복/혼용되며 import 시 오타·충돌 위험 증가.</p>
<h4 id="변경-파급캡슐화-붕괴">변경 파급(캡슐화 붕괴)</h4>
<p>특정 엔드포인트의 스펙만 바꿔도 같은 파일을 건드리면서 다른 DTO까지 린터/빌드/테스트에 영향 → 작은 변경도 큰 PR로 부풀어짐.</p>
<h2 id="바뀐-dto">바뀐 DTO</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/0538a756-1235-4d36-978b-7f16b823da58/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/a3151dab-b1f9-4160-b03b-3a46ff16fc55/image.png" alt=""></p>
<h1 id="리팩토링-결과-설명">리팩토링 결과 설명</h1>
<h2 id="1-params는-dto에서-분리-파이프-사용">1) Params는 DTO에서 분리 (파이프 사용)</h2>
<ul>
<li><strong>의도:</strong> 경로 변수는 대부분 스칼라 타입(숫자/문자열)이라 DTO까지 만들 필요가 없음.</li>
<li><strong>사용:</strong> Nest의 <code>@Param()</code> + 파이프(예: <code>ParseIntPipe</code>)로 즉시 변환·검증.</li>
<li><strong>효과:</strong> DTO 파일에서 불필요한 타입을 제거 → DTO는 “메시지 바디/쿼리/응답”에만 집중.</li>
</ul>
<pre><code class="language-jsx">// controller
@Get(&#39;/:shopId&#39;)
getShop(@Param(&#39;shopId&#39;, ParseIntPipe) shopId: number) {
  return this.shopService.findOne(shopId);
}</code></pre>
<hr>
<h2 id="2-dtoquery--query-전용-dto">2) <code>dto/query/</code> — <strong>Query 전용 DTO</strong></h2>
<ul>
<li><p><strong>의도:</strong> 페이지네이션, 정렬, 필터 등 <strong>URL 쿼리스트링</strong>을 명확히 분리.</p>
</li>
<li><p><strong>특징:</strong></p>
<ul>
<li><code>class-transformer</code>로 숫자/불리언 변환 (<code>@Type(() =&gt; Number)</code>, <code>@Transform(...)</code>).</li>
<li><code>class-validator</code>로 유효성 보장 (<code>@IsInt</code>, <code>@Min</code>, <code>@IsOptional</code> 등).</li>
<li>Swagger 문서화(<code>@ApiPropertyOptional</code>)로 클라이언트와 스펙 공유.</li>
</ul>
</li>
<li><p><strong>예:</strong> <code>pagination.dto.ts</code>, <code>user.dto.ts</code>(사용자 조회 조건 등)</p>
</li>
</ul>
<pre><code class="language-jsx">// shop/dto/query/pagination.dto.ts
import { ApiPropertyOptional } from &#39;@nestjs/swagger&#39;;
import { IsInt, IsOptional, Min } from &#39;class-validator&#39;;
import { Type } from &#39;class-transformer&#39;;

export class GetShopWithin1KmDTO {
  @ApiProperty({ description: &#39;위도&#39;, example: 37.5665 })
  @IsNumber()
  @IsNotEmpty()
  @Type(() =&gt; Number)
  lat: number;

  @ApiProperty({ description: &#39;경도&#39;, example: 127 })
  @IsNumber()
  @IsNotEmpty()
  @Type(() =&gt; Number)
  lng: number;</code></pre>
<p>사용처:</p>
<pre><code class="language-jsx">async getShopWithin1Km(
  @Query() getShopWithin1KmDTO: GetShopWithin1KmDTO,               @GetUUID() uuid: string
) {...}</code></pre>
<hr>
<h2 id="3-dtorequests--requestbody-전용-dto">3) <code>dto/requests/</code> — <strong>RequestBody 전용 DTO</strong></h2>
<ul>
<li><p><strong>의도:</strong> 클라이언트가 서버로 <strong>보내는 데이터(Body)</strong> 만을 명확히 규정.</p>
</li>
<li><p><strong>특징:</strong></p>
<ul>
<li>입력 검증 중심(<code>class-validator</code> 필수).</li>
<li>서버가 관리하는 필드(id, createdAt 등)는 절대 포함하지 않음.</li>
<li>엔드포인트별로 구분: <code>user_requests.dto.ts</code>, <code>wishlist_requests.dto.ts</code>, <code>review_request.dto.ts</code> 등.</li>
</ul>
</li>
<li><p><strong>효과:</strong> 입력 스키마가 명확해지고 Under/Over-posting 위험 감소.</p>
</li>
</ul>
<pre><code class="language-jsx">// shop/dto/requests/review_request.dto.ts
import { ApiProperty } from &#39;@nestjs/swagger&#39;;
import { IsInt, IsNotEmpty, IsString, MaxLength, Min, Type } from &#39;class-transformer&#39;;

export class SubmitNewShopDto {
  @ApiProperty({ description: &#39;소품샵 정보&#39; })
  @IsNotEmpty()
  shop: SubmitShop;

  @ApiPropertyOptional({ description: &#39;운영 시간 정보&#39; })
  operatingHours?: OperatingHoursDto;

  @ApiPropertyOptional({ description: &#39;판매 제품 리스트&#39;, type: [Products] })
  products?: Products[];
}</code></pre>
<p>사용처:</p>
<pre><code class="language-jsx">  async submitNewShop(
    @Body() newShopData: SubmitNewShopDto, 
    @GetUUID() uuid: string
  ) {...}
</code></pre>
<hr>
<h2 id="4-dtoresponses--response-전용-dtoviewmodel">4) <code>dto/responses/</code> — <strong>Response 전용 DTO(ViewModel)</strong></h2>
<ul>
<li><p><strong>의도:</strong> 서버가 <strong>클라이언트로 반환하는 형태</strong>를 명확히 분리(엔티티와 분리).</p>
</li>
<li><p><strong>특징:</strong></p>
<ul>
<li>문서화 중심(<code>@ApiProperty</code> 적극 사용).</li>
<li>가공/집계 필드, 관계형 데이터의 <strong>표현용</strong> 구조를 포함 가능.</li>
<li><strong>검증 데코레이터는 보통 생략</strong>하고, 필요 시 <code>class-transformer</code>의 <code>@Expose</code>/<code>@Transform</code>으로 직렬화 제어.</li>
<li>페이지네이션 래퍼(<code>pagination_response.dto.ts</code>) 등 <strong>공용 뷰모델</strong> 제공.</li>
</ul>
</li>
<li><p><strong>효과:</strong> 엔티티 스키마 변경이 외부 계약을 곧바로 흔들지 않음. 응답 일관성↑.</p>
</li>
</ul>
<pre><code class="language-jsx">// shop/dto/responses/shop_responses.dto.ts
import { ApiProperty } from &#39;@nestjs/swagger&#39;;

export class ShopWithin1KmResponseItemDTO {
  @ApiProperty({ example: 1 })
  id: number;

  @ApiProperty({ example: &#39;Green Valley Market&#39; })
  name: string;

  ...
}</code></pre>
<p>서비스 → 응답 매핑은 <strong>어셈블러/프레젠터</strong>에서:</p>
<pre><code class="language-jsx">  return new ShopSearchPageNationResultDTO(mappedResults, pageInfoDTO);</code></pre>
<hr>
<h2 id="5-이-구조의-핵심-장점">5) 이 구조의 핵심 장점</h2>
<ul>
<li><strong>계약 명확화:</strong> 입력(Query/Body)과 출력(Response)을 분리해 API 계약이 선명.</li>
<li><strong>SRP 준수:</strong> 목적별 DTO로 파일·리뷰 범위 축소.</li>
<li><strong>테스트 용이:</strong> 각 DTO를 독립적으로 검증 테스트 가능.</li>
<li><strong>문서 품질↑:</strong> Swagger 스키마가 역할별로 정돈.</li>
<li><strong>버저닝 용이:</strong> v1/v2 응답 DTO를 폴더로 공존시키기 쉬움.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Swagger) NestJS Swagger 수동 작성에서 데코레이터 기반 자동화로 전환하기]]></title>
            <link>https://velog.io/@cchoi-geon/Swagger-NestJS-Swagger-%EC%88%98%EB%8F%99-%EC%9E%91%EC%84%B1%EC%97%90%EC%84%9C-%EB%8D%B0%EC%BD%94%EB%A0%88%EC%9D%B4%ED%84%B0-%EA%B8%B0%EB%B0%98-%EC%9E%90%EB%8F%99%ED%99%94%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@cchoi-geon/Swagger-NestJS-Swagger-%EC%88%98%EB%8F%99-%EC%9E%91%EC%84%B1%EC%97%90%EC%84%9C-%EB%8D%B0%EC%BD%94%EB%A0%88%EC%9D%B4%ED%84%B0-%EA%B8%B0%EB%B0%98-%EC%9E%90%EB%8F%99%ED%99%94%EB%A1%9C-%EC%A0%84%ED%99%98%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 Aug 2025 06:36:07 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://docs.nestjs.com/openapi/introduction">https://docs.nestjs.com/openapi/introduction</a></li>
</ul>
<h2 id="기존-swagger-문서-관리">기존 Swagger 문서 관리</h2>
<ul>
<li><p>현재 Swagger 문서를 직접 생성해서 수동으로 작성하고 있었음.
<img src="https://velog.velcdn.com/images/cchoi-geon/post/5fd8ebf9-837b-4a23-9938-aa839687e986/image.png" alt=""></p>
</li>
<li><p>swagger 문서 생성 및 외부 문서 병합하여 사용 중. swaggerDocs.paths와 기존 document.paths를 병합 → 수동 정의된 Swagger 문서(swaggerDocs)를 병합해 함께 사용하려는 목적</p>
<pre><code class="language-ts">const document = SwaggerModule.createDocument(app, config);
(document as any).paths = {
...document.paths,
...swaggerDocs.paths,
};</code></pre>
<h3 id="수동-작성에-따른-문제점">수동 작성에 따른 문제점</h3>
</li>
<li><p>개발 과정에서 매개변수나 로직이 변경되면서 쿼리스트링, 응답 데이터(Response) 등이 자주 수정되는 경우가 있었다. 그러나 매번 변경 사항을 수동으로 반영하다 보니, 최신 상태로 유지되지 않는 경우가 종종 발생했다.</p>
</li>
</ul>
<h2 id="데코레이터-기반-자동화-전환">데코레이터 기반 자동화 전환</h2>
<pre><code class="language-ts">import { Controller, Get, Param, ParseIntPipe, Query, UseGuards } from &#39;@nestjs/common&#39;;
import { ApiTags, ApiOperation, ApiResponse, ApiBearerAuth, ApiOkResponse, getSchemaPath, ApiExtraModels } from &#39;@nestjs/swagger&#39;;
import { ShopService } from &#39;./shop.service&#39;;
import { SuccessResponseDTO } from &#39;src/common/response/response.dto&#39;;
import { GetSearchPageShopDTO, GetShopByShopIdDTO, GetShopWithin1KmDTO, ShopSearchPageNationResultDTO } from &#39;./dto/paging.dto&#39;;
import { GetUUID } from &#39;../../common/deco/get-user.deco&#39;;
import { OptionalAuthGuard } from &#39;src/common/gurad/optional-auth-guard.guard&#39;;
import { ShopDetailResponseDTO, ShopRegionDTO, ShopWithin1KmResponseItemDTO } from &#39;./dto/response.dto&#39;;

@ApiTags(&#39;Shop&#39;)
@Controller(&#39;shop&#39;)
export class ShopController {
  constructor(private shopService: ShopService) {}

  @Get(&#39;/&#39;)
  @ApiOperation({
    summary: &#39;1km 반경 내 소품샵 조회&#39;,
    description: &#39;사용자 위치 기준 1km 반경 내의 소품샵들을 조회합니다. 거리순 또는 인기순으로 정렬할 수 있습니다.&#39;,
  })
  @ApiExtraModels(SuccessResponseDTO, ShopWithin1KmResponseItemDTO)
  @ApiOkResponse({
    description: &#39;소품샵 목록 조회 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        {
          properties: {
            result: {
              type: &#39;array&#39;,
              items: { $ref: getSchemaPath(ShopWithin1KmResponseItemDTO) },
            },
          },
        },
      ],
    },
  })
  @UseGuards(OptionalAuthGuard)
  async getShopWithin1Km(@Query() getShopWithin1KmDTO: GetShopWithin1KmDTO, @GetUUID() uuid: string) {
    return new SuccessResponseDTO(await this.shopService.findShopsWithin1Km(getShopWithin1KmDTO, uuid));
  }

  @Get(&#39;/search&#39;)
  @ApiOperation({
    summary: &#39;키워드로 소품샵 검색&#39;,
    description: &#39;키워드를 사용하여 소품샵을 검색합니다.&#39;,
  })
  @ApiExtraModels(SuccessResponseDTO, ShopSearchPageNationResultDTO)
  @ApiOkResponse({
    description: &#39;검색 결과 조회 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        {
          properties: {
            result: {
              $ref: getSchemaPath(ShopSearchPageNationResultDTO),
            },
          },
        },
      ],
    },
  })
  async getSearchPageShop(@Query() getSearchPageShopDTO: GetSearchPageShopDTO) {
    return new SuccessResponseDTO(await this.shopService.findShopsByKeyword(getSearchPageShopDTO));
  }

  @Get(&#39;/region&#39;)
  @ApiOperation({
    summary: &#39;전체 지역 조회&#39;,
    description: &#39;소품샵이 있는 모든 지역을 조회합니다.&#39;,
  })
  @ApiExtraModels(SuccessResponseDTO, ShopRegionDTO)
  @ApiOkResponse({
    description: &#39;지역 목록 조회 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        {
          properties: {
            result: {
              type: &#39;array&#39;,
              items: { $ref: getSchemaPath(ShopRegionDTO) },
            },
          },
        },
      ],
    },
  })
  async getAllShopRegion() {
    return new SuccessResponseDTO(await this.shopService.findAllShopRegion());
  }

  @Get(&#39;/temp&#39;)
  @ApiResponse({
    status: 200,
    description: &#39;임시 데이터 조회 성공&#39;,
  })
  async getTemp() {
    return new SuccessResponseDTO(await this.shopService.findTemp());
  }

  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @Get(&#39;/:shopId&#39;)
  @ApiOperation({
    summary: &#39;소품샵 상세 정보 조회&#39;,
    description: &#39;특정 소품샵의 상세 정보를 조회합니다.&#39;,
  })
  @ApiExtraModels(SuccessResponseDTO, ShopDetailResponseDTO)
  @ApiOkResponse({
    description: &#39;소품샵 상세 정보 조회 성공&#39;,
    schema: {
      allOf: [
        { $ref: getSchemaPath(SuccessResponseDTO) },
        {
          properties: {
            result: { $ref: getSchemaPath(ShopDetailResponseDTO) },
          },
        },
      ],
    },
  })
  @UseGuards(OptionalAuthGuard)
  async getShopByShopId(@Param() getShopByShopIdDTO: GetShopByShopIdDTO, @GetUUID() uuid: string) {
    return new SuccessResponseDTO(await this.shopService.findShopByShopId(getShopByShopIdDTO, uuid));
  }
}
</code></pre>
<h2 id="막혔던-부분">막혔던 부분</h2>
<ul>
<li>현재 아래 형태로 response가 되게 사용 중이였다.<pre><code class="language-json">{
message: string
statusCode: number
result: T
}</code></pre>
</li>
<li>따라서 Swagger에서 Response를 정의할 때 해당 API에 맞는 ResponseDTO를 result 타입에 정의해줘야 했다. </li>
<li>하지만 아래와 같이 코드를 작성했을 때 result의 타입을 Swagger에서 인식하지 못하는 오류가 발생했다.<pre><code class="language-ts">@ApiOkResponse({
description: &#39;소품샵 목록 조회 성공&#39;,
schema: {
  allOf: [
    { $ref: getSchemaPath(SuccessResponseDTO) },
    {
      properties: {
        result: {
          type: &#39;array&#39;,
          items: { $ref: getSchemaPath(ShopWithin1KmResponseItemDTO) }
        }
      }
    }
  ]
}
})</code></pre>
<img src="https://velog.velcdn.com/images/cchoi-geon/post/121bf41a-3049-426f-b242-d1d9166eb21c/image.png" alt=""></li>
</ul>
<ul>
<li><p>찾아보니 @ApiExtraModels를 통해 Swagger에게 명시적으로 &quot;이 DTO들 쓸 거야&quot; 라고 등록하는 작업을 했어야 했다. NestJS는 제네릭 타입이나 중첩된 타입은 자동으로 감지 못하기 때문에, 수동으로 알려줘야 Swagger가 올바르게 문서를 생성한다.</p>
</li>
<li><p>따라서 아래와 같이 코드를 수정했다.</p>
<pre><code class="language-ts">@ApiExtraModels(SuccessResponseDTO, ShopWithin1KmResponseItemDTO)
@ApiOkResponse({
description: &#39;소품샵 목록 조회 성공&#39;,
schema: {
  allOf: [
    { $ref: getSchemaPath(SuccessResponseDTO) },
    {
      properties: {
        result: {
          type: &#39;array&#39;,
          items: { $ref: getSchemaPath(ShopWithin1KmResponseItemDTO) }
        }
      }
    }
  ]
}
})
</code></pre>
</li>
</ul>
<pre><code>![](https://velog.velcdn.com/images/cchoi-geon/post/f51af9f7-0089-481d-a25e-8c5cfb91cf69/image.png)


### `@ApiExtraModels(...)`란?

```tsx
@ApiExtraModels(SuccessResponseDTO, ShopWithin1KmResponseItemDTO)</code></pre><ul>
<li>Swagger에게 <strong>명시적으로 &quot;이 DTO들 쓸 거야&quot;</strong> 라고 등록하는 작업.</li>
<li>NestJS는 <strong>제네릭 타입이나 중첩된 타입은 자동으로 감지 못하기 때문에</strong>, 수동으로 알려줘야 Swagger가 올바르게 문서를 생성한다.</li>
</ul>
<h3 id="apiokresponse-schema--란"><code>@ApiOkResponse({ schema: ... })</code>란?</h3>
<pre><code class="language-tsx">@ApiOkResponse({
  description: &#39;소품샵 목록 조회 성공&#39;,
  schema: {
    allOf: [
      { $ref: getSchemaPath(SuccessResponseDTO) },
      {
        properties: {
          result: {
            type: &#39;array&#39;,
            items: { $ref: getSchemaPath(ShopWithin1KmResponseItemDTO) }
          }
        }
      }
    ]
  }
})</code></pre>
<ul>
<li>Swagger에 <strong>&quot;응답은 SuccessResponseDTO이고, result는 배열이야&quot;</strong> 라고 구체적으로 알려주는 코드</li>
</ul>
<h3 id="allof-란"><code>allOf: [...]</code>란?</h3>
<ul>
<li>Swagger/OpenAPI에서 <strong>스키마 조합</strong>을 의미함</li>
<li><code>SuccessResponseDTO</code>의 기본 구조 사용 (<code>message</code>, <code>statusCode</code>, <code>result</code>)</li>
<li>그 안의 <code>result</code> 속성만 <strong>구체적으로 다시 정의</strong>함 (배열 형태로 덮어쓰기)</li>
</ul>
<pre><code class="language-tsx">{
  &quot;message&quot;: &quot;Success&quot;,
  &quot;statusCode&quot;: 200,
  &quot;result&quot;: [
    {
      &quot;id&quot;: 1,
      &quot;name&quot;: &quot;Green Valley Market&quot;,
      &quot;lat&quot;: 37.5665,
      ...
    }
  ]
}</code></pre>
<h3 id="getschemapath란"><code>getSchemaPath(...)</code>란?</h3>
<pre><code class="language-tsx">getSchemaPath(SuccessResponseDTO)</code></pre>
<ul>
<li>DTO 클래스를 <code>$ref</code> 형식의 Swagger 경로(<code>#/components/schemas/...</code>)로 변환해줌</li>
<li>Swagger 문서 내부에서 <code>$ref</code>를 통해 <strong>재사용 가능한 객체 스키마</strong>를 지정할 수 있음</li>
</ul>
<h3 id="코드-설명">코드 설명</h3>
<blockquote>
<p>@ApiOkResponse 데코레이터는 해당 API가 성공적으로 응답했을 때의 응답 형식을 Swagger 문서에 명시하는 역할을 한다.</p>
<p>이 코드에서는 응답이 <code>SuccessResponseDTO</code> 구조를 따르되, 그 안의 <code>result</code> 필드는 <code>ShopWithin1KmResponseItemDTO</code> 타입의 객체 배열임을 명확하게 정의하고 있다.</p>
<p><code>allOf</code> 키워드를 사용해 기본 응답 구조(<code>message</code>, <code>statusCode</code>, <code>result</code>)는 <code>SuccessResponseDTO</code>로부터 참조하고, 그 중 <code>result</code> 필드만 <code>ShopWithin1KmResponseItemDTO[]</code>로 <strong>덮어써서</strong> Swagger 문서에 반영하는 방식이다.</p>
</blockquote>
<h2 id="궁금증">궁금증</h2>
<ul>
<li>현재 서비스에서 JWT Auth 인증이 필요하지 않은 API들이 존재한다. 따라서 <pre><code class="language-tsx">@ApiTags(&#39;User&#39;)
@ApiBearerAuth(&#39;JWT-auth&#39;)
@Controller(&#39;user&#39;)</code></pre>
</li>
</ul>
<p>위처럼 사용하지 못하고, 각 EndPoint 별로 <code>@ApiBearerAuth(&#39;JWT-auth&#39;)</code> 를 설정해줘야 하는 번거로움이 생겼다.</p>
<pre><code class="language-tsx">
  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @Delete(&#39;/:uuid&#39;)
  @UseGuards(JwtAuthGuard)
  async deleteUser(@Query(&#39;deleteType&#39;) deleteType: number, @Param(&#39;uuid&#39;) uuid: string, @GetUUID() currentUUID: string) {
    if (uuid !== currentUUID) throw new ConflictException(&#39;Not equal User UUID&#39;);
    return new SuccessResponseDTO(await this.userService.deleteUser(uuid, deleteType));
  }

  ...

  @ApiBearerAuth(&#39;JWT-auth&#39;)
  @Post(&#39;/nickname&#39;)
  @UseGuards(JwtAuthGuard)
  async setNickName(@Body() nickNameDTO: NickNameDTO, @GetUUID() uuid: string) {
    return new SuccessResponseDTO(await this.userService.findAndUpdateUserNickname(nickNameDTO, uuid));
  }</code></pre>
<ul>
<li>여기서 든 생각이<ol>
<li>어차피 <code>@ApiBearerAuth(&#39;JWT-auth&#39;)</code> 를 각 Controller의 상단에 배치시켜도 동작하는 데는 문제가 없으니 상단에 항상 배치할까?</li>
<li>실제 JWT 로직인 <code>@UseGuards(JwtAuthGuard)</code> 와 <code>@ApiBearerAuth(&#39;JWT-auth&#39;)</code> 를 묶은 데코레이터를 사용할까? </li>
</ol>
<ul>
<li>위 두가지였다.</li>
</ul>
</li>
<li>1번의 경우 번거로움도 없고 로직상 추가되는 것도 없어서 편할 것 같지만 Swagger란 API를 문서화하기 위한 도구인데, JWT Auth 로직이 필요하지 않는 곳에 Swagger에서 자물쇠가 걸려있다면 JWT가 필요한 로직으로 착각할 수 있을 것 같다.</li>
<li>따라서 2번을 택하는 게 좋다고 생각한다.</li>
</ul>
<pre><code class="language-tsx">export function AuthGuardWithSwagger() {
  return applyDecorators(
    UseGuards(JwtAuthGuard),
    ApiBearerAuth(&#39;JWT-auth&#39;),
  );
}</code></pre>
<pre><code class="language-tsx">
  @Delete(&#39;/:uuid&#39;)
  AuthGuardWithSwagger()
  async deleteUser(@Query(&#39;deleteType&#39;) deleteType: number, @Param(&#39;uuid&#39;) uuid: string, @GetUUID() currentUUID: string) {
    if (uuid !== currentUUID) throw new ConflictException(&#39;Not equal User UUID&#39;);
    return new SuccessResponseDTO(await this.userService.deleteUser(uuid, deleteType));
  }

  ...

  @Post(&#39;/nickname&#39;)
    AuthGuardWithSwagger()
  async setNickName(@Body() nickNameDTO: NickNameDTO, @GetUUID() uuid: string) {
    return new SuccessResponseDTO(await this.userService.findAndUpdateUserNickname(nickNameDTO, uuid));
  }</code></pre>
<ul>
<li>이게 최선의 방법인지 모르겠다... </li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(RESTful) RESTful API로 수정하기]]></title>
            <link>https://velog.io/@cchoi-geon/RESTFul-RESTFul-API%EB%A1%9C-%EC%88%98%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@cchoi-geon/RESTFul-RESTFul-API%EB%A1%9C-%EC%88%98%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 08 Aug 2025 05:43:42 GMT</pubDate>
            <description><![CDATA[<h2 id="서비스-소개">서비스 소개</h2>
<ul>
<li>소품샵 정보를 제공하고, 리뷰·운영시간·상품 등을 포함한 상세 데이터를 보여주는 서비스</li>
</ul>
<h3 id="사이트">사이트</h3>
<ul>
<li><a href="https://soso-client-soso-web.vercel.app/">https://soso-client-soso-web.vercel.app/</a></li>
</ul>
<h2 id="기존-api-endpoint">기존 API Endpoint</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/bf192f5c-75ff-4d64-8724-74919b7704a4/image.png" alt=""></p>
<h3 id="사용자-관련-user">사용자 관련 (<code>/user</code>)</h3>
<ul>
<li><code>DELETE /user/:uuid</code>: 사용자 삭제</li>
<li><code>GET /user/nickname/:nickName</code>: 닉네임 중복 체크</li>
<li><code>GET /user/profile</code>: 사용자 프로필 조회</li>
<li><code>GET /user/submit</code>: 사용자의 제출 기록 조회</li>
<li><code>GET /user/review</code>: 사용자의 리뷰 목록 조회</li>
<li><code>GET /user/wishlist</code>: 사용자의 위시리스트 조회</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/b169546c-fd2c-441b-bd60-69b12f7bd102/image.png" alt=""></p>
<h3 id="상점-관련-shop">상점 관련 (<code>/shop</code>)</h3>
<ul>
<li><code>GET /shop/</code>: 1km 이내의 상점 조회</li>
<li><code>GET /shop/search</code>: 키워드로 상점 검색</li>
<li><code>GET /shop/region</code>: 모든 상점 지역 조회</li>
<li><code>GET /shop/temp</code>: 임시 상점 목록 조회</li>
<li><code>GET /shop/:shopId</code>: 특정 상점 상세 정보 조회</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/0c6493ca-bbad-434d-b69b-ca4392782dd0/image.png" alt=""></p>
<h3 id="제출-관련-submit">제출 관련 (<code>/submit</code>)</h3>
<ul>
<li><code>POST /submit/</code>: 새로운 상점 제출</li>
<li><code>POST /submit/operating</code>: 상점 운영 시간 제출</li>
<li><code>POST /submit/products</code>: 상품 정보 제출</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/994b898c-5ed5-4899-b217-e5b1a36da387/image.png" alt=""></p>
<h3 id="신고-관련-report">신고 관련 (<code>/report</code>)</h3>
<ul>
<li><code>POST /report/review</code>: 리뷰 신고</li>
<li><code>POST /report/shop</code>: 상점 신고</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/ffb46123-e0d4-42c2-80ce-8f1155578e72/image.png" alt=""></p>
<h3 id="위시리스트-관련-wishlist">위시리스트 관련 (<code>/wishlist</code>)</h3>
<ul>
<li><code>POST /wishlist/</code>: 위시리스트 추가</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/2cf5c1ad-213d-4da1-8b55-a7605f6f91ea/image.png" alt=""></p>
<h3 id="최근-검색어-관련-recent-search">최근 검색어 관련 (<code>/recent-search</code>)</h3>
<ul>
<li><code>GET /recent-search/</code>: 최근 검색어 조회</li>
<li><code>DELETE /recent-search/</code>: 특정 최근 검색어 삭제</li>
<li><code>DELETE /recent-search/all</code>: 모든 최근 검색어 삭제</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/59be903f-5cdf-4169-ba34-3e2b32a5ec56/image.png" alt=""></p>
<h3 id="공지사항-관련-notice">공지사항 관련 (<code>/notice</code>)</h3>
<ul>
<li><code>GET /notice/</code>: 공지사항 목록 조회</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/0c01073b-2d27-4c42-89e9-618fd1a23c29/image.png" alt=""></p>
<h3 id="피드백-관련-feedback">피드백 관련 (<code>/feedback</code>)</h3>
<ul>
<li><code>POST /feedback/</code>: 피드백 저장</li>
</ul>
<h2 id="restful-api로-수정한-endpoint">RESTFul API로 수정한 Endpoint</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/f14648c0-5773-440f-b1c3-4404e9c42ada/image.png" alt=""></p>
<h3 id="사용자-관련-users">사용자 관련 (<code>/users</code>)</h3>
<ul>
<li><code>GET /users/me</code> 프로필 정보 불러오기 </li>
<li><code>PATCH /users/me</code> 프로필 정보 수정하기 </li>
<li><code>DELETE /users/me</code> 회원탈퇴 </li>
<li><code>GET /users/duplicate-check</code> 닉네임 중복 체크</li>
<li><code>GET /users/me/shop-submissions</code> 사용자가 등록/제안한 소품샵 목록 </li>
<li><code>DELETE /users/me/shop-submissions/{submitId}</code> 제보한 데이터 삭제 </li>
<li><code>GET /users/me/reviews</code> 사용자가 등록한 리뷰 목록 </li>
<li><code>GET /users/me/wishlist</code> 사용자가 찜한 소품샵 리스트 </li>
<li><code>POST /users/me/wishlist</code> 소품샵 찜하기 </li>
<li><code>GET /users/me/recent-searches</code> 최근 검색 기록 조회 </li>
<li><code>DELETE /users/me/recent-searches</code> 최근 검색 기록 전체 삭제 </li>
<li><code>DELETE /users/me/recent-searches/{recentSearchId}</code> 최근 검색 기록 개별 삭제 </li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/3ad16fd9-3f89-4733-9618-16ad1d3d4793/image.png" alt=""></p>
<h3 id="상점-관련-shops">상점 관련 (<code>/shops</code>)</h3>
<ul>
<li><code>GET /shops</code> 1km 반경 내 소품샵 조회</li>
<li><code>POST /shops</code> 새로운 소품샵 제보</li>
<li><code>GET /shops/search</code> 키워드로 소품샵 검색</li>
<li><code>GET /shops/temp</code> 임시 소품샵 목록 조회</li>
<li><code>GET /shops/{shopId}</code> 소품샵 상세 정보 조회</li>
<li><code>POST /shops/{shopId}/operating</code> 소품샵 운영정보 제보</li>
<li><code>POST /shops/{shopId}/products</code> 소품샵 판매 목록 제보</li>
<li><code>POST /shops/{shopId}/reviews</code> 리뷰 작성</li>
<li><code>PATCH /shops/{shopId}/reviews/{reviewId}</code> 리뷰 수정</li>
<li><code>DELETE /shops/{shopId}/reviews/{reviewId}</code> 리뷰 삭제</li>
<li><code>POST /shops/{shopId}/shop/report</code> 소품샵 신고</li>
<li><code>POST /shops/{shopId}/reviews/{reviewId}/report</code> 리뷰 신고</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/60cab905-e575-404d-8c02-f5daf4c5801b/image.png" alt=""></p>
<h2 id="restful-api로-적용-후-느낀점">RESTFul API로 적용 후 느낀점</h2>
<h3 id="문제점">문제점</h3>
<p>일단 Users와 Shops 도메인에 너무 많은 코드가 몰렸다. 
코드 리팩토링할 때 힘들 것 같다.
수정이 생기거나, 추가해야 되는 부분이 있을 경우 힘들 것 같다.</p>
<h3 id="내가-원하는-방향">내가 원하는 방향</h3>
<p>나는 엔드포인트를 도메인별(Review, Report 등)로 분리하는 것이 더 효율적이라고 생각한다.
그 이유는, 도메인 단위로 구분하면 유지보수가 용이하고 코드가 한곳에 과도하게 몰리지 않아 수정 시에도 훨씬 수월하기 때문이다. </p>
<h2 id="앞으로-계획">앞으로 계획</h2>
<p>OpenAPI와 스웨거를 활용한 실전 API 설계 책: <a href="https://product.kyobobook.co.kr/detail/S000211655004">https://product.kyobobook.co.kr/detail/S000211655004</a>
을 읽고 RESTFul에 대한 공부를 더 한 뒤 추가적으로 글을 쓸 예정이다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(AWS) Let's encrypt 인증서 재발급]]></title>
            <link>https://velog.io/@cchoi-geon/AWS-Lets-encrypt-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EC%9E%AC%EB%B0%9C%EA%B8%89</link>
            <guid>https://velog.io/@cchoi-geon/AWS-Lets-encrypt-%EC%9D%B8%EC%A6%9D%EC%84%9C-%EC%9E%AC%EB%B0%9C%EA%B8%89</guid>
            <pubDate>Fri, 01 Aug 2025 05:23:04 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/06169db2-eafe-4a7b-9f78-5d3b32c01812/image.png" alt=""></p>
<ul>
<li>어느 날 서버 500 에러가 나더니, 위처럼 오류가 발생했다.</li>
<li>이유를 찾던 도중  SSL 인증서(HTTPS 인증서)의 유효 기간 또는 날짜 설정에 관련된 문제라는 글을 발견했다.</li>
<li>해당 상태는 SSL 인증서의 유효 기간이 만료되어 생기는 문제였다. </li>
</ul>
<h2 id="해결-방법">해결 방법</h2>
<h4 id="1-인증서-만료-여부-확인">1. 인증서 만료 여부 확인</h4>
<ul>
<li><p>처음에 인증서를 어디에 두었는지부터 찾아야 한다.
<code>/opt/bitnami/letsencrypt/certificates/</code> 나는 이 곳에 두었다. </p>
</li>
<li><p>인증서를 확인해야 하므로, .crt인 파일을 찾아야 한다.
<code>sudo openssl x509 -in /opt/bitnami/letsencrypt/certificates/api.sosohan.shop.crt -noout -dates</code></p>
</li>
</ul>
<pre><code>notBefore=May  1 10:02:29 2025 GMT
notAfter=Jul 30 10:02:28 2025 GMT  ← 이게 과거면 만료됨
</code></pre><ul>
<li>위처럼 결과가 나오는데, noAfter을 현재 날짜와 비교해서 만료되었다면 인증서를 재발급 받아야 한다.</li>
<li>Jul 30으로 되어 있기 때문에 현재 8월 1일이므로 기한이 만료됐다...</li>
</ul>
<h4 id="1-1-인증서-발급시-등록한-이메일-찾기">1-1. 인증서 발급시 등록한 이메일 찾기</h4>
<ul>
<li>등록한 이메일을 알아야 재발급이 가능하다. </li>
<li>아래와 같은 명령어로 등록한 이메일을 찾을 수 있다.<pre><code># 경로 이동
cd /opt/bitnami/letsencrypt/accounts/acme-v02.api.letsencrypt.org/
</code></pre></li>
</ul>
<p>#내부 확인
ls</p>
<pre><code>
#### 2. 인증서 재발급 받기</code></pre><p>sudo /opt/bitnami/letsencrypt/lego <br>--tls <br>--email 이메일 <br>--domains 도메인 <br>--path &quot;/opt/bitnami/letsencrypt&quot; <br>renew</p>
<pre><code>- 위 코드를 입력해주면 완성이다.
- 단 주의할게 있는데, 위 명령어에서 443 포트를 잠깐 점유해야 하기 때문에 다른 프로세스 (예: nginx, Node.js 등)가 443을 사용 중이면 충돌이 생긴다. 따라서 실행 중인 서비스를 중단해야 한다. </code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - Repository ]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Repository</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Repository</guid>
            <pubDate>Sun, 11 May 2025 14:37:25 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/working-with-repository">https://typeorm.io/working-with-repository</a></li>
</ul>
<h2 id="repository란">Repository란?</h2>
<ul>
<li>TypeORM에서 특정 <strong>엔티티(User 등)</strong> 에 대해 데이터베이스 작업(조회, 저장, 삭제 등)을 수행하는 전용 도구</li>
<li>EntityManager와 유사하지만, 하나의 엔티티에만 집중하도록 설계되어 있어 <strong>타입 안전성(Type Safety)</strong> 이 높고, 코드가 간결해진다.</li>
</ul>
<blockquote>
<p>Repository는 특정 엔티티 전용의 EntityManager다.</p>
</blockquote>
<pre><code class="language-ts">import { User } from &quot;./entity/User&quot;

const userRepository = dataSource.getRepository(User)
const user = await userRepository.findOneBy({
    id: 1,
})
user.name = &quot;Umed&quot;
await userRepository.save(user)</code></pre>
<hr>
<h2 id="주요-옵션">주요 옵션</h2>
<h3 id="1-select">1. <code>select</code></h3>
<ul>
<li>반환되는 객체에서 가져올 컬럼만 지정</li>
</ul>
<pre><code class="language-ts">userRepository.find({
  select: {
    firstName: true,
    lastName: true,
  },
})
// → SELECT &quot;firstName&quot;, &quot;lastName&quot; FROM &quot;user&quot;</code></pre>
<hr>
<h3 id="2-relations">2. <code>relations</code></h3>
<ul>
<li>관계 설정된 엔티티도 함께 가져옴 (JOIN)</li>
</ul>
<pre><code class="language-ts">userRepository.find({
  relations: {
    profile: true,
    photos: true,
  },
})</code></pre>
<ul>
<li>중첩도 가능:</li>
</ul>
<pre><code class="language-ts">relations: {
  videos: {
    videoAttributes: true,
  },
}</code></pre>
<hr>
<h3 id="3-where">3. <code>where</code></h3>
<ul>
<li>조건 설정. AND 연산자처럼 작동</li>
</ul>
<pre><code class="language-ts">where: {
  firstName: &quot;Timber&quot;,
  lastName: &quot;Saw&quot;,
}</code></pre>
<ul>
<li>관계 엔티티 조건:</li>
</ul>
<pre><code class="language-ts">where: {
  project: {
    name: &quot;TypeORM&quot;,
    initials: &quot;TORM&quot;,
  },
}</code></pre>
<hr>
<h3 id="4-or-조건">4. OR 조건</h3>
<ul>
<li>여러 조건 중 하나라도 만족하면</li>
</ul>
<pre><code class="language-ts">where: [
  { firstName: &quot;Timber&quot;, lastName: &quot;Saw&quot; },
  { firstName: &quot;Stan&quot;, lastName: &quot;Lee&quot; },
]</code></pre>
<hr>
<h3 id="5-order">5. <code>order</code></h3>
<ul>
<li>정렬 지정</li>
</ul>
<pre><code class="language-ts">order: {
  name: &quot;ASC&quot;,
  id: &quot;DESC&quot;,
}</code></pre>
<hr>
<h3 id="6-skip--take">6. <code>skip</code> + <code>take</code></h3>
<ul>
<li><strong>pagination</strong> 구현 시 사용:</li>
</ul>
<pre><code class="language-ts">skip: 5,   // OFFSET
take: 10,  // LIMIT</code></pre>
<blockquote>
<p>MSSQL에서는 <code>order</code> 없으면 오류 발생하므로 함께 사용해야 함</p>
</blockquote>
<hr>
<h3 id="7-withdeleted">7. <code>withDeleted</code></h3>
<ul>
<li>softDelete된 데이터까지 포함</li>
</ul>
<pre><code class="language-ts">withDeleted: true</code></pre>
<hr>
<h3 id="8-cache">8. <code>cache</code></h3>
<ul>
<li>결과 캐싱 사용</li>
</ul>
<pre><code class="language-ts">cache: true</code></pre>
<hr>
<h3 id="9-lock">9. <code>lock</code></h3>
<pre><code class="language-ts">lock: { mode: &quot;optimistic&quot;, version: 1 }</code></pre>
<hr>
<h2 id="조건자advanced-operators">조건자(Advanced Operators)</h2>
<p><code>findBy()</code> 또는 <code>where</code>에서 사용할 수 있는 조건자들</p>
<table>
<thead>
<tr>
<th>연산자</th>
<th>의미</th>
<th>예시 코드</th>
</tr>
</thead>
<tbody><tr>
<td><code>Not(...)</code></td>
<td>값이 아닌 경우</td>
<td><code>Not(Equal(&quot;About #2&quot;))</code></td>
</tr>
<tr>
<td><code>LessThan(n)</code></td>
<td>n보다 작은 값</td>
<td><code>LessThan(10)</code></td>
</tr>
<tr>
<td><code>MoreThanOrEqual(n)</code></td>
<td>n 이상</td>
<td><code>MoreThanOrEqual(5)</code></td>
</tr>
<tr>
<td><code>Like(&#39;%text%&#39;)</code></td>
<td>SQL LIKE</td>
<td><code>Like(&quot;%out%&quot;)</code></td>
</tr>
<tr>
<td><code>ILike(&#39;%text%&#39;)</code></td>
<td>대소문자 구분 없는 LIKE (Postgres)</td>
<td><code>ILike(&quot;%out%&quot;)</code></td>
</tr>
<tr>
<td><code>Between(a, b)</code></td>
<td>두 값 사이</td>
<td><code>Between(1, 10)</code></td>
</tr>
<tr>
<td><code>In([a, b])</code></td>
<td>여러 값 중 하나</td>
<td><code>In([&quot;About #2&quot;, &quot;About #3&quot;])</code></td>
</tr>
<tr>
<td><code>IsNull()</code></td>
<td>null인 경우</td>
<td><code>IsNull()</code></td>
</tr>
<tr>
<td><code>Raw(...)</code></td>
<td>커스텀 SQL 삽입</td>
<td><code>Raw(alias =&gt; \</code>${alias} &gt; NOW()`)`</td>
</tr>
<tr>
<td><code>And(...)</code>, <code>Or(...)</code></td>
<td>복합 조건</td>
<td><code>And(Not(Equal(&quot;About #2&quot;)), ILike(&quot;%About%&quot;))</code></td>
</tr>
</tbody></table>
<hr>
<h2 id="repository-주요-기능">Repository 주요 기능</h2>
<table>
<thead>
<tr>
<th>기능</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>create()</code></td>
<td>엔티티 인스턴스 생성</td>
</tr>
<tr>
<td><code>save()</code></td>
<td>엔티티 저장 (insert or update 자동 감지)</td>
</tr>
<tr>
<td><code>remove()</code> / <code>delete()</code></td>
<td>엔티티 삭제</td>
</tr>
<tr>
<td><code>find()</code> / <code>findOne()</code> / <code>findBy()</code></td>
<td>데이터 조회</td>
</tr>
<tr>
<td><code>update()</code> / <code>upsert()</code></td>
<td>조건부 수정 또는 없으면 삽입</td>
</tr>
<tr>
<td><code>preload()</code> / <code>merge()</code></td>
<td>기존 데이터 덮어쓰기 또는 병합</td>
</tr>
<tr>
<td><code>increment()</code> / <code>decrement()</code></td>
<td>수치형 필드 증감</td>
</tr>
<tr>
<td><code>exists()</code> / <code>count()</code></td>
<td>존재 여부 / 개수 확인</td>
</tr>
<tr>
<td><code>softDelete()</code> / <code>restore()</code></td>
<td>soft-delete 기능 사용</td>
</tr>
<tr>
<td><code>query()</code> / <code>clear()</code></td>
<td>raw SQL 실행 / 테이블 비우기</td>
</tr>
<tr>
<td><code>createQueryBuilder()</code></td>
<td>복잡한 SQL 생성기 사용 가능</td>
</tr>
</tbody></table>
<hr>
<h2 id="사용-예시">사용 예시</h2>
<h3 id="기본-생성--저장">기본 생성 &amp; 저장</h3>
<pre><code class="language-ts">const user = repository.create({ firstName: &quot;Timber&quot; })
await repository.save(user)</code></pre>
<hr>
<h3 id="조건-검색">조건 검색</h3>
<pre><code class="language-ts">await repository.find({ where: { firstName: &quot;Timber&quot; } })
await repository.findBy({ firstName: &quot;Timber&quot; }) // 더 단순</code></pre>
<hr>
<h3 id="업데이트--업서트">업데이트 &amp; 업서트</h3>
<pre><code class="language-ts">await repository.update(1, { age: 30 })
await repository.upsert([{ externalId: &quot;abc123&quot;, name: &quot;Timber&quot; }], [&quot;externalId&quot;])</code></pre>
<hr>
<h3 id="삭제">삭제</h3>
<pre><code class="language-ts">await repository.delete(1)
await repository.softDelete({ name: &quot;Timber&quot; })
await repository.restore(1)</code></pre>
<hr>
<h3 id="통계-메서드">통계 메서드</h3>
<pre><code class="language-ts">await repository.countBy({ firstName: &quot;Timber&quot; })
await repository.sum(&quot;age&quot;, { active: true })
await repository.maximum(&quot;score&quot;, {})</code></pre>
<hr>
<h3 id="raw-query">Raw Query</h3>
<pre><code class="language-ts">await repository.query(&quot;SELECT * FROM user WHERE name = ?&quot;, [&quot;John&quot;])</code></pre>
<hr>
<h2 id="preload-vs-merge-차이"><code>preload()</code> vs <code>merge()</code> 차이</h2>
<table>
<thead>
<tr>
<th>메서드</th>
<th>사용 상황</th>
</tr>
</thead>
<tbody><tr>
<td><code>merge()</code></td>
<td>메모리 상에서 단순 병합</td>
</tr>
<tr>
<td><code>preload()</code></td>
<td>DB에서 엔티티 불러오고 병합하여 반환 (비동기)</td>
</tr>
</tbody></table>
<hr>
<h2 id="additional-options-예시">Additional Options 예시</h2>
<h3 id="save-시">Save 시</h3>
<pre><code class="language-ts">await repository.save(users, {
  chunk: 1000, // 매우 큰 데이터 분할 저장
  transaction: false, // 트랜잭션 사용 안함
  listeners: false // subscriber 이벤트 발생 안함
})</code></pre>
<h3 id="remove-시">Remove 시</h3>
<pre><code class="language-ts">await repository.remove(users, {
  chunk: 1000,
  transaction: false,
})</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - EntityManager]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-EntityManager</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-EntityManager</guid>
            <pubDate>Sun, 11 May 2025 14:23:55 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/working-with-entity-manager">https://typeorm.io/working-with-entity-manager</a></li>
</ul>
<h2 id="entitymanager란">EntityManager란?</h2>
<ul>
<li><p>TypeORM에서 모든 엔티티에 대해 삽입(insert), 조회(find), 수정(update), 삭제(delete) 등의 작업을 하나의 객체로 처리할 수 있게 해주는 중앙 관리자 같은 역할을 한다.</p>
</li>
<li><p>Repository들을 일일이 가져오지 않고도, EntityManager만으로 대부분의 DB 조작이 가능하다.</p>
</li>
</ul>
<h3 id="예시-코드">예시 코드</h3>
<pre><code class="language-ts">import { DataSource } from &quot;typeorm&quot;
import { User } from &quot;./entity/User&quot;

const myDataSource = new DataSource(/*...*/)
const user = await myDataSource.manager.findOneBy(User, {
    id: 1,
})
user.name = &quot;Umed&quot;
await myDataSource.manager.save(user)</code></pre>
<h2 id="entitymanager와-repository의-차이점">EntityManager와 Repository의 차이점</h2>
<ul>
<li>Repository가 한 엔티티에 특화된 도구라면, EntityManager는 전체 엔티티에 대해 범용적으로 사용할 수 있는 관리자다.</li>
</ul>
<table>
<thead>
<tr>
<th>항목</th>
<th>EntityManager</th>
<th>Repository</th>
</tr>
</thead>
<tbody><tr>
<td>대상</td>
<td>모든 엔티티</td>
<td>단일 엔티티</td>
</tr>
<tr>
<td>사용 방식</td>
<td><code>manager.save(User)</code></td>
<td><code>userRepo.save()</code></td>
</tr>
<tr>
<td>유연성</td>
<td>더 범용적, 트랜잭션 조작 편함</td>
<td>명확하고 타입 안전함</td>
</tr>
<tr>
<td>트랜잭션 사용</td>
<td>가능 (<code>transaction</code>)</td>
<td>내부 트랜잭션만 일부 가능</td>
</tr>
</tbody></table>
<h2 id="사용-방법">사용 방법</h2>
<h3 id="entity-생성-및-병합">Entity 생성 및 병합</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>create()</code></td>
<td>새 인스턴스 생성 (생성자 역할)</td>
</tr>
<tr>
<td><code>merge()</code></td>
<td>여러 객체의 값을 병합하여 하나의 entity로</td>
</tr>
<tr>
<td><code>preload()</code></td>
<td>기존 entity를 불러오고, 변경값만 덮어쓰기</td>
</tr>
</tbody></table>
<h3 id="데이터-조작-메서드">데이터 조작 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>save()</code></td>
<td>새 엔티티는 insert, 기존 엔티티는 update</td>
</tr>
<tr>
<td><code>insert()</code></td>
<td>무조건 새 레코드 추가 (중복 시 오류)</td>
</tr>
<tr>
<td><code>update()</code></td>
<td>조건에 맞는 레코드 일부 속성만 업데이트</td>
</tr>
<tr>
<td><code>upsert()</code></td>
<td>중복되면 update, 없으면 insert (충돌 키 지정)</td>
</tr>
<tr>
<td><code>remove()</code></td>
<td>엔티티 삭제</td>
</tr>
<tr>
<td><code>delete()</code></td>
<td>조건 또는 id로 삭제</td>
</tr>
<tr>
<td><code>increment()</code> / <code>decrement()</code></td>
<td>숫자 필드를 증감</td>
</tr>
</tbody></table>
<h3 id="조회-관련-메서드">조회 관련 메서드</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>find()</code></td>
<td>조건으로 여러 개 조회 (<code>FindOptions</code> 사용)</td>
</tr>
<tr>
<td><code>findBy()</code></td>
<td><code>FindOptionsWhere</code> 사용한 조건 조회</td>
</tr>
<tr>
<td><code>findAndCount()</code></td>
<td>결과 + 전체 개수 반환</td>
</tr>
<tr>
<td><code>findOne()</code></td>
<td>첫 번째 일치하는 엔티티 조회</td>
</tr>
<tr>
<td><code>findOneBy()</code></td>
<td><code>FindOptionsWhere</code> 기반 단일 조회</td>
</tr>
<tr>
<td><code>findOneOrFail()</code></td>
<td>못 찾으면 예외 발생</td>
</tr>
<tr>
<td><code>exists()</code> / <code>existsBy()</code></td>
<td>존재 여부 확인</td>
</tr>
<tr>
<td><code>count()</code> / <code>countBy()</code></td>
<td>조건에 맞는 레코드 수 반환</td>
</tr>
</tbody></table>
<h3 id="기타">기타</h3>
<table>
<thead>
<tr>
<th>메서드</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>clear()</code></td>
<td>테이블 비우기 (TRUNCATE)</td>
</tr>
<tr>
<td><code>release()</code></td>
<td>직접 관리한 QueryRunner 해제</td>
</tr>
<tr>
<td><code>getTreeRepository()</code></td>
<td>트리 구조 엔티티 전용 Repository 가져오기</td>
</tr>
<tr>
<td><code>getMongoRepository()</code></td>
<td>MongoDB용 Repository</td>
</tr>
<tr>
<td><code>withRepository()</code></td>
<td>트랜잭션 내에서 커스텀 Repository 사용 시</td>
</tr>
</tbody></table>
<h2 id="사용-예시-요약">사용 예시 요약</h2>
<pre><code class="language-ts">// 조회
const user = await manager.findOne(User, { where: { id: 1 } })

// 생성
const user = manager.create(User, { name: &#39;Timber&#39; })
await manager.save(user)

// 트랜잭션
await manager.transaction(async (m) =&gt; {
  await m.update(User, 1, { name: &#39;Updated&#39; })
})</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - Relations]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Relations</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Relations</guid>
            <pubDate>Sat, 10 May 2025 14:25:03 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/relations#cascade-options">https://typeorm.io/relations#cascade-options</a></li>
</ul>
<h2 id="relation-종류">Relation 종류</h2>
<ol>
<li>일대일 (One-to-One)</li>
</ol>
<ul>
<li><p>한 엔티티가 다른 엔티티와 1:1로 매핑됩니다. 예를 들어, 한 사용자가 하나의 프로필을 가질 수 있다.</p>
<pre><code class="language-tsx">  @OneToOne(() =&gt; Profile)
  @JoinColumn()
  profile: Profile;
</code></pre>
</li>
</ul>
<ol start="2">
<li>일대다 / 다대일 (One-to-Many / Many-to-One)</li>
</ol>
<ul>
<li><p>한 엔티티가 여러 엔티티와 연관되거나, 여러 엔티티가 하나의 엔티티와 연관됩니다. 예를 들어, 한 사용자가 여러 게시물을 작성할 수 있다.</p>
<pre><code class="language-tsx">  @OneToMany(() =&gt; Post, post =&gt; post.user)
  posts: Post[];

  @ManyToOne(() =&gt; User, user =&gt; user.posts)
  user: User;
</code></pre>
</li>
</ul>
<ol start="3">
<li>다대다 (Many-to-Many)</li>
</ol>
<ul>
<li><p>여러 엔티티가 서로 다수와 연관됩니다. 예를 들어, 여러 학생이 여러 과목을 수강할 수 있다.</p>
<pre><code class="language-tsx">  @ManyToMany(() =&gt; Subject)
  @JoinTable()
  subjects: Subject[];
</code></pre>
</li>
</ul>
<h2 id="relation-option">Relation Option</h2>
<ul>
<li><strong>eager</strong>: <code>true</code>로 설정하면, 관계된 엔티티를 자동으로 로드한다.</li>
<li><strong>cascade</strong>: 연관된 엔티티를 자동으로 삽입, 업데이트, 삭제할 수 있다. 예: <code>cascade: true</code> 또는 <code>cascade: [&#39;insert&#39;, &#39;update&#39;]</code></li>
<li><strong>onDelete</strong>: 부모 엔티티 삭제 시 자식 엔티티의 동작을 정의한다.
옵션: <code>&#39;RESTRICT&#39;</code>, <code>&#39;CASCADE&#39;</code>, <code>&#39;SET NULL&#39;</code></li>
<li><strong>nullable</strong>: 관계 컬럼이 <code>NULL</code>을 허용할지 여부를 설정한다.</li>
<li><strong>orphanedRowAction</strong>: 부모 엔티티에서 제거된 자식 엔티티의 처리 방식을 정의한다. 옵션: <code>&#39;nullify&#39;</code>, <code>&#39;delete&#39;</code>, <code>&#39;soft-delete&#39;</code>, <code>&#39;disable&#39;</code></li>
</ul>
<h2 id="cascade란">cascade란?</h2>
<p><code>cascade</code>는 <strong>연관된 엔티티도 함께 저장(insert), 수정(update), 삭제(remove)할 수 있도록 하는 옵션</strong></p>
<ul>
<li>예를 들어, <code>Question</code> 엔티티를 저장할 때, 연결된 <code>Category</code> 엔티티도 자동으로 저장되게 만들 수 있다.</li>
<li>이를 위해 <code>@ManyToMany</code>, <code>@OneToMany</code>, <code>@OneToOne</code> 등의 관계 설정에 <code>cascade</code> 옵션을 지정한다.</li>
</ul>
<h3 id="category-entity">Category Entity</h3>
<pre><code class="language-ts">@Entity()
export class Category {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    name: string

    @ManyToMany(() =&gt; Question, question =&gt; question.categories)
    questions: Question[]
}</code></pre>
<h3 id="question-entity">Question Entity</h3>
<pre><code class="language-ts">@Entity()
export class Question {
    @PrimaryGeneratedColumn()
    id: number

    @Column()
    title: string

    @Column()
    text: string

    @ManyToMany(() =&gt; Category, category =&gt; category.questions, {
        cascade: true,  // 중요 포인트
    })
    @JoinTable()
    categories: Category[]
}</code></pre>
<blockquote>
<p><code>cascade: true</code>를 설정하면 <code>Question</code>을 저장할 때 <code>Category</code>도 같이 저장됨</p>
</blockquote>
<h3 id="저장-코드">저장 코드</h3>
<pre><code class="language-ts">const category1 = new Category()
category1.name = &quot;ORMs&quot;

const category2 = new Category()
category2.name = &quot;Programming&quot;

const question = new Question()
question.title = &quot;How to ask questions?&quot;
question.text = &quot;Where can I ask TypeORM-related questions?&quot;
question.categories = [category1, category2]

await dataSource.manager.save(question)</code></pre>
<blockquote>
<p><code>category1</code>, <code>category2</code>를 따로 <code>save()</code> 하지 않아도, <code>cascade</code> 때문에 자동으로 DB에 저장됩니다.</p>
</blockquote>
<h2 id="cascade-옵션-종류">cascade 옵션 종류</h2>
<table>
<thead>
<tr>
<th>옵션명</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;insert&quot;</code></td>
<td>새로운 객체가 있으면 자동 저장됨</td>
</tr>
<tr>
<td><code>&quot;update&quot;</code></td>
<td>기존 객체가 변경되면 자동 업데이트</td>
</tr>
<tr>
<td><code>&quot;remove&quot;</code></td>
<td>관계에서 빠진 객체를 DB에서도 삭제</td>
</tr>
<tr>
<td><code>&quot;soft-remove&quot;</code></td>
<td><code>soft remove</code> 수행</td>
</tr>
<tr>
<td><code>&quot;recover&quot;</code></td>
<td><code>soft remove</code>된 객체 복구</td>
</tr>
</tbody></table>
<h3 id="예시">예시</h3>
<pre><code class="language-ts">@ManyToMany(() =&gt; PostCategory, {
    cascade: true // insert, update, remove 모두 포함
})
categories: PostCategory[]

@ManyToMany(() =&gt; PostDetails, {
    cascade: [&quot;insert&quot;] // 새로 생성된 것만 저장
})
details: PostDetails[]

@ManyToMany(() =&gt; PostImage, {
    cascade: [&quot;update&quot;] // 기존 객체 수정만 반영
})
images: PostImage[]

@ManyToMany(() =&gt; PostInformation, {
    cascade: [&quot;insert&quot;, &quot;update&quot;]
})
informations: PostInformation[]</code></pre>
<h2 id="joincolumn이란">@JoinColumn이란?</h2>
<ul>
<li>@JoinColumn은 관계를 정의할 때 외래 키 컬럼을 설정하는 데 사용된다.</li>
<li>주로 @ManyToOne 또는 @OneToOne 관계에서 사용된다.</li>
</ul>
<table>
<thead>
<tr>
<th>관계 방향</th>
<th><code>@JoinColumn</code> 필요 여부</th>
</tr>
</thead>
<tbody><tr>
<td><code>@ManyToOne</code></td>
<td>선택 (자동으로 생성됨)</td>
</tr>
<tr>
<td><code>@OneToOne</code></td>
<td>필수 (한쪽은 반드시 명시해야 함)</td>
</tr>
</tbody></table>
<h2 id="기본-동작">기본 동작</h2>
<pre><code class="language-ts">@ManyToOne(() =&gt; Category)
@JoinColumn()
category: Category;</code></pre>
<p>위 코드는 <code>categoryId</code>라는 컬럼(FK)을 생성하여 <code>Category</code> 엔티티의 <code>id</code>(기본 키)를 참조하는 외래 키로 사용한다.</p>
<h2 id="컬럼-이름-커스터마이징">컬럼 이름 커스터마이징</h2>
<pre><code class="language-ts">@ManyToOne(() =&gt; Category)
@JoinColumn({ name: &quot;cat_id&quot; })
category: Category;</code></pre>
<p><code>categoryId</code>(자동으로 생성되는 컬럼) 대신 <code>cat_id</code>라는 외래 키 컬럼을 사용하도록 지정한 것.</p>
<h2 id="참조-컬럼-지정-기본키-말고-다른-컬럼">참조 컬럼 지정 (기본키 말고 다른 컬럼)</h2>
<pre><code class="language-ts">@ManyToOne(() =&gt; Category)
@JoinColumn({ referencedColumnName: &quot;name&quot; })
category: Category;</code></pre>
<ul>
<li>이 경우 외래 키는 Category.name을 참조하며, 자동으로 생성되는 컬럼(FK) 이름은 <strong>categoryName</strong> 이 된다.</li>
</ul>
<h2 id="다중-컬럼-조인-복합-외래-키">다중 컬럼 조인 (복합 외래 키)</h2>
<pre><code class="language-ts">@ManyToOne(() =&gt; Category)
@JoinColumn([
  { name: &quot;category_id&quot;, referencedColumnName: &quot;id&quot; },
  { name: &quot;locale_id&quot;, referencedColumnName: &quot;locale_id&quot; }
])
category: Category;</code></pre>
<p>정확한 이해를 돕기 위해 <code>@JoinTable</code>의 동작 원리와 옵션들을 예제 기반으로 <strong>알기 쉽게</strong> 설명해드릴게요.</p>
<hr>
<h2 id="jointable이란">@JoinTable이란?</h2>
<ul>
<li><code>@ManyToMany</code> 관계에서는 두 엔티티 간의 관계를 나타내기 위한 <strong>중간 테이블(junction table)</strong> 이 자동 생성된다.</li>
<li>이 <strong>중간 테이블의 이름</strong>, 그리고 <strong>어떤 컬럼이 어떤 엔티티의 어떤 컬럼을 참조하는지</strong>를 설정할 수 있는 데코레이터가 <code>@JoinTable</code> 이다.</li>
</ul>
<hr>
<h2 id="기본-사용-예시">기본 사용 예시</h2>
<pre><code class="language-ts">@ManyToMany(() =&gt; Category)
@JoinTable()
categories: Category[];</code></pre>
<ul>
<li>이 코드는 기본적으로 <code>question_categories_category</code> 같은 중간 테이블을 자동 생성하며, 컬럼은 <code>questionId</code>, <code>categoryId</code>처럼 자동으로 설정된다.</li>
</ul>
<hr>
<h2 id="옵션-지정-예시">옵션 지정 예시</h2>
<pre><code class="language-ts">@ManyToMany(() =&gt; Category)
@JoinTable({
  name: &quot;question_categories&quot;, // 중간 테이블 이름
  joinColumn: {
    name: &quot;question&quot;, // 현재 엔티티(Question)의 FK 컬럼명
    referencedColumnName: &quot;id&quot; // Question의 어떤 컬럼을 참조할지 (기본은 id)
  },
  inverseJoinColumn: {
    name: &quot;category&quot;, // 반대편 엔티티(Category)의 FK 컬럼명
    referencedColumnName: &quot;id&quot; // Category의 어떤 컬럼을 참조할지
  }
})
categories: Category[];</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>name</code></td>
<td>생성할 중간 테이블 이름 지정</td>
</tr>
<tr>
<td><code>joinColumn</code></td>
<td>현재 엔티티(왼쪽)의 외래 키 설정</td>
</tr>
<tr>
<td><code>inverseJoinColumn</code></td>
<td>상대 엔티티(오른쪽)의 외래 키 설정</td>
</tr>
</tbody></table>
<p>➡ 결과적으로 생성되는 중간 테이블은 다음과 같이 생깁니다:</p>
<table>
<thead>
<tr>
<th>question</th>
<th>category</th>
</tr>
</thead>
<tbody><tr>
<td>1</td>
<td>2</td>
</tr>
<tr>
<td>1</td>
<td>3</td>
</tr>
</tbody></table>
<hr>
<h2 id="복합키composite-key일-경우">복합키(Composite Key)일 경우</h2>
<p>예를 들어 <code>Category</code>의 기본키가 <code>id</code> + <code>localeId</code> 두 개라면:</p>
<pre><code class="language-ts">@ManyToMany(() =&gt; Category)
@JoinTable({
  name: &quot;question_categories&quot;,
  joinColumn: [
    { name: &quot;question_id&quot;, referencedColumnName: &quot;id&quot; }
  ],
  inverseJoinColumn: [
    { name: &quot;category_id&quot;, referencedColumnName: &quot;id&quot; },
    { name: &quot;category_locale&quot;, referencedColumnName: &quot;localeId&quot; }
  ]
})
categories: Category[];</code></pre>
<ul>
<li>이처럼 <code>joinColumn</code> 또는 <code>inverseJoinColumn</code>에 <strong>배열</strong>을 넣어 복합키를 설정할 수 있다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - View Entity]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-View-Entity</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-View-Entity</guid>
            <pubDate>Fri, 09 May 2025 15:08:59 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/view-entities">https://typeorm.io/view-entities</a></li>
</ul>
<h2 id="view-entity란">View Entity란?</h2>
<ul>
<li>DB View를 표현하는 읽기 전용 엔티티</li>
<li>@ViewEntity() 데코레이터로 정의</li>
<li>실제 테이블이 아니라 SQL SELECT 문을 기반으로 구성됨</li>
</ul>
<h3 id="예시">예시</h3>
<pre><code class="language-ts">@ViewEntity({
  name: &quot;post_category&quot;, // 생략 가능
  expression: `
    SELECT post.id AS id, post.name AS name, category.name AS categoryName
    FROM post
    LEFT JOIN category ON category.id = post.categoryId
  `,
})
export class PostCategory {
  @ViewColumn()
  id: number;

  @ViewColumn()
  name: string;

  @ViewColumn()
  categoryName: string;
}</code></pre>
<p>또는 QueryBuilder로도 표현 가능:</p>
<pre><code class="language-ts">@ViewEntity({
  expression: (dataSource: DataSource) =&gt;
    dataSource
      .createQueryBuilder()
      .select(&quot;post.id&quot;, &quot;id&quot;)
      .addSelect(&quot;post.name&quot;, &quot;name&quot;)
      .addSelect(&quot;category.name&quot;, &quot;categoryName&quot;)
      .from(Post, &quot;post&quot;)
      .leftJoin(Category, &quot;category&quot;, &quot;category.id = post.categoryId&quot;),
})</code></pre>
<ul>
<li>중요: <code>where(&quot;... = :value&quot;, { value })</code> 형태의 파라미터 바인딩은 안 된다. 문자열 리터럴만 사용 가능.</li>
</ul>
<h2 id="viewentity-vs-일반-entity-차이점">ViewEntity vs 일반 Entity 차이점</h2>
<table>
<thead>
<tr>
<th>항목</th>
<th>Entity</th>
<th>ViewEntity</th>
</tr>
</thead>
<tbody><tr>
<td>매핑 대상</td>
<td>테이블</td>
<td>뷰 (SELECT 결과)</td>
</tr>
<tr>
<td>읽기/쓰기</td>
<td>읽기/쓰기 가능</td>
<td>읽기 전용</td>
</tr>
<tr>
<td>사용 목적</td>
<td>데이터 저장 및 조작</td>
<td>여러 테이블 집계, 통계</td>
</tr>
<tr>
<td>쿼리 정의 방식</td>
<td>없음</td>
<td><code>expression</code>으로 정의</td>
</tr>
</tbody></table>
<h2 id="실행-흐름-요약">실행 흐름 요약</h2>
<ol>
<li><code>Post</code>와 <code>Category</code> 테이블 생성</li>
<li>그 둘을 조인한 <code>PostCategory</code> ViewEntity 생성</li>
<li><code>@ViewColumn()</code>으로 SELECT된 값을 매핑</li>
<li>ViewEntity는 <code>entities: [PostCategory]</code>로 등록 필요</li>
<li><code>find()</code> 또는 <code>findOneBy()</code>로 일반 엔티티처럼 조회 가능</li>
</ol>
<pre><code class="language-ts">const postCategories = await dataSource.manager.find(PostCategory);
// 결과: [{ id: 1, name: &quot;About BMW&quot;, categoryName: &quot;Cars&quot; }, ...]</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - Embedded Entities]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Embedded-Entities</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Embedded-Entities</guid>
            <pubDate>Fri, 09 May 2025 15:02:22 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/embedded-entities">https://typeorm.io/embedded-entities</a></li>
</ul>
<h2 id="embedded-entities란">Embedded Entities란?</h2>
<ul>
<li>상속(inheritance) 대신 <strong>구성(composition)</strong>을 활용하여 코드 중복을 줄이는 강력한 방법</li>
<li>여러 엔티티(User, Employee, Student 등)가 동일한 속성(firstName, lastName)을 반복적으로 갖고 있을 때, 그 중복을 없애기 위한 기능이다.</li>
</ul>
<h3 id="개념">개념</h3>
<table>
<thead>
<tr>
<th>개념</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>Embedded Entity</td>
<td>독립된 테이블은 아니지만, 특정 컬럼들을 그룹화하여 다른 엔티티 안에 포함시킬 수 있는 클래스</td>
</tr>
<tr>
<td>목적</td>
<td>반복되는 컬럼들을 재사용하여 <strong>코드 중복 제거</strong></td>
</tr>
<tr>
<td>작동 방식</td>
<td>클래스의 필드들을 실제 테이블의 컬럼으로 <strong>병합(Merge)</strong></td>
</tr>
</tbody></table>
<hr>
<h3 id="예시-구조">예시 구조</h3>
<h4 id="중복-구조">중복 구조:</h4>
<pre><code class="language-ts">@Column()
firstName: string;

@Column()
lastName: string;</code></pre>
<p>위 코드가 <code>User</code>, <code>Employee</code>, <code>Student</code>에 중복되어 있음.</p>
<h4 id="embedded-구조">Embedded 구조:</h4>
<pre><code class="language-ts">export class Name {
    @Column()
    first: string;

    @Column()
    last: string;
}</code></pre>
<pre><code class="language-ts">@Column(() =&gt; Name)
name: Name;</code></pre>
<hr>
<h3 id="실제-테이블-구조">실제 테이블 구조</h3>
<pre><code class="language-sql">-- User 테이블
id | nameFirst | nameLast | isActive
-- Employee 테이블
id | nameFirst | nameLast | salary
-- Student 테이블
id | nameFirst | nameLast | faculty</code></pre>
<ul>
<li>즉, name: Name으로 객체를 묶었지만 실제 DB에는 nameFirst, nameLast처럼 평탄화된 컬럼으로 생됨</li>
</ul>
<h3 id="embedded-entity의-장점">Embedded Entity의 장점</h3>
<ul>
<li>코드 재사용성 향상</li>
<li>테이블 구조는 평탄화되어 있어 SQL에서도 접근 쉬움</li>
<li>중첩(embedded inside embedded)도 가능</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - Entity ]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Entity</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-Entity</guid>
            <pubDate>Thu, 08 May 2025 14:02:30 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/entities">https://typeorm.io/entities</a></li>
</ul>
<h2 id="entity란">Entity란?</h2>
<ul>
<li>Entity는 데이터베이스 <strong>테이블</strong>에 매핑되는 클래스이다.</li>
<li>즉, TypeORM에서는 User와 같은 클래스를 만들고 여기에 데코레이터(@Entity, @Column 등)를 붙여주면, 그것이 곧 데이터베이스의 하나의 테이블이 된다.</li>
</ul>
<pre><code class="language-ts">import { Entity, PrimaryGeneratedColumn, Column } from &quot;typeorm&quot;;

@Entity()  // 이 클래스는 테이블로 매핑된다
export class User {
  @PrimaryGeneratedColumn()  // 기본키 + AUTO_INCREMENT
  id: number;

  @Column()
  firstName: string;

  @Column()
  lastName: string;

  @Column()
  isActive: boolean;
}</code></pre>
<h3 id="column-이란">@Column() 이란?</h3>
<pre><code class="language-ts">@Column()
firstName: string;</code></pre>
<ul>
<li><p>@Column() 데코레이터를 사용하면 이 속성이 데이터베이스 테이블의 <strong>열로</strong> 매핑된다.</p>
</li>
<li><p>데이터 타입은 TypeScript의 타입을 기준으로 자동 추론된다 (예: string → varchar, number → int).</p>
</li>
<li><p>자세한 내용은 뒤에 있다.</p>
</li>
</ul>
<h3 id="primary-column이란">@Primary Column()이란?</h3>
<ul>
<li>Entity는 반드시 최소한 하나의 Primary Key를 가져야 한다.</li>
</ul>
<table>
<thead>
<tr>
<th>데코레이터</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@PrimaryColumn()</code></td>
<td>수동 지정 필요. 직접 값을 넣어야 함</td>
</tr>
<tr>
<td><code>@PrimaryGeneratedColumn()</code></td>
<td>자동 증가(AUTO_INCREMENT) 숫자 ID</td>
</tr>
<tr>
<td><code>@PrimaryGeneratedColumn(&quot;uuid&quot;)</code></td>
<td>자동 생성 UUID 문자열 ID</td>
</tr>
</tbody></table>
<h3 id="복합-기본-키-사용-법">복합 기본 키 사용 법</h3>
<pre><code class="language-ts">@Entity()
export class User {
  @PrimaryColumn()
  firstName: string;

  @PrimaryColumn()
  lastName: string;
}
</code></pre>
<ul>
<li>두 컬럼을 함께 기본 키로 사용한다.</li>
<li>데이터는 firstName + lastName 조합이 고유해야 저장된다.</li>
</ul>
<h2 id="typeorm-special-columns">TypeORM Special Columns</h2>
<table>
<thead>
<tr>
<th>데코레이터</th>
<th>용도 설명</th>
<th>자동 설정 시점</th>
<th>컬럼 타입</th>
<th>사용 예시</th>
</tr>
</thead>
<tbody><tr>
<td><code>@CreateDateColumn()</code></td>
<td>레코드가 <strong>처음 생성된 시간</strong>을 자동 저장</td>
<td><code>INSERT</code> 시 자동 설정</td>
<td><code>Date</code></td>
<td>생성일 저장 (<code>createdAt</code>)</td>
</tr>
<tr>
<td><code>@UpdateDateColumn()</code></td>
<td>레코드가 <strong>수정된 시간</strong>을 자동 저장</td>
<td><code>INSERT</code>, <code>UPDATE</code> 시 갱신</td>
<td><code>Date</code></td>
<td>수정일 저장 (<code>updatedAt</code>)</td>
</tr>
<tr>
<td><code>@DeleteDateColumn()</code></td>
<td><strong>소프트 삭제(soft-delete)</strong> 된 시간 저장</td>
<td><code>softRemove()</code> 호출 시 설정</td>
<td><code>Date</code></td>
<td>삭제일 저장 (<code>deletedAt</code>)</td>
</tr>
<tr>
<td><code>@VersionColumn()</code></td>
<td>레코드의 **버전(숫자)**을 저장하며, 저장 시마다 자동 증가</td>
<td><code>save()</code> 호출 시 증가</td>
<td><code>number</code></td>
<td>낙관적 락 버전 관리용 (<code>version</code>)</td>
</tr>
</tbody></table>
<p>예시 코드: </p>
<pre><code class="language-ts">import {
  Entity,
  PrimaryGeneratedColumn,
  Column,
  CreateDateColumn,
  UpdateDateColumn,
  DeleteDateColumn,
  VersionColumn,
} from &#39;typeorm&#39;;

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  name: string;

  @CreateDateColumn()
  createdAt: Date;

  @UpdateDateColumn()
  updatedAt: Date;

  @DeleteDateColumn()
  deletedAt?: Date;  // soft-delete되기 전에는 null

  @VersionColumn()
  version: number;
}
</code></pre>
<h3 id="생성되는-db-테이블-예시">생성되는 DB 테이블 예시</h3>
<table>
<thead>
<tr>
<th>컬럼 이름</th>
<th>타입</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>id</td>
<td>int</td>
<td>기본 키 (자동 증가)</td>
</tr>
<tr>
<td>name</td>
<td>varchar</td>
<td>유저 이름</td>
</tr>
<tr>
<td>createdAt</td>
<td>datetime</td>
<td>생성 시간 (자동 설정됨)</td>
</tr>
<tr>
<td>updatedAt</td>
<td>datetime</td>
<td>수정 시간 (자동 갱신됨)</td>
</tr>
<tr>
<td>deletedAt</td>
<td>datetime (null)</td>
<td>소프트 삭제 시간 (삭제 시 설정)</td>
</tr>
<tr>
<td>version</td>
<td>int</td>
<td>버전 번호 (매 저장 시 증가)</td>
</tr>
</tbody></table>
<h2 id="컬럼-타입-지정-방법">컬럼 타입 지정 방법</h2>
<h4 id="1-기본-문법">1. 기본 문법</h4>
<pre><code class="language-ts">@Column(&quot;int&quot;)
age: number;</code></pre>
<h4 id="2-옵션-객체로-지정">2. 옵션 객체로 지정</h4>
<pre><code class="language-ts">@Column({ type: &quot;int&quot;, width: 11 })
age: number;</code></pre>
<h4 id="3-문자열-길이-지정-varchar-등">3. 문자열 길이 지정 (varchar 등)</h4>
<pre><code class="language-ts">@Column(&quot;varchar&quot;, { length: 200 })
name: string;</code></pre>
<h2 id="mysql--mariadb에서-지원하는-주요-컬럼-타입">MySQL / MariaDB에서 지원하는 주요 컬럼 타입</h2>
<table>
<thead>
<tr>
<th>타입</th>
<th>설명</th>
<th>TypeScript 타입</th>
</tr>
</thead>
<tbody><tr>
<td><code>int</code>, <code>integer</code></td>
<td>일반 정수</td>
<td><code>number</code></td>
</tr>
<tr>
<td><code>tinyint</code></td>
<td>아주 작은 정수 (0~255)</td>
<td><code>number</code>, <code>boolean</code></td>
</tr>
<tr>
<td><code>bigint</code></td>
<td>아주 큰 정수 (JS에서는 <code>string</code>)</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>float</code>, <code>double</code></td>
<td>실수</td>
<td><code>number</code></td>
</tr>
<tr>
<td><code>decimal</code></td>
<td>고정 소수점 숫자 (금융용)</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>boolean</code>, <code>bool</code></td>
<td>불리언 값</td>
<td><code>boolean</code></td>
</tr>
<tr>
<td><code>date</code></td>
<td>날짜</td>
<td><code>Date</code></td>
</tr>
<tr>
<td><code>datetime</code></td>
<td>날짜 + 시간</td>
<td><code>Date</code></td>
</tr>
<tr>
<td><code>timestamp</code></td>
<td>타임스탬프</td>
<td><code>Date</code></td>
</tr>
<tr>
<td><code>char</code>, <code>varchar</code></td>
<td>고정 / 가변 길이 문자열</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>text</code> 계열</td>
<td>긴 텍스트 (tinytext ~ longtext)</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>blob</code> 계열</td>
<td>바이너리 데이터</td>
<td><code>Buffer</code></td>
</tr>
<tr>
<td><code>enum</code>, <code>set</code></td>
<td>제한된 값 중 선택</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>json</code></td>
<td>JSON 객체</td>
<td><code>object</code></td>
</tr>
<tr>
<td><code>uuid</code></td>
<td>UUID 문자열 (MariaDB 한정)</td>
<td><code>string</code></td>
</tr>
<tr>
<td><code>point</code>, <code>geometry</code> 등</td>
<td>공간 데이터</td>
<td><code>string</code> (WKT 형식)</td>
</tr>
</tbody></table>
<h3 id="bigint-주의사항">bigint 주의사항</h3>
<ul>
<li>bigint는 JavaScript의 <strong>number 범위를 초과</strong>하므로 TypeORM에서는 <strong>string으로 매핑</strong>된다.</li>
</ul>
<pre><code class="language-ts">@Column(&quot;bigint&quot;)
longValue: string;</code></pre>
<pre><code class="language-ts">const entity = new MyEntity();
entity.longValue = &quot;9007199254740992&quot;; // 문자열로 처리</code></pre>
<h2 id="enum-컬럼-타입">Enum 컬럼 타입</h2>
<p>(추천)</p>
<pre><code class="language-ts">export enum UserRole {
  ADMIN = &quot;admin&quot;,
  EDITOR = &quot;editor&quot;,
  GHOST = &quot;ghost&quot;,
}

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: &quot;enum&quot;,
    enum: UserRole,
    default: UserRole.GHOST,
  })
  role: UserRole;
}</code></pre>
<p>또는</p>
<pre><code class="language-ts"> export type UserRoleType = &quot;admin&quot; | &quot;editor&quot; | &quot;ghost&quot;;

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: &quot;enum&quot;,
    enum: [&quot;admin&quot;, &quot;editor&quot;, &quot;ghost&quot;],
    default: &quot;ghost&quot;,
  })
  role: UserRoleType;
}
</code></pre>
<h2 id="set-컬럼-타입">set 컬럼 타입</h2>
<p>(추천)</p>
<pre><code class="language-ts">export enum UserRole {
  ADMIN = &quot;admin&quot;,
  EDITOR = &quot;editor&quot;,
  GHOST = &quot;ghost&quot;,
}

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: &quot;set&quot;,
    enum: UserRole,
    default: [UserRole.GHOST, UserRole.EDITOR],
  })
  roles: UserRole[];
}</code></pre>
<p>또는</p>
<pre><code class="language-ts">export type UserRoleType = &quot;admin&quot; | &quot;editor&quot; | &quot;ghost&quot;;

@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column({
    type: &quot;set&quot;,
    enum: [&quot;admin&quot;, &quot;editor&quot;, &quot;ghost&quot;],
    default: [&quot;ghost&quot;, &quot;editor&quot;],
  })
  roles: UserRoleType[];
}</code></pre>
<h2 id="simple-array-컬럼-타입">simple-array 컬럼 타입</h2>
<ul>
<li><p>string[], number[] 등 배열을 문자열로 콤마(,) 구분해 저장.</p>
<pre><code class="language-ts">@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column(&quot;simple-array&quot;)
names: string[];
}</code></pre>
</li>
</ul>
<pre><code class="language-ts">user.names = [&quot;Alex&quot;, &quot;Sasha&quot;];
// DB에는 &quot;Alex,Sasha&quot;로 저장됨
</code></pre>
<h2 id="simple-json-컬럼-타입">simple-json 컬럼 타입</h2>
<ul>
<li><p>JSON 객체를 문자열 형태로 저장 (별도의 JSON 타입 없이도 가능)</p>
<pre><code class="language-ts">@Entity()
export class User {
@PrimaryGeneratedColumn()
id: number;

@Column(&quot;simple-json&quot;)
profile: { name: string; nickname: string };
}</code></pre>
</li>
</ul>
<pre><code class="language-ts">user.profile = { name: &quot;John&quot;, nickname: &quot;Malkovich&quot; };
// DB에는 &#39;{&quot;name&quot;:&quot;John&quot;,&quot;nickname&quot;:&quot;Malkovich&quot;}&#39;로 저장됨</code></pre>
<h4 id="한계">한계</h4>
<table>
<thead>
<tr>
<th>한계</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td>❌ 쿼리 불가</td>
<td>SQL에서는 객체 내부 필드를 조건으로 검색 불가 (<code>WHERE profile.name = &#39;John&#39;</code> 불가능)</td>
</tr>
<tr>
<td>❌ 인덱싱 불가</td>
<td>내부 필드에 인덱스를 걸 수 없음</td>
</tr>
<tr>
<td>❌ 데이터 무결성 없음</td>
<td>구조 강제 불가 (profile.name이 string인지 보장 불가)</td>
</tr>
<tr>
<td>❌ 정렬, 조인 불가</td>
<td>JSON 안의 값으로 정렬하거나 조인할 수 없음</td>
</tr>
<tr>
<td>❌ 대용량 시 성능 저하</td>
<td>큰 JSON이 많아지면 성능 및 관리 복잡도 증가</td>
</tr>
</tbody></table>
<h2 id="generated-데코레이터란">@Generated() 데코레이터란?</h2>
<ul>
<li>TypeORM에서 데이터베이스가 자동으로 생성해주는 값을 가지는 컬럼에 붙이는 데코레이터</li>
</ul>
<h3 id="지원되는-생성-타입">지원되는 생성 타입</h3>
<table>
<thead>
<tr>
<th>생성 타입</th>
<th>설명</th>
<th>지원 DB</th>
</tr>
</thead>
<tbody><tr>
<td><code>&quot;uuid&quot;</code></td>
<td>UUID 자동 생성 (문자열)</td>
<td>대부분 (MySQL, Postgres 등)</td>
</tr>
<tr>
<td><code>&quot;increment&quot;</code></td>
<td>숫자 자동 증가 (auto-increment)</td>
<td>MySQL, SQLite 등</td>
</tr>
<tr>
<td><code>&quot;identity&quot;</code></td>
<td>Postgres 10+의 <code>GENERATED BY DEFAULT AS IDENTITY</code></td>
<td>PostgreSQL 10 이상</td>
</tr>
<tr>
<td><code>&quot;rowid&quot;</code></td>
<td>CockroachDB의 내부 row 식별자</td>
<td>CockroachDB</td>
</tr>
</tbody></table>
<h3 id="uuid-자동-생성-컬럼">UUID 자동 생성 컬럼</h3>
<pre><code class="language-ts">@Entity()
export class User {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Generated(&quot;uuid&quot;)
  uuid: string;
}</code></pre>
<h3 id="숫자-자동-증가-컬럼-비기본키">숫자 자동 증가 컬럼 (비기본키)</h3>
<pre><code class="language-ts">@Entity()
export class Order {
  @PrimaryGeneratedColumn()
  id: number;

  @Column()
  @Generated(&quot;increment&quot;)
  orderNumber: number;
}
</code></pre>
<h2 id="column-옵션">Column 옵션</h2>
<table>
<thead>
<tr>
<th>옵션 이름</th>
<th>설명</th>
<th>사용 예시 / 적용 대상</th>
<th>비고</th>
</tr>
</thead>
<tbody><tr>
<td><code>type</code></td>
<td>컬럼의 데이터베이스 타입 지정</td>
<td><code>&quot;varchar&quot;</code>, <code>&quot;int&quot;</code>, <code>&quot;decimal&quot;</code> 등</td>
<td>필수</td>
</tr>
<tr>
<td><code>name</code></td>
<td>DB에서의 컬럼명 (기본: 변수명과 동일)</td>
<td><code>name: &quot;user_name&quot;</code></td>
<td></td>
</tr>
<tr>
<td><code>length</code></td>
<td>문자열 컬럼 길이 지정 (<code>varchar(150)</code> 등)</td>
<td><code>length: 150</code></td>
<td>문자형 컬럼에서만 적용</td>
</tr>
<tr>
<td><code>width</code></td>
<td>숫자 타입의 <strong>표현 너비</strong> 설정 (MySQL만)</td>
<td><code>width: 5</code></td>
<td>시각적 효과용, MySQL만 지원</td>
</tr>
<tr>
<td><code>onUpdate</code></td>
<td>ON UPDATE 트리거 (MySQL 전용)</td>
<td><code>onUpdate: &quot;CURRENT_TIMESTAMP&quot;</code></td>
<td><code>timestamp</code> 등에서 사용</td>
</tr>
<tr>
<td><code>nullable</code></td>
<td><code>NULL</code> 허용 여부 (<code>true</code>이면 NULL 허용)</td>
<td><code>nullable: true</code></td>
<td>기본값: <code>false</code></td>
</tr>
<tr>
<td><code>update</code></td>
<td><code>save()</code> 시 해당 컬럼이 업데이트 될지 여부</td>
<td><code>update: false</code></td>
<td>insert만 허용하고 싶을 때</td>
</tr>
<tr>
<td><code>insert</code></td>
<td><code>save()</code> 시 insert 여부 제어</td>
<td><code>insert: false</code></td>
<td>update만 허용하고 싶을 때</td>
</tr>
<tr>
<td><code>select</code></td>
<td><code>find()</code> 등 조회 시 포함 여부 설정</td>
<td><code>select: false</code></td>
<td>기본: <code>true</code>, 숨기고 싶을 때</td>
</tr>
<tr>
<td><code>default</code></td>
<td>DB 레벨에서 기본값 설정</td>
<td><code>default: &quot;guest&quot;</code></td>
<td></td>
</tr>
<tr>
<td><code>primary</code></td>
<td>Primary Key 여부 설정</td>
<td><code>primary: true</code></td>
<td><code>@PrimaryColumn</code>과 동일 역할</td>
</tr>
<tr>
<td><code>unique</code></td>
<td>UNIQUE 제약 조건 부여</td>
<td><code>unique: true</code></td>
<td></td>
</tr>
<tr>
<td><code>comment</code></td>
<td>DB에 컬럼 설명 추가</td>
<td><code>comment: &quot;사용자 이름&quot;</code></td>
<td>일부 DB에서만 지원</td>
</tr>
<tr>
<td><code>precision</code></td>
<td><code>decimal</code> 타입의 전체 자릿수 설정</td>
<td><code>precision: 10</code></td>
<td>정밀 소수용 (<code>12345.6789</code>)</td>
</tr>
<tr>
<td><code>scale</code></td>
<td><code>decimal</code> 타입의 소수점 이하 자릿수 설정</td>
<td><code>scale: 2</code></td>
<td>위와 함께 사용해야 함</td>
</tr>
<tr>
<td><code>zerofill</code></td>
<td>숫자 타입 앞에 0 채움 (MySQL 전용)</td>
<td><code>zerofill: true</code></td>
<td>자동 <code>UNSIGNED</code> 처리됨</td>
</tr>
<tr>
<td><code>unsigned</code></td>
<td>음수를 허용하지 않음 (MySQL 전용)</td>
<td><code>unsigned: true</code></td>
<td>정수형에서만 유효</td>
</tr>
<tr>
<td><code>charset</code></td>
<td>문자 집합 설정</td>
<td><code>charset: &quot;utf8mb4&quot;</code></td>
<td>문자형 컬럼에만 적용</td>
</tr>
<tr>
<td><code>collation</code></td>
<td>정렬 방식 설정</td>
<td><code>collation: &quot;utf8mb4_general_ci&quot;</code></td>
<td>문자형 컬럼에만 적용</td>
</tr>
<tr>
<td><code>enum</code></td>
<td>열거형 값 배열 또는 enum 클래스 설정</td>
<td><code>enum: UserRole</code> or <code>enum: [&quot;admin&quot;, ...]</code></td>
<td><code>enum</code> 또는 <code>set</code> 타입에서 사용</td>
</tr>
<tr>
<td><code>enumName</code></td>
<td>PostgreSQL에서 enum 타입 이름 지정</td>
<td><code>enumName: &quot;user_role_enum&quot;</code></td>
<td>PostgreSQL 전용</td>
</tr>
<tr>
<td><code>asExpression</code></td>
<td>계산된 컬럼 정의 (Generated Column 표현식)</td>
<td><code>asExpression: &quot;price * quantity&quot;</code></td>
<td>MySQL 전용 (<code>VIRTUAL</code>, <code>STORED</code>)</td>
</tr>
<tr>
<td><code>generatedType</code></td>
<td>Generated Column의 저장 방식 지정 (<code>VIRTUAL</code> or <code>STORED</code>)</td>
<td><code>generatedType: &quot;VIRTUAL&quot;</code></td>
<td>MySQL 전용</td>
</tr>
<tr>
<td><code>hstoreType</code></td>
<td>PostgreSQL의 HSTORE 컬럼 처리 방식 지정 (<code>object</code> or <code>string</code>)</td>
<td><code>hstoreType: &quot;object&quot;</code></td>
<td>PostgreSQL 전용</td>
</tr>
<tr>
<td><code>array</code></td>
<td>배열 컬럼 여부 (예: <code>int[]</code>)</td>
<td><code>array: true</code></td>
<td>PostgreSQL, CockroachDB</td>
</tr>
<tr>
<td><code>transformer</code></td>
<td>커스텀 변환기 (DB &lt;-&gt; App 타입 매핑)</td>
<td><code>transformer: { from: ..., to: ... }</code></td>
<td>암호화, 포맷 처리 등에 유용</td>
</tr>
</tbody></table>
]]></description>
        </item>
        <item>
            <title><![CDATA[(NestJS) Nest JS - Exception  Filters]]></title>
            <link>https://velog.io/@cchoi-geon/NestJS-Nest-JS-Exception-Filters</link>
            <guid>https://velog.io/@cchoi-geon/NestJS-Nest-JS-Exception-Filters</guid>
            <pubDate>Thu, 08 May 2025 06:35:48 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://docs.nestjs.com/exception-filters">https://docs.nestjs.com/exception-filters</a></li>
</ul>
<h2 id="exception-filters란">Exception Filters란?</h2>
<ul>
<li>NestJS에서 예외 발생 시 에러 응답의 형식과 처리 방식을 개발자가 직접 제어할 수 있도록 해주는 전용 에러 처리 컴포넌트이다.</li>
<li>NestJS 애플리케이션 전반에서 발생하는 예외를 가로채어, 적절한 응답 형식을 생성하거나 추가적인 처리를 수행할 수 있도록 해주는 구조화된 예외 처리 메커니즘이다.</li>
<li>NestJS의 기본 Exception Filter는 기본적으로 HttpException 또는 그 하위 클래스들을 처리하도록 설계되어 있다.</li>
</ul>
<h2 id="httpexception이란">HttpException이란?</h2>
<ul>
<li>NestJS는 REST API 및 GraphQL API에서 에러 발생 시 HTTP 상태 코드에 맞는 표준화된 에러 응답을 보내기 위해 HttpException 클래스를 제공한다.<pre><code class="language-ts">throw new HttpException(&#39;Forbidden&#39;, HttpStatus.FORBIDDEN);</code></pre>
</li>
<li>이처럼 예외를 발생시키면 NestJS가 자동으로 다음과 같은 응답을 만들어 준다.<pre><code class="language-json">{
&quot;statusCode&quot;: 403,
&quot;message&quot;: &quot;Forbidden&quot;
}</code></pre>
</li>
</ul>
<h4 id="httpexception-생성자-인자">HttpException 생성자 인자</h4>
<pre><code class="language-ts">new HttpException(response, status, options?)</code></pre>
<ul>
<li>respons : JSON 응답에 담길 본문. 문자열이면 메시지로 처리되고, 객체를 넘기면 그대로 응답 바디로 사용됨</li>
<li>status: HTTP 상태 코드 (HttpStatus.FORBIDDEN 등)</li>
<li>options (선택) 내부적으로만 사용하는 cause 값을 포함할 수 있음 (로깅에 유용)</li>
</ul>
<h2 id="메시지-커스터마이징-방법">메시지 커스터마이징 방법</h2>
<h3 id="1-기본-메시지-사용-문자열">(1) 기본 메시지 사용 (문자열)</h3>
<pre><code class="language-ts">throw new HttpException(&#39;Forbidden&#39;, HttpStatus.FORBIDDEN);
</code></pre>
<p>응답:</p>
<pre><code>{
  &quot;statusCode&quot;: 403,
  &quot;message&quot;: &quot;Forbidden&quot;
}</code></pre><ul>
<li>문자열만 넘기면 message 필드만 변경됨. Nest가 자동으로 statusCode를 추가.</li>
</ul>
<h3 id="2-전체-json-응답-구조-커스터마이징-객체-전달">(2) 전체 JSON 응답 구조 커스터마이징 (객체 전달)</h3>
<pre><code class="language-ts">throw new HttpException(
  {
    status: HttpStatus.FORBIDDEN,
    error: &#39;This is a custom message&#39;,
  },
  HttpStatus.FORBIDDEN,
  {
    cause: error  // 내부 로깅용, 클라이언트에게는 응답되지 않음
  }
);</code></pre>
<p>응답:</p>
<pre><code>{
  &quot;status&quot;: 403,
  &quot;error&quot;: &quot;This is a custom message&quot;
}</code></pre><ul>
<li>이 경우 statusCode 대신 status가 들어가고, message 대신 error라는 사용자 지정 키가 사용됨.</li>
<li>내부적으로 로깅할 때 어떤 원인(exception)이 있었는지 추적할 수 있게 함</li>
</ul>
<h2 id="custom-exceptions란">Custom Exceptions란?</h2>
<ul>
<li>NestJS에서 기본으로 제공하는 BadRequestException,NotFoundException 등의 예외 클래스 외에, 프로젝트 상황에 맞게 새롭게 정의한 예외 클래스</li>
</ul>
<h3 id="예시-forbidden-exception-사용">예시: forbidden exception 사용</h3>
<pre><code class="language-ts">// forbidden.exception.ts
import { HttpException, HttpStatus } from &#39;@nestjs/common&#39;;

export class ForbiddenException extends HttpException {
  constructor() {
    super(&#39;Forbidden&#39;, HttpStatus.FORBIDDEN);
  }
}</code></pre>
<pre><code class="language-ts">// cats.controller.ts
@Get()
async findAll() {
  throw new ForbiddenException();
}</code></pre>
<p>응답:</p>
<pre><code>{
  &quot;statusCode&quot;: 403,
  &quot;message&quot;: &quot;Forbidden&quot;
}
</code></pre><h2 id="nestjscommon-에서-지원해주는-exception">@nestjs/common 에서 지원해주는 Exception</h2>
<table>
<thead>
<tr>
<th>클래스 이름</th>
<th>의미</th>
<th>상태 코드</th>
</tr>
</thead>
<tbody><tr>
<td><code>BadRequestException</code></td>
<td>잘못된 요청</td>
<td>400</td>
</tr>
<tr>
<td><code>UnauthorizedException</code></td>
<td>인증 실패</td>
<td>401</td>
</tr>
<tr>
<td><code>ForbiddenException</code></td>
<td>접근 금지</td>
<td>403</td>
</tr>
<tr>
<td><code>NotFoundException</code></td>
<td>리소스 없음</td>
<td>404</td>
</tr>
<tr>
<td><code>ConflictException</code></td>
<td>리소스 충돌</td>
<td>409</td>
</tr>
<tr>
<td><code>InternalServerErrorException</code></td>
<td>서버 에러</td>
<td>500</td>
</tr>
<tr>
<td><code>ServiceUnavailableException</code></td>
<td>서비스 사용 불가</td>
<td>503</td>
</tr>
<tr>
<td><code>ImATeapotException</code></td>
<td>유머용 상태 코드 🍵</td>
<td>418</td>
</tr>
<tr>
<td>⋯ 등 총 20여 개 예외를 제공</td>
<td></td>
<td></td>
</tr>
</tbody></table>
<h3 id="사용방법">사용방법</h3>
<pre><code class="language-ts">throw new NotFoundException(&#39;User not found&#39;);</code></pre>
<p>응답: </p>
<pre><code>{
  &quot;statusCode&quot;: 404,
  &quot;message&quot;: &quot;User not found&quot;
}
</code></pre><h4 id="고급-사용법--options-파라미터-사용">고급 사용법 : options 파라미터 사용</h4>
<pre><code class="language-ts">throw new BadRequestException(&#39;Something bad happened&#39;, {
  cause: new Error(),
  description: &#39;Some error description&#39;,
});</code></pre>
<table>
<thead>
<tr>
<th>항목</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>&#39;Something bad happened&#39;</code></td>
<td>기본 메시지 (<code>message</code> 필드에 들어감)</td>
</tr>
<tr>
<td><code>cause</code></td>
<td>내부 로깅이나 추적용 원인 예외 (응답에는 포함되지 않음)</td>
</tr>
<tr>
<td><code>description</code></td>
<td>사용자 정의 상세 설명 (<code>error</code> 필드에 포함됨)</td>
</tr>
</tbody></table>
<p>응답: </p>
<pre><code>{
  &quot;message&quot;: &quot;Something bad happened&quot;,
  &quot;error&quot;: &quot;Some error description&quot;,
  &quot;statusCode&quot;: 400
}
</code></pre><h2 id="왜-커스텀-exception-filter를-쓰는가">왜 커스텀 Exception Filter를 쓰는가?</h2>
<p>NestJS의 기본 예외 필터는 HttpException 기반 예외에 대해 잘 작동하지만, 다음과 같은 요구가 있을 때는 직접 예외 필터를 구현해야 한다</p>
<ul>
<li>예외 발생 시 로깅 추가</li>
<li>응답 구조를 API 사양에 맞게 수정</li>
<li>클라이언트 요청 경로, 타임스탬프 등을 응답에 포함</li>
<li>예외 타입에 따라 분기 처리</li>
</ul>
<h4 id="예시">예시</h4>
<pre><code class="language-ts">@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();                         // HTTP 컨텍스트 추출
    const response = ctx.getResponse&lt;Response&gt;();            // Express의 Response 객체
    const request = ctx.getRequest&lt;Request&gt;();               // Express의 Request 객체
    const status = exception.getStatus();                    // 상태코드 추출

    // 로깅 서비스 이용 등...
    response.status(status).json({                           // 직접 응답 구성
      statusCode: status,
      timestamp: new Date().toISOString(),
      path: request.url,
    });
  }
}</code></pre>
<table>
<thead>
<tr>
<th>구성 요소</th>
<th>설명</th>
</tr>
</thead>
<tbody><tr>
<td><code>@Catch(HttpException)</code></td>
<td>이 필터는 <code>HttpException</code>만 처리함</td>
</tr>
<tr>
<td><code>ExceptionFilter&lt;T&gt;</code></td>
<td>Nest의 예외 필터 인터페이스. <code>T</code>는 처리할 예외 타입</td>
</tr>
<tr>
<td><code>catch()</code></td>
<td>예외가 발생했을 때 호출되는 메서드</td>
</tr>
<tr>
<td><code>ArgumentsHost</code></td>
<td>현재 실행 컨텍스트(HTTP, WebSocket 등)를 제공</td>
</tr>
<tr>
<td><code>switchToHttp()</code></td>
<td>HTTP 컨텍스트로 전환</td>
</tr>
<tr>
<td><code>getRequest()</code> / <code>getResponse()</code></td>
<td>Express의 Request/Response 객체 추출</td>
</tr>
<tr>
<td><code>response.status().json()</code></td>
<td>직접 응답을 작성 (상태 코드, 경로, 시간 포함)</td>
</tr>
</tbody></table>
<h3 id="catch">Catch(...)</h3>
<ul>
<li>특정 예외 클래스만 걸러내기 위한 데코레이터</li>
<li>특정 예외만 등록해서 걸러내기 가능 </li>
<li>ex) @Catch(BadRequestException, ForbiddenException)</li>
</ul>
<h2 id="exception-filter-적용-범위">Exception Filter 적용 범위</h2>
<h3 id="1--메서드-범위method-scoped">1.  메서드 범위(Method-scoped)</h3>
<pre><code class="language-ts">@Post()
@UseFilters(HttpExceptionFilter)
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}</code></pre>
<h3 id="2-컨트롤러-범위controller-scoped">2. 컨트롤러 범위(Controller-scoped)</h3>
<pre><code class="language-ts">@Controller()
@UseFilters(HttpExceptionFilter)
export class CatsController {
  @Post()
  async create(@Body() createCatDto: CreateCatDto) {
    throw new ForbiddenException();
  }

  @Get()
  async findAll() {
    throw new ForbiddenException();
  }
}</code></pre>
<h3 id="3-전역-범위global-scoped">3. 전역 범위(Global-scoped)</h3>
<h4 id="방법-a-maints에서-적용">방법 A. main.ts에서 적용</h4>
<pre><code class="language-ts">// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());  // 전역 필터 등록
  await app.listen(3000);
}
</code></pre>
<ul>
<li>new HttpExceptionFilter()로 직접 인스턴스화하므로 의존성 주입 불가</li>
<li>예: 로깅 서비스, 설정 서비스 등 주입 불가</li>
</ul>
<h4 id="방법-b-appmodule에서-app_filter-토큰을-활용한-등록-di-가능">방법 B. AppModule에서 APP_FILTER 토큰을 활용한 등록 (DI 가능)</h4>
<pre><code class="language-ts">@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}</code></pre>
<ul>
<li>NestJS가 자동으로 인스턴스를 생성하고 의존성 주입도 지원</li>
<li>진짜 글로벌 필터이면서도 유연하게 활용 가능</li>
</ul>
<h2 id="모든-예외-잡기">모든 예외 잡기</h2>
<ul>
<li>Catch() 데코레이터에 아무 인자도 주지 않음으로써 예외의 종류와 상관없이 전부 잡는 필터를 만드는 것</li>
<li>@Catch()에 아무 인자도 넣지 않으면 모든 종류의 예외(즉, HttpException 이외의 일반 Error, TypeError, ReferenceError 등도 포함)를 포괄 처리할 수 있다.</li>
</ul>
<pre><code class="language-ts">import {
  ExceptionFilter,
  Catch,
  ArgumentsHost,
  HttpException,
  HttpStatus,
} from &#39;@nestjs/common&#39;;
import { HttpAdapterHost } from &#39;@nestjs/core&#39;;

@Catch()
export class CatchEverythingFilter implements ExceptionFilter {
  constructor(private readonly httpAdapterHost: HttpAdapterHost) {}

  catch(exception: unknown, host: ArgumentsHost): void {
    const { httpAdapter } = this.httpAdapterHost;

    const ctx = host.switchToHttp();

    const httpStatus =
      exception instanceof HttpException
        ? exception.getStatus()
        : HttpStatus.INTERNAL_SERVER_ERROR;

    const responseBody = {
      statusCode: httpStatus,
      timestamp: new Date().toISOString(),
      path: httpAdapter.getRequestUrl(ctx.getRequest()),
    };

    httpAdapter.reply(ctx.getResponse(), responseBody, httpStatus);
  }
}</code></pre>
<p>응답: </p>
<pre><code class="language-ts">// ex) 
const responseBody = {
  statusCode: 500,
  timestamp: new Date().toISOString(),
  path: httpAdapter.getRequestUrl(ctx.getRequest()),
};

httpAdapter.reply(ctx.getResponse(), responseBody, 500);

-&gt;
{
  &quot;statusCode&quot;: 500,
  &quot;timestamp&quot;: &quot;2025-05-08T06:30:45.123Z&quot;,
  &quot;path&quot;: &quot;/users&quot;
}

</code></pre>
<h2 id="baseexceptionfilter-상속-후-커스터마이징">BaseExceptionFilter 상속 후 커스터마이징</h2>
<ul>
<li>기존 Nest의 예외 처리 로직을 유지하면서, 거기에 추가 로직을 더하고 싶을 때 사용한다.</li>
<li>BaseExceptionFilter는 Nest가 기본적으로 사용하는 내장 예외 필터</li>
<li>기본 처리 로직을 재사용하면서 필요한 부분만 오버라이딩 할 수 있다.</li>
</ul>
<h3 id="logger--sentry-연동-예시">Logger + Sentry 연동 예시</h3>
<pre><code class="language-ts">import {
  Catch,
  ArgumentsHost,
  HttpException,
  Logger,
} from &#39;@nestjs/common&#39;;
import { BaseExceptionFilter } from &#39;@nestjs/core&#39;;
import { Request } from &#39;express&#39;;
import * as Sentry from &#39;@sentry/node&#39;;

@Catch()
export class AllExceptionsFilter extends BaseExceptionFilter {
  private readonly logger = new Logger(AllExceptionsFilter.name);

  catch(exception: unknown, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const request = ctx.getRequest&lt;Request&gt;();

    const status =
      exception instanceof HttpException
        ? exception.getStatus()
        : 500;

    // Logger
    this.logger.error(
      `[${request.method}] ${request.url} -&gt; ${status}`,
      (exception as any).stack,
    );

    // Sentry 전송
    Sentry.captureException(exception);

    // Nest 기본 처리 수행 (statusCode, message 응답 전송)
    super.catch(exception, host);
  }
}
</code></pre>
<h3 id="적용-방법-1-maints에서-httpadapter-수동-주입">적용 방법 1. main.ts에서 HttpAdapter 수동 주입</h3>
<pre><code class="language-ts">async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  const { httpAdapter } = app.get(HttpAdapterHost);
  app.useGlobalFilters(new AllExceptionsFilter(httpAdapter));

  await app.listen(3000);
}</code></pre>
<h3 id="적용-방법-2-app_filter-토큰을-이용한-의존성-주입-방식">적용 방법 2. APP_FILTER 토큰을 이용한 의존성 주입 방식</h3>
<pre><code class="language-ts">@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: AllExceptionsFilter,
    },
  ],
})
export class AppModule {}
</code></pre>
<h3 id="응답-예시">응답 예시</h3>
<p>요청: GET /users/999</p>
<p>예외: throw new NotFoundException(&#39;User not found&#39;)</p>
<p>클라이언트 응답:</p>
<pre><code>{
  &quot;statusCode&quot;: 404,
  &quot;message&quot;: &quot;User not found&quot;
}</code></pre><p>로그 (콘솔):</p>
<pre><code>[ERROR] AllExceptionsFilter - [GET] /users/999 -&gt; 404
Error: User not found
    at ...</code></pre><p>Sentry: 예외 상세 정보가 자동으로 전송</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(AWS) AWS Lightsail Nest Js 인스턴스 CI/CD]]></title>
            <link>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-Nest-Js-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-CICD</link>
            <guid>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-Nest-Js-%EC%9D%B8%EC%8A%A4%ED%84%B4%EC%8A%A4-CICD</guid>
            <pubDate>Thu, 08 May 2025 05:35:20 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-블로그">참고 블로그</h2>
<ul>
<li><p><a href="https://velog.io/@ckdwns9121/Github-Action%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-AWS-Lightsail-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-1%ED%8E%B8">https://velog.io/@ckdwns9121/Github-Action을-이용해-AWS-Lightsail-CICD-파이프라인-구축하기-1편</a></p>
</li>
<li><p><a href="https://velog.io/@ckdwns9121/Github-Action%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%B4-AWS-Lightsail-CICD-%ED%8C%8C%EC%9D%B4%ED%94%84%EB%9D%BC%EC%9D%B8-%EA%B5%AC%EC%B6%95%ED%95%98%EA%B8%B0-2%ED%8E%B8">https://velog.io/@ckdwns9121/Github-Action을-이용해-AWS-Lightsail-CICD-파이프라인-구축하기-2편</a></p>
</li>
</ul>
<h2 id="과정">과정</h2>
<ol>
<li>Lightsail 인스턴스 생성 후 SSH 키페어 생성</li>
<li>IAM 사용자 생성 및 AWS AccessKey 발급 받기</li>
<li>Lightsail에 aws-cli 설치</li>
<li>Lightsail에 configure 설정</li>
<li>Github 프로젝트(리포지토리)에 SecretKey 등록</li>
<li>Github Action의 Workflow 생성</li>
</ol>
<h2 id="1-lightsail-인스턴스-생성-후-ssh-키페어-생성">1. Lightsail 인스턴스 생성 후 SSH 키페어 생성</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/06519d6e-a243-45c1-9632-a9196162c38f/image.png" alt=""></p>
<ul>
<li>SSH key &gt; Create custom key 클릭 </li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/4e88f32c-d6c1-46cc-b194-776f4b3cccf7/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/f6e62366-782f-45c8-a8ec-465ec9421472/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/63985719-ee2f-44e6-8a3b-43cdd529785c/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/661ef9f0-ecfc-4d97-9bc3-a267ebcf983e/image.png" alt=""></p>
<ul>
<li>다운로드 받은 .pem키 원하는 곳에 위치 시키기 (Desktop, ssh ...)</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/7dd76e19-c564-4484-9612-535e420932b8/image.png" alt=""></p>
<ul>
<li><p>해당 .pem을 cat으로 열어서 저장해놓기 (---BEGIN RSA PRIVATE KEY---- 부터 제일 마지막 ---END RSA PRIVATE KEY--- 을 포함한 값까지!) </p>
</li>
<li><p>아래의 경우 맨 마지막에 보이는 %는 포함시키면 안된다 (이거 때문에 오류가 </p>
</li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/96cbb3fa-7949-442b-a526-19bbeab1f8ae/image.png" alt=""></p>
<ul>
<li>권한 에러가 날 수 있기 때문에 권한 수정해놓기<pre><code>chmod 400 CICDTEST.pem</code></pre></li>
</ul>
<h2 id="2-iam-사용자-생성-및-aws-accesskey-발급-받기">2. IAM 사용자 생성 및 AWS AccessKey 발급 받기</h2>
<ul>
<li>IAM에서 사용자 생성 → 권한은 AWSCodeDeplyFullAccess 추가하기 → 생성 후 액세스 키 생성해서 다운 받기 </li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/941084c1-7fe6-4609-b032-bdfb65a91f76/image.png" alt=""></p>
<h2 id="3-lightsail에-aws-cli-설치">3. Lightsail에 aws-cli 설치</h2>
<ul>
<li>aws-cli 설치: </li>
</ul>
<pre><code>sudo apt-get update &amp;&amp; sudo apt-get install awscli</code></pre><p><img src="https://velog.velcdn.com/images/cchoi-geon/post/e55e71f6-fdea-4cfb-b809-91f56ad24c6b/image.png" alt=""></p>
<ul>
<li>설치 후 버전 확인 : </li>
</ul>
<p><code>aws --version</code></p>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/913f5d29-a1e7-43c2-8704-96e573f9af33/image.png" alt=""></p>
<h2 id="4-lightsail-configure-설정하기">4. Lightsail Configure 설정하기</h2>
<pre><code>aws configure</code></pre><ul>
<li>AWS Access Key ID: IAM에서 발급받은 ACCESS_ID</li>
<li>AWS Secret Access Key ID: IAM에서 ACCESS_KEY</li>
<li>region:  ap-northeast-2 (서울)</li>
<li>output format : json
<img src="https://velog.velcdn.com/images/cchoi-geon/post/2585b0ef-209e-4a15-b30a-d30705d871b4/image.png" alt=""></li>
</ul>
<h2 id="5-github-프로젝트에-secretkey-등록하기">5. Github 프로젝트에 SecretKey 등록하기</h2>
<ul>
<li><p>CI/CD를 적용할 리포지토리의 Settings &gt; Secrets and variables &gt; Actions 클릭
<img src="https://velog.velcdn.com/images/cchoi-geon/post/5efd5df7-8bd6-499c-ae9e-110a05195647/image.png" alt=""></p>
</li>
<li><p>이후 키 등록</p>
<ul>
<li>AWS_ACCESS_KEY_ID : IAM에서 발급 받은 ACCESS_ID</li>
<li>AWS_SECRET_ACCESS_KEY: IAM에서 발급 받은 ACCESS_KEY</li>
<li>LIGHTSAIL_HOST: Lightsail 인스턴스의 public IP</li>
<li>LIGHTSAIL_SSH_KEY: 인스턴스를 생성할 때 발급받은 SSH 키페어.<ul>
<li>---BEGIN RSA PRIVATE KEY---- 부터 제일 마지막 ---END RSA PRIVATE KEY--- 을 포함한 값까지!</li>
</ul>
</li>
</ul>
</li>
<li><p>예시) 
Name: AWS_ACCESS_KEY_ID
Secret: IAM 콘솔에서 발급 받은 ACCESS_ID
<img src="https://velog.velcdn.com/images/cchoi-geon/post/f1e896d3-2163-476c-89f9-8cee29e9ded6/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/2ac7607c-4411-4f1b-898a-3e92f3f92498/image.png" alt=""></p>
</li>
</ul>
<h2 id="6-github-actions-workflow-생성">6. Github Actions Workflow 생성</h2>
<ul>
<li><p>CI/CD를 적용할 리포지토리의 Actions &gt; set up a workflow yourself 클릭 
<img src="https://velog.velcdn.com/images/cchoi-geon/post/b070ddfb-f651-4a46-9b06-aa5a6e8e2b20/image.png" alt=""></p>
</li>
<li><p>아래 코드 복붙</p>
<pre><code class="language-yml">name: CI CD
</code></pre>
</li>
</ul>
<p>on:
  push:
    branches: [&#39;main&#39;]
  pull_request:
    branches: [&#39;main&#39;]</p>
<p>env:
  LIGHTSAIL_SSH_KEY: ${{ secrets.LIGHTSAIL_SSH_KEY }}
  LIGHTSAIL_HOST: ${{ secrets.LIGHTSAIL_HOST }}
  LIGHTSAIL_USERNAME: bitnami
  AWS_REGION: ap-northeast-2</p>
<p>jobs:
  deploy:
    runs-on: ubuntu-latest</p>
<pre><code>steps:
  - name: 소스 코드 체크아웃
    uses: actions/checkout@v3

  - name: Node.js 설정
    uses: actions/setup-node@v3
    with:
      node-version: 20 // 원하는 버전 선택

  - name: 의존성 설치
    run: sudo npm install

  - name: 빌드
    run: sudo npm run build

  - name: AWS 자격 증명 설정
    uses: aws-actions/configure-aws-credentials@v3
    with:
      aws-region: ${{ env.AWS_REGION }}
      aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
      aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

  - name: SSH 키 설정
    run: |
      mkdir -p $HOME/.ssh
      echo &quot;${{ secrets.LIGHTSAIL_SSH_KEY }}&quot; &gt; $HOME/.ssh/deploy_key
      chmod 600 $HOME/.ssh/deploy_key
      eval $(ssh-agent -s)
      ssh-add $HOME/.ssh/deploy_key
      ssh-keyscan -H ${{ secrets.LIGHTSAIL_HOST }} &gt;&gt; $HOME/.ssh/known_hosts

  - name: 기존 디렉토리 정리 및 권한 설정
    uses: appleboy/ssh-action@master
    with:
      host: ${{ secrets.LIGHTSAIL_HOST }}
      username: ${{ env.LIGHTSAIL_USERNAME }}
      key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
      script: |
        sudo rm -rf /home/bitnami/test-cicd // test-cicd를 자신의 프로젝트명으로 수정!            sudo mkdir -p /home/bitnami/test-cicd
        sudo chown -R bitnami:bitnami /home/bitnami/test-cicd // test-cicd를 자신의 프로젝트명으로 수정!
        sudo chmod -R 755 /home/bitnami/test-cicd // test-cicd를 자신의 프로젝트명으로 수정!

  - name: 애플리케이션 파일 배포
    uses: appleboy/scp-action@master
    with:
      host: ${{ secrets.LIGHTSAIL_HOST }}
      username: ${{ env.LIGHTSAIL_USERNAME }}
      key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
      source: &#39;dist/,package.json,package-lock.json&#39;
      target: &#39;/home/bitnami/test-cicd&#39; // test-cicd를 자신의 프로젝트명으로 수정!
      strip_components: 0
      overwrite: true

  - name: PM2 프로세스 재시작
    uses: appleboy/ssh-action@master
    with:
      host: ${{ secrets.LIGHTSAIL_HOST }}
      username: ${{ env.LIGHTSAIL_USERNAME }}
      key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
      script: |
        cd /home/bitnami/test-cicd // test-cicd를 자신의 프로젝트명으로 수정!
        sudo npm ci --production
        sudo pm2 restart main || sudo pm2 start dist/main.js --name main</code></pre><pre><code>![](https://velog.velcdn.com/images/cchoi-geon/post/26309da5-9659-4465-ba94-a6b5c1663d6a/image.png)


## workflow 설명 
```yml
name: CI CD

# main 브랜치에 push 또는 pull request 발생 시 워크플로우 실행
on:
  push:
    branches: [&#39;main&#39;]
  pull_request:
    branches: [&#39;main&#39;]

# 공통 환경 변수 정의 (GitHub Secrets 사용)
env:
  LIGHTSAIL_SSH_KEY: ${{ secrets.LIGHTSAIL_SSH_KEY }}
  LIGHTSAIL_HOST: ${{ secrets.LIGHTSAIL_HOST }}
  LIGHTSAIL_USERNAME: bitnami
  AWS_REGION: ap-northeast-2

jobs:
  deploy:
    runs-on: ubuntu-latest  # 최신 Ubuntu 환경에서 실행

    steps:
      # GitHub 저장소에서 소스 코드를 체크아웃
      - name: 소스 코드 체크아웃
        uses: actions/checkout@v3

      # Node.js 20버전 설정
      - name: Node.js 설정
        uses: actions/setup-node@v3
        with:
          node-version: 20 # 원하는 Node.js 버전

      # npm 의존성 설치
      - name: 의존성 설치
        run: sudo npm install

      # 빌드 명령 실행 (예: Next.js 앱 빌드)
      - name: 빌드
        run: sudo npm run build

      # AWS 자격 증명 환경 설정 (Secrets에 저장된 키 사용)
      - name: AWS 자격 증명 설정
        uses: aws-actions/configure-aws-credentials@v3
        with:
          aws-region: ${{ env.AWS_REGION }}
          aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
          aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}

      # Lightsail 서버 접근을 위한 SSH 키 설정
      - name: SSH 키 설정
        run: |
          mkdir -p $HOME/.ssh
          echo &quot;${{ secrets.LIGHTSAIL_SSH_KEY }}&quot; &gt; $HOME/.ssh/deploy_key
          chmod 600 $HOME/.ssh/deploy_key
          eval $(ssh-agent -s)
          ssh-add $HOME/.ssh/deploy_key
          ssh-keyscan -H ${{ secrets.LIGHTSAIL_HOST }} &gt;&gt; $HOME/.ssh/known_hosts

      # 서버에서 기존 프로젝트 디렉토리 삭제 후 재생성, 권한 설정
      - name: 기존 디렉토리 정리 및 권한 설정
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.LIGHTSAIL_HOST }}
          username: ${{ env.LIGHTSAIL_USERNAME }}
          key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
          script: |
            # 기존 디렉토리 삭제 및 생성 (프로젝트명에 따라 경로 변경 가능)
            sudo rm -rf /home/bitnami/test-cicd
            sudo mkdir -p /home/bitnami/test-cicd
            sudo chown -R bitnami:bitnami /home/bitnami/test-cicd
            sudo chmod -R 755 /home/bitnami/test-cicd

      # 빌드된 파일을 서버로 전송
      - name: 애플리케이션 파일 배포
        uses: appleboy/scp-action@master
        with:
          host: ${{ secrets.LIGHTSAIL_HOST }}
          username: ${{ env.LIGHTSAIL_USERNAME }}
          key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
          source: &#39;dist/,package.json,package-lock.json&#39;
          target: &#39;/home/bitnami/test-cicd&#39; # 이 경로는 프로젝트명에 맞게 수정
          strip_components: 0
          overwrite: true

      # PM2를 통해 앱 재시작 또는 새로 시작
      - name: PM2 프로세스 재시작
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.LIGHTSAIL_HOST }}
          username: ${{ env.LIGHTSAIL_USERNAME }}
          key: ${{ secrets.LIGHTSAIL_SSH_KEY }}
          script: |
            cd /home/bitnami/test-cicd  # 해당 디렉토리로 이동
            sudo npm ci --production    # production 환경용 의존성만 설치
            # main이라는 이름으로 프로세스를 재시작, 없으면 새로 실행
            sudo pm2 restart main || sudo pm2 start dist/main.js --name main
</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[(AWS) AWS Lightsail SSH 접속]]></title>
            <link>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-SSH-%EC%A0%91%EC%86%8D</link>
            <guid>https://velog.io/@cchoi-geon/AWS-AWS-Lightsail-SSH-%EC%A0%91%EC%86%8D</guid>
            <pubDate>Thu, 08 May 2025 05:08:30 GMT</pubDate>
            <description><![CDATA[<h2 id="인스턴스-생성">인스턴스 생성</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/e7ab7119-13b9-464d-93cf-67290b066a7c/image.png" alt=""></p>
<ul>
<li>SSH key &gt; Create custom key 선택
<img src="https://velog.velcdn.com/images/cchoi-geon/post/8bdbe03d-ecb0-492e-9cdb-0ae7ec4ce864/image.png" alt=""></li>
</ul>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/95c00e13-93d6-4b2f-b45c-535a12c9e8cd/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/7a12df8e-0988-4dc0-b3fe-cfeb1dbe9ce3/image.png" alt="">
<img src="https://velog.velcdn.com/images/cchoi-geon/post/371761a6-f0f1-489b-9844-5fcbfe49018a/image.png" alt=""></p>
<ul>
<li><p>다운 받은 키는 원하는 곳에 위치시키기 (Desktop, ssh 등)</p>
</li>
<li><p>아래 예시는 Desktop에 위치시킴
<img src="https://velog.velcdn.com/images/cchoi-geon/post/eea504af-4a65-475d-853e-42e199a37d30/image.png" alt=""></p>
</li>
<li><p>권한 문제가 발생할 수 있기 때문에 chmod 400 your-key.pem 로 권한 변경</p>
<pre><code>chmod 400 ~Desktop/CICDTEST.pem</code></pre></li>
</ul>
<h2 id="ssh-접속">SSH 접속</h2>
<pre><code class="language-jsx">ssh -i 키위치/키이름.pem bitnami@&lt;인스턴스-공인IP&gt;</code></pre>
<pre><code class="language-jsx">ssh -i ~/Desktop/CICDTEST.pem bitnami@3.34.135.165
</code></pre>
<ul>
<li>접속된 상태
<img src="https://velog.velcdn.com/images/cchoi-geon/post/c8118753-bdad-4e74-a4f6-df03f6e11068/image.png" alt=""></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(NestJs) NestJs - Module]]></title>
            <link>https://velog.io/@cchoi-geon/NestJs-NestJs-Module</link>
            <guid>https://velog.io/@cchoi-geon/NestJs-NestJs-Module</guid>
            <pubDate>Wed, 07 May 2025 09:29:23 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://docs.nestjs.com/modules">https://docs.nestjs.com/modules</a></li>
</ul>
<h2 id="module이란">Module이란?</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/b9d2aec5-1feb-4e7c-bc44-edaaa36d445c/image.png" alt=""></p>
<ul>
<li>@Module() 데코레이터로 정의된 클래스이며, NestJS에서 어플리케이션을 구조화하고 관리하는 기본 단위이다.</li>
<li>모든 NestJS 앱은 최소한 하나의 루트 모듈(AppModule) 을 가진다.</li>
<li>모듈은 providers, controllers, imports, exports로 구성되어 내부 구성요소를 캡슐화하며 재사용 가능하게 만든다.</li>
</ul>
<h3 id="주요-속성">주요 속성</h3>
<ul>
<li>providers: 서비스, 유틸 등 의존성 주입 대상</li>
<li>controllers: 요청/응답 처리용 컨트롤러</li>
<li>imports: 다른 모듈을 현재 모듈에서 사용</li>
<li>exports: 외부 모듈에서 사용할 수 있도록 내보냄</li>
</ul>
<pre><code class="language-ts">// cats.module.ts
import { Module } from &#39;@nestjs/common&#39;;
import { CatsController } from &#39;./cats.controller&#39;;
import { CatsService } from &#39;./cats.service&#39;;

@Module({
  imports:[AnyModule],
  controllers: [CatsController],
  providers: [CatsService],  // CatsService 제공
  exports: [CatsService]  // 외부 모듈에서도 사용 가능하도록 export
})
export class CatsModule {}</code></pre>
<pre><code class="language-ts">// app.module.ts (또는 다른 모듈)
import { Module } from &#39;@nestjs/common&#39;;
import { CatsModule } from &#39;./cats/cats.module&#39;;

@Module({
  imports: [CatsModule],  // CatsModule을 불러옴
})
export class AppModule {}</code></pre>
<h2 id="exports를-해야하는-이유">exports를 해야하는 이유</h2>
<ul>
<li>exports를 사용하지 않으면 해당 모듈의 Provider를 다른 모듈에서 사용할 수 없다.</li>
<li>즉, provider는 모듈 내부에서 기본적으로 캡슐화되어 있어서 exports 하지 않으면 외부에 공개되지 않는다.(private 설정이 Default라고 생각하는 게 편할 것 같다.)</li>
<li>위 코드를 예시로 들면, CatsService를 담고 있는 Module에서 exports: [CatsService]를 설정해주지 않는다면, 다른 모듈에서 CatsService에 접근할 수 없다.<pre><code class="language-ts">// cats.module.ts
import { Module } from &#39;@nestjs/common&#39;;
import { CatsController } from &#39;./cats.controller&#39;;
import { CatsService } from &#39;./cats.service&#39;;
</code></pre>
</li>
</ul>
<p>@Module({
  imports:[AnyModule],
  controllers: [CatsController],
  providers: [CatsService],  // CatsService 제공
  exports: []  // CatsService 외부로 내보내지 않음 (private)
})
export class CatsModule {}</p>
<pre><code>```ts
// anyModule.module.ts
@Module({
  imports: [CatsModule],
  providers: [AnyService],
})
export class AnyModule {}
</code></pre><pre><code class="language-ts">// any.service.ts
@Injectable()
export class AnyService {
  constructor(private catsService: CatsService) {} // 이 시점에서 에러 발생
}</code></pre>
<ul>
<li>AnyModule에서는 CatsModule에 있는 CatsService에 접근할 수 없다.</li>
<li>위와 같은 경우 CatsService를 주입하려 하면 NestJS는 Cannot resolve dependency 에러를 던지게 된다.</li>
</ul>
<h2 id="global-모듈-만들기">Global 모듈 만들기</h2>
<ul>
<li>다른 모듈에 접근할 때 해당 모듈에서 imports:[Module...]로 불러와야한다. </li>
<li>하지만 @Global() 데코레이터를 사용하면 다른 모듈에서 해당 모듈에 접근할 때 imports를 하지 않아도 바로 사용이 가능하다.</li>
<li>단, exports로 사용할 Provider를 설정해야 한다!!</li>
<li>보통 공통 서비스(Helper, Logger, DB, Auth) 등은 Global로 설정한다.</li>
</ul>
<pre><code class="language-ts">import { Module, Global } from &#39;@nestjs/common&#39;;
import { CatsController } from &#39;./cats.controller&#39;;
import { CatsService } from &#39;./cats.service&#39;;

@Global()
@Module({
  controllers: [CatsController],
  providers: [CatsService],
  exports: [CatsService],  // 반드시 exports도 함께 해야 외부 사용 가능
})
export class CatsModule {}</code></pre>
<pre><code class="language-ts">// 다른 모듈에서 CatsModule을 import하지 않아도 CatsService 사용 가능
@Injectable()
export class OtherService {
  constructor(private catsService: CatsService) {}
}</code></pre>
<h2 id="dynamic-module이란">Dynamic Module이란?</h2>
<ul>
<li>반적으로 모듈은 정적으로 정의되며, @Module() 데코레이터 내부에서 providers, controllers, imports, exports 등을 미리 지정해야 한다.</li>
<li>하지만 경우에 따라 앱이 실행될 때 동적으로 모듈을 설정해야 하는 상황이 발생할 수 있다.</li>
<li>예를 들어 데이터베이스 연결 → 환경 변수에 따라 다른 DB에 연결해야 할 때, API 인증 모듈 → 특정 설정 값에 따라 다른 인증 전략을 사용할 때, 다국어 지원 (i18n) → 설정에 따라 지원하는 언어를 다르게 구성할 때</li>
<li>이러한 경우 동적 모듈(Dynamic Module) 을 사용하면 모듈을 유연하게 생성 및 구성할 수 있다.</li>
</ul>
<pre><code class="language-ts">import { Module, DynamicModule } from &#39;@nestjs/common&#39;;
import { createDatabaseProviders } from &#39;./database.providers&#39;;
import { Connection } from &#39;./connection.provider&#39;;

@Module({
  providers: [Connection], // 기본 Provider
  exports: [Connection],
})
export class DatabaseModule {
  static forRoot(entities = [], options?): DynamicModule {
    const providers = createDatabaseProviders(options, entities); // 동적 Provider 생성

    return {
      module: DatabaseModule,
      providers: providers, // 동적으로 설정된 providers 추가
      exports: providers,
    };
  }
}</code></pre>
<ul>
<li><code>forRoot()</code> 메서드를 통해 모듈을 생성할 때 옵션을 받을 수 있음.</li>
<li>이 옵션을 기반으로 <code>createDatabaseProviders()</code> 함수를 호출하여 <strong>동적으로 Provider를 생성</strong>.</li>
<li><code>providers</code>와 <code>exports</code>를 설정하여 <strong>해당 설정을 다른 모듈에서도 사용 가능</strong>하게 함.</li>
</ul>
<pre><code class="language-ts">import { Module } from &#39;@nestjs/common&#39;;
import { DatabaseModule } from &#39;./database/database.module&#39;;

@Module({
  imports: [DatabaseModule.forRoot([&#39;User&#39;, &#39;Product&#39;], { sync: true })],
})
export class AppModule {}</code></pre>
<ul>
<li>forRoot() 메서드에서 [&#39;User&#39;, &#39;Product&#39;] 엔티티와 sync: true 옵션을 받아 런타임에서 동적으로 provider를 설정한다.</li>
</ul>
<h3 id="글로벌-동적-모듈">글로벌 동적 모듈</h3>
<ul>
<li>만약 전역(Global)으로 사용할 동적 모듈을 만들고 싶다면 { global: true } 옵션을 추가할 수 있다.</li>
</ul>
<pre><code class="language-ts">static forRoot(entities = [], options?): DynamicModule {
  const providers = createDatabaseProviders(options, entities);

  return {
    global: true,  // 글로벌 모듈 설정
    module: DatabaseModule,
    providers: providers,
    exports: providers,
  };
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[(NestJs) NestJs - Provider ]]></title>
            <link>https://velog.io/@cchoi-geon/NestJs-NestJs-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-Provider</link>
            <guid>https://velog.io/@cchoi-geon/NestJs-NestJs-%EA%B3%B5%EC%8B%9D%EB%AC%B8%EC%84%9C-Provider</guid>
            <pubDate>Wed, 07 May 2025 08:53:18 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://docs.nestjs.com/providers">https://docs.nestjs.com/providers</a></li>
</ul>
<h2 id="provider란">Provider란?</h2>
<p><img src="https://velog.velcdn.com/images/cchoi-geon/post/c8d34e45-92dc-4c44-be13-9a329f7caebe/image.png" alt=""></p>
<ul>
<li>서비스, 리포지토리, 팩토리, 헬퍼와 같은 많은 기본 Nest 클래스이다.</li>
<li>Provider는 종속성으로 주입될 수 있어 객체가 서로 다양한 관계를 형성할 수 있다. </li>
</ul>
<h2 id="service-생성-cli-명령어">Service 생성 CLI 명령어</h2>
<pre><code>nest g service [경로]/[모듈명] </code></pre><pre><code>nest g service api/cats </code></pre><p>-&gt; src/api/cats/cats.service.ts 생성 </p>
<ul>
<li>만약 경로를 설정하지 않고 바로 모듈명을 입력하면 src아래 모듈아래 생성된다.</li>
<li><blockquote>
<p>src/cats/cats.service.ts 생성</p>
</blockquote>
</li>
<li>본인이 사용하고 있는 디렉토리 구조에 맞게 생성하면 된다.</li>
</ul>
<pre><code>nest g service api/cats  --no-sepc</code></pre><ul>
<li>--no-spec을 붙이면 테스트 코드가 같이 생기지 않는다. </li>
</ul>
<h2 id="injectable-데코레이터">@Injectable() 데코레이터</h2>
<ul>
<li>@Injectable()를 추가하면 NestJS가 해당 클래스를 &quot;의존성 주입 컨테이너(DI)&quot;에서 관리할 수 있도록 설정한다.</li>
<li>의존성 주입이란? : 객체 간의 의존성을 직접 생성하는 대신, 외부에서 주입하여 관리하는 디자인 패턴<pre><code class="language-ts">import { Injectable } from &#39;@nestjs/common&#39;;
import { Cat } from &#39;./interfaces/cat.interface&#39;;
</code></pre>
</li>
</ul>
<p>@Injectable()
export class CatsService {
  private readonly cats: Cat[] = [];</p>
<p>  create(cat: Cat) {
    this.cats.push(cat);
  }</p>
<p>  findAll(): Cat[] {
    return this.cats;
  }
}</p>
<pre><code>
- 위와 같이 CatsService를 구성했을 때, 다른 컴포넌트(예: 컨트롤러)에서 주입(Injection)하여 사용할 수 있다.
```ts
@Controller(&#39;cats&#39;)
export class CatsController {
  constructor(private readonly catsService: CatsService) {} // DI (의존성 주입)</code></pre><h2 id="provider-등록">Provider 등록</h2>
<ul>
<li>NestJS는 IoC (Inversion of Control) 원칙에 따라 클래스 간 의존성을 Nest가 자동으로 주입해주는 구조이다</li>
<li>이를 위해선 Nest가 해당 provider(CatsService 같은)를 미리 알고 있어야 한다. </li>
</ul>
<pre><code class="language-ts">import { Module } from &#39;@nestjs/common&#39;;
import { CatsController } from &#39;./cats/cats.controller&#39;;
import { CatsService } from &#39;./cats/cats.service&#39;;

@Module({
  controllers: [CatsController],   // 요청을 처리하는 컨트롤러 등록
  providers: [CatsService],        // 주입받을 서비스(= Provider) 등록
})
export class AppModule {}</code></pre>
<h2 id="스코프란">스코프란?</h2>
<ul>
<li>NestJS에서 스코프(Scope)는 Provider(서비스, 리포지토리, 가드 등)의 생명 주기를 결정하는 설정이다.</li>
<li>기본적으로 NestJS의 Provider는 싱글톤(Singleton)으로 동작하며, 애플리케이션이 실행될 때 한 번만 생성한다.</li>
<li>하지만 특정 경우(예: 요청마다 새로운 인스턴스 필요)에는 Request-Scoped(요청 단위 스코프)로 변경할 수 있다.</li>
</ul>
<h4 id="기본-provider의-생명-주기-singleton">기본 Provider의 생명 주기 (Singleton)</h4>
<pre><code class="language-ts">import { Injectable } from &#39;@nestjs/common&#39;;

@Injectable()
export class CatsService {
  private count = 0;

  increment() {
    this.count++;
    return this.count;
  }
}
</code></pre>
<ul>
<li>애플리케이션이 실행되는 동안 단 하나의 인스턴스만 유지한다.</li>
<li>즉, 모든 요청에서 CatsService 내부는 count 값을 공유한다.</li>
</ul>
<h4 id="request-scoped-요청-단위-스코프">Request-Scoped (요청 단위 스코프)</h4>
<ul>
<li>요청(Request)마다 새로운 인스턴스를 생성하고 싶다면, 스코프를 Scope.REQUEST로 설정하면 된다.<pre><code class="language-ts">import { Injectable, Scope } from &#39;@nestjs/common&#39;;
</code></pre>
</li>
</ul>
<p>@Injectable({ scope: Scope.REQUEST }) // 요청 단위 스코프 설정
export class CatsService {
  private count = 0;</p>
<p>  increment() {
    this.count++;
    return this.count;
  }
}</p>
<pre><code>- 매번 요청이 들어올 때마다 새로운 CatsService 인스턴스가 생성된다.
- count 값이 요청마다 초기화됨 → 요청 간 데이터 공유 X.
- 특정 요청 단위에서만 유지해야 하는 상태(예: 트랜잭션, 요청 ID 추적 등)에 유용하다.

</code></pre>]]></description>
        </item>
        <item>
            <title><![CDATA[(TypeORM) TypeORM - Getting Start]]></title>
            <link>https://velog.io/@cchoi-geon/TypeORM-TypeORM-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EA%B3%B5%EB%B6%80-Getting-Start</link>
            <guid>https://velog.io/@cchoi-geon/TypeORM-TypeORM-%EA%B3%B5%EC%8B%9D-%EB%AC%B8%EC%84%9C-%EA%B3%B5%EB%B6%80-Getting-Start</guid>
            <pubDate>Tue, 06 May 2025 10:58:34 GMT</pubDate>
            <description><![CDATA[<h2 id="참고-문서">참고 문서</h2>
<ul>
<li><a href="https://typeorm.io/">https://typeorm.io/</a></li>
<li>해당 글은 NestJS 프레임워크를 기반으로 설명합니다.</li>
<li>Database는 MySQL을 기준으로 합니다.</li>
</ul>
<h2 id="typeorm-라이브러리-다운로드-및-기타-설정">TypeORM 라이브러리 다운로드 및 기타 설정</h2>
<ol>
<li>TypeORM + DB 드라이버 설치<pre><code>npm install @nestjs/typeorm typeorm mysql2
</code></pre></li>
</ol>
<pre><code>
2. TypeORM 설정
```ts
// app.module.ts
import { Module } from &#39;@nestjs/common&#39;;
import { TypeOrmModule } from &#39;@nestjs/typeorm&#39;;
import { User } from &#39;./user/user.entity&#39;;

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: &#39;postgres&#39;, // 또는 &#39;mysql&#39;, &#39;sqlite&#39; 등
      host: &#39;localhost&#39;,
      port: 5432,
      username: &#39;your_username&#39;,
      password: &#39;your_password&#39;,
      database: &#39;your_database&#39;,
      entities: [User], // 또는 path: [__dirname + &#39;/**/*.entity{.ts,.js}&#39;]
      synchronize: true, // 개발용 옵션 (운영환경에서는 false)
    }),
    TypeOrmModule.forFeature([User]), // Repository 사용을 위한 Entity 등록
  ],
})
export class AppModule {}</code></pre>]]></description>
        </item>
    </channel>
</rss>