Search

6. 본격 프로젝트 시작

이번 프로젝트에서는 중고차 견적을 제공하는 API를 만드는 방법을 다룬다.
이전에 개발한 메시지 애플리케이션에서 한 단계 더 나아가, 보다 구체적인 기능을 갖춘 애플리케이션을 만들어보자.

프로젝트 개요

사용자가 자신의 차량의 판매할 때 적절한 가격을 설정하는 것은 어려운 일이다.
이를 해결하기 위해 차량의 기본 정보를 입력하면 예상 판매 가격을 제공하는 API를 개발할 예정이다.
주요 기능은 다음과 같다.
1.
사용자 인증: 이메일 및 비밀번호를 통한 가입 및 로그인 기능
2.
차량 예상 가격 제공: 사용자가 차량 정보를 입력하면 예상 판매 가격을 반환 (차종, 모델, 연식, 주행거리 등)
3.
실제 판매 가격 입력: 사용자가 차량을 판매한 후 실제 판매 가격을 입력 가능
4.
관리자 검토 시스템: 사용자가 등록한 판매 가격이 비정상적인 값이 아닌지 관리자가 검토

프로젝트 구조

프로젝트에 필요한 API들과 모듈을 설계해보면 다음과 같은 구조가 도출된다.
크게 UsersModuleReportsModule로 나눌 수 있다.

프로젝트 생성

1. NestJS 프로젝트 생성

터미널에서 다음 명령어를 실행하여 새 NestJS 프로젝트를 생성한다.
nest new mycv
Shell
복사
패키지 관리자로 npm을 선택한 후, 설치가 완료될 때까지 기다린다.

2. 프로젝트 디렉터리로 이동 및 정리

cd mycv
PowerShell
복사
src 디렉터리 내부의 모든 파일을 삭제하고, main.ts 파일만 남겨둔다.

3. 모듈 및 서비스 생성

이제 프로젝트의 핵심 구조를 만들기 위해 모듈, 컨트롤러, 서비스를 생성한다.
nest g module users nest g module reports nest g service users nest g service reports nest g controller users nest g controller reports
PowerShell
복사

TypeORM을 이용해 데이터 보관하기

NestJS 애플리케이션에서 데이터베이스를 사용할 때 일반적인 옵션으로 TypeORMMongoose 두 가지가 있다.
처음에 우리는 가장 다루기 쉬운 SQLite를 이용해서 세팅을 한 다음, 프로젝트가 진행이 되면서 더욱 강력한 솔루션을 제공하는 Postgres로 마이그레이션 하는 작업을 거칠 예정이다.

1. TypeORM이란?

TypeORM은 다양한 관계형 데이터베이스와 원활하게 작동하는 ORM(Object Relational Mapper)이다.
MySQL, PostgreSQL, SQLite, MongoDB 등 여러 데이터베이스를 지원하며, NestJS와의 궁합도 뛰어나다.
앱을 생성할 때 자동으로 AppModule이라는 모듈 하나가 생성되는데 그 다음 Users와 Reports라는 모듈 2개를 더 생성했다. 이제 TypeORM 통합을 시작하게되면 이 3개 모듈 사이에 몇 가지 사항을 더 넣을 것이다.
TypeORm을 도입하면 가장 먼저 데이터베이스 연결을 설정해야 한다. AppModule 내부에서 SQLite 데이터베이스와의 연결을 생성한다. 한 번 설정된 연결은 Users 모듈과 Reports 모듈에서도 자동으로 공유된다. 따라서 한 번의 데이터베이스 연결 설정만으로 전체 프로젝트가 해당 연결을 활용할 수 있다.
그리고 각 모듈 내부에는 데이터 모델을 정의하는 엔터티(Entity) 파일을 추가해야 한다.
엔터티는 일반적으로 MVC 패턴의 모델(Model)과 유사하며, 데이터베이스에 저장할 테이블 구조를 정의하는 역할을 한다.

2. TypeORM 설치하기

터미널을 열고 프로젝트에서 다음 명령어를 실행한다.
npm install @nestjs/typeorm typeorm sqlite3
PowerShell
복사

3. TypeORM 설정하기

NestJS에서 TypeORM을 설정하려면 TypeOrmModule을 애플리케이션의 모듈에 등록해야 한다.
app.module.ts
import { Module } from '@nestjs/common'; import { UsersModule } from './users/users.module'; import { ReportsModule } from './reports/reports.module'; import { TypeOrmModule } from '@nestjs/typeorm'; @Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', entities: [], synchronize: true, }), UsersModule, ReportsModule, ], }) export class AppModule {}
TypeScript
복사
이제 애플리케이션을 실행하면 SQLite 데이터베이스가 자동으로 생성된다.

4. UserEntity 설정하기

NestJS에서 TypeORM을 사용하면 엔터티 개념을 기반으로 데이터 모델을 정의하고, 이를 데이터베이스와 연결하여 활용할 수 있다.
이 과정은 크게 엔터티 파일 생성 → 부모 모듈과 연결 → 최상위 모듈과 연결의 세 단계로 진행된다.

1단계: 엔터티 파일 생성하기

먼저, user 디렉터리 내부에 user.entity.ts 파일을 생성한다.
엔터티(Entity)는 TypeORM에서 데이터베이스 테이블을 나타내는 개념이며, 이를 정의하려면 TypeORM의 여러 데코레이터를 사용해야 한다.
TypeORM 데코레이터 임포트
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';
TypeScript
복사
@Entity() → 이 클래스가 데이터베이스 테이블과 연결된 엔터티임을 명시
@PrimaryGeneratedColumn() → 기본 키(primary key)로 자동 증가하는 ID 필드 설정
@Column() → 데이터베이스에 저장할 필드(컬럼) 정의
이어서 파일 안에 User 클래스를 생성하고 엑스포트한다.
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { }
TypeScript
복사
Nest 안에 생성하는 클래스에는 전부 해당 클래스와 연결된 대상의 이름을 붙이고 그 뒤에는 클래스 타입을 넣는다고 했다. 이 명명 규칙을 따르면 이 클래스에는 UserEntity 같은 이름이 붙어야 되는게 맞다.
하지만 업계 관례에 따르면 엔터티 이름은 클래스 타입을 빼기 때문에, 우리는 UserEntity 대신 User를 사용할 것이다.
이어서 이 클래스 안에다 사용자가 갖추어야 할 속성을 전부 열거한다.
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() password: string; }
TypeScript
복사

2단계: 엔터티를 부모 모듈에 연결하기

엔터티를 생성한 후, 이를 UsersModule에 연결해야 한다.
모듈 내부에서 엔터티를 등록하면 NestJS가 자동으로 해당 엔터티의 리포지토리를 생성하여 서비스에서 사용할 수 있도록 한다.
users.module.ts 수정
import { Module } from '@nestjs/common'; import { UsersController } from './users.controller'; import { UsersService } from './users.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './users.entity'; @Module({ imports: [TypeOrmModule.forFeature([User])], controllers: [UsersController], providers: [UsersService], }) export class UsersModule {}
TypeScript
복사
TypeOrmModule.forFeature([User]) → 이 모듈에서 User 엔터티를 사용하도록 등록 → 자동으로 UserRepository가 생성됨

3단계: 엔터티를 최상위 모듈에 연결하기

이제 AppModule에서 프로젝트 전체에서 User 엔터티를 사용할 수 있도록 등록해야 한다.
app.module.ts 수정
import { Module } from '@nestjs/common'; import { UsersModule } from './users/users.module'; import { ReportsModule } from './reports/reports.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './users/users.entity'; @Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', entities: [User], synchronize: true, }), UsersModule, ReportsModule, ], }) export class AppModule {}
TypeScript
복사
entities: [User]→ 데이터베이스에서 사용할 엔터티를 등록

5. ReportEntity 설정하기

ReportEntityUserEntity와 같은 방법으로 설정한다.

1단계: 엔터티 파일 생성하기

Report 디렉터리 내부에 report.entity.ts 파일을 생성한다.
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class Report { @PrimaryGeneratedColumn() id: number; @Column() price: number; }
TypeScript
복사

2단계: 엔터티를 부모 모듈에 연결하기

reports.module.ts 수정
import { Module } from '@nestjs/common'; import { ReportsController } from './reports.controller'; import { ReportsService } from './reports.service'; import { TypeOrmModule } from '@nestjs/typeorm'; import { Report } from './report.entity'; @Module({ imports: [TypeOrmModule.forFeature([Report])], controllers: [ReportsController], providers: [ReportsService], }) export class ReportsModule {}
TypeScript
복사

3단계: 엔터티를 최상위 모듈에 연결하기

app.module.ts 수정
import { Module } from '@nestjs/common'; import { UsersModule } from './users/users.module'; import { ReportsModule } from './reports/reports.module'; import { TypeOrmModule } from '@nestjs/typeorm'; import { User } from './users/users.entity'; import { Report } from './reports/report.entity'; @Module({ imports: [ TypeOrmModule.forRoot({ type: 'sqlite', database: 'db.sqlite', entities: [User, Report], synchronize: true, }), UsersModule, ReportsModule, ], }) export class AppModule {}
TypeScript
복사

6. 데이터베이스 확인

이제 변경된 내용을 저장하고, 프로젝트 폴더를 살펴보면 db.sqlite 파일이 생성된 것을 볼 수 있다.
VS Code에서 SQLite 익스텐션을 설치한 후, VS Code 상단 메뉴바 보기 → 명령 팔레트에서 SQLite Open Database를 검색한 뒤 db.sqlite를 선택해준다.
dq.splite 파일을 열어보면 VS Code 좌측 하단 메뉴바에 reports 테이블과 users 테이블이 생성된 것을 확인할 수 있다.

TypeORM 데코레이터 이해하기

TypeORM을 설정할 때 app.module.ts 파일에서 TypeOrmModule.forRoot()를 사용하여 데이터베이스 연결을 구성한다. 이때 중요한 옵션 중 하나가 synchronize: true이다. 이 옵션의 역할은 엔터티 변경 사항을 데이터베이스 테이블에 자동으로 반영하도록 한다.
SQL 기반 데이터베이스는 엄격한 구조를 가지며, 테이블의 스키마가 미리 정의되어야 한다. 예를 들어, users 테이블이 존재하고 다음과 같은 구조를 가진다고 가정해보자.
이때 테이블 구조를 변경하려면 보통 마이그레이션(Migration)을 수행해야 한다. 하지만 TypeORM에서는 synchronize: true를 설정하면 엔터티 변경 사항이 자동으로 테이블에 반영된다.
TypeORM은 데코레이터를 활용하여 엔터티(Entity)를 정의하고 데이터베이스 테이블과 매핑한다.
각 데코레이터의 기능을 자세히 살펴보자.

1. @Entity()

@Entity() 데코레이터는 해당 클래스가 데이터베이스의 테이블과 매핑되는 엔터티임을 나타낸다.
이 데코레이터를 붙이면 TypeORM이 애플리케이션 시작 시 해당 클래스를 기반으로 테이블을 생성한다.
import { Entity } from 'typeorm'; @Entity() export class User { ... }
TypeScript
복사
위 코드에서 User 클래스는 user라는 테이블로 변환된다.
테이블 이름을 직접 지정하고 싶다면 @Entity('custom_table_name') 형태로 사용할 수도 있다.

2. @PrimaryGeneratedColumn()

@PrimaryGeneratedColumn() 데코레이터는 테이블의 기본 키(Primary Key)를 자동 생성하는 역할을 한다. 보통 id 필드에 사용되며, 자동 증가하는 정수(Auto Increment)로 설정된다.
import { Entity, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; }
TypeScript
복사
만약 uuid를 기본 키로 사용하고 싶다면 다음과 같이 변경할 수 있다.
@PrimaryGeneratedColumn('uuid') id: string;
TypeScript
복사

3. @Column()

@Column() 데코레이터는 엔터티의 속성이 데이터베이스 테이블의 컬럼임을 나타낸다.
이 데코레이터를 통해 데이터 타입, 기본값, 제약 조건 등을 설정할 수 있다.
import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm'; @Entity() export class User { @PrimaryGeneratedColumn() id: number; @Column() email: string; @Column() password: string; }
TypeScript
복사
위 코드에서는 email과 password 필드가 문자열(VARCHAR) 타입의 컬럼으로 생성된다.
추가적인 옵션을 설정할 수도 있다.
@Column({ unique: true }) email: string; // 이메일은 유니크(중복 불가) 속성을 가짐 @Column({ length: 100 }) password: string; // 최대 길이 100자로 제한
TypeScript
복사

TypeORM의 리포지토리 패턴 이해하기

TypeORM을 사용하면 엔터티(Entity)와 리포지토리(Repository)를 활용하여 데이터베이스와 상호작용할 수 있다.
특히 NestJS와 함께 사용할 경우, 의존성 주입을 통해 서비스 내부에서 리포지토리를 쉽게 사용할 수 있다.

1. 엔터티(Entity) vs 리포지토리(Repository)의 차이

TypeORM에서는 엔터티(Entity)가 데이터베이스의 테이블과 직접 매핑되며, 이를 통해 테이블 구조를 정의한다.
반면, 리포지토리(Repository)는 엔터티에 대한 데이터 조회, 저장, 업데이트, 삭제 등의 작업을 수행하는 역할을 한다.

2. TypeORM의 주요 리포지토리 메서드

리포지토리는 특정 엔터티와 연결되어 데이터베이스 작업을 수행하는 클래스이다.
리포지토리를 사용하면 다양한 메서드를 활용하여 데이터베이스 조작이 가능하다.

주의할 점

TypeORM의 리포지토리 메서드를 사용할 때 주의할 점은 의미가 모호한 것들이 있어 그 차이를 명확히 알아야 한다.
save() → 데이터를 추가하거나 업데이트 (ID가 있으면 업데이트, 없으면 삽입)
insert() → 새로운 데이터만 삽입 (ID가 존재하면 에러 발생)
update() → 기존 데이터 수정 (ID가 없으면 적용되지 않음)
// save()는 존재하면 업데이트, 없으면 삽입 await userRepository.save({ id: 1, email: 'test@example.com', password: '1234' }); // insert()는 새로운 데이터만 삽입, 기존 ID는 변경 불가 await userRepository.insert({ email: 'test@example.com', password: '1234' }); // update()는 존재하면 특정 필드만 변경, 없으면 적용되지 않음 await userRepository.update({ id: 1 }, { password: "5678" });
TypeScript
복사
remove(Entity) → 엔터티 인스턴스를 삭제 (먼저 데이터를 조회한 후 삭제)
delete(id) → 조건에 맞는 데이터를 즉시 삭제
const user = await userRepository.findOne({ where: { id: 1 } }); await userRepository.remove(user); // remove()는 엔터티 인스턴스를 필요로 함 await userRepository.delete(1); // delete()는 ID만 전달하면 즉시 삭제됨
TypeScript
복사

중간 점검

본격적으로 코드를 작성하기에 앞서 앞으로의 어떤 식으로 설계할 것인지 잠깐 체크하고 가자.
컨트롤러의 각 메서드는 서비스 계층의 메서드를 호출한다.
예를 들어, /auth/signup의 경우 createUser 컨트롤러 메서드 → 서비스의 create 메서드 → 리포지토리에서 사용자를 생성하는 방식이다.
서비스 계층을 활용함으로 비즈니스 로직과 컨트롤러를 분리할건데 코드 중복은 발생할 수 있으나, 구조적으로 깔끔하게 유지할 수 있다는 장점이 있다.
그리고 비밀번호를 평문으로 저장하지 않고, 이후 암호화 등의 보안 처리를 추가할 예정이다.
그리고 기존에는 회원가입(signup)로그인(login) 기능을 중심으로 구현할 예정이었으나, TypeORM을 더 잘 이해하기 위해 추가적인 사용자 관리 기능을 연습해본다.
특정 ID로 사용자를 검색하는 경로
특정 이메일 주소로 사용자를 검색하는 경로
사용자 정보 업데이트
사용자 삭제

사용자 회원가입 API 구현

이제 auth/signup 엔드포인트를 통해 이메일과 비밀번호를 포함한 POST 요청을 처리하고, 해당 정보를 검증한 후 데이터베이스에 저장하는 흐름을 살펴보자.

1. 컨트롤러 설정

우선 users.controller.ts 파일을 수정하여 auth/signup 경로를 처리하는 새로운 메서드를 추가한다.
import { Body, Controller, Post } from '@nestjs/common'; import { CreateUserDto } from './dtos/create-user.dto'; @Controller('auth') export class UsersController { @Post('/signup') createUser(@Body() body: CreateUserDto) { console.log(body); } }
TypeScript
복사

2. DTO 파일 생성

NestJS에서 데이터 검증을 수행하려면 class-validatorclass-transformer 패키지가 필요하다.
따라서 터미널에서 다음 명령어를 실행하여 패키지를 설치한다.
npm install class-validator class-transformer
Plain Text
복사
들어오는 요청의 유효성을 검증하기 위해 CreateUserDto 클래스를 작성한다.
import { IsEmail, IsString } from 'class-validator'; export class CreateUserDto { @IsEmail() email: string; @IsString() password: string; }
TypeScript
복사

3. 전역 검증 파이프 설정

NestJS 애플리케이션 전체에 DTO 검증을 활성화하기 위해 main.ts 파일을 수정한다.
import { NestFactory } from '@nestjs/core'; import { ValidationPipe } from '@nestjs/common'; import { AppModule } from './app.moudle'; async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, }), ); await app.listen(3000); } bootstrap();
TypeScript
복사
ValidationPipe의 옵션에 whitelisttrue로 설정하면 요청 본문을 DTO에서 정의한 속성만 허용하도록 설정할 수 있다.

4. 회원가입 엔드포인트 테스트

이제 auth/signup 엔드포인트를 테스트하여 검증이 올바르게 작동하는지 확인한다.

4.1. 요청 예시(정상 요청)

{ "email": "test@test.com", "password": "test" }
JSON
복사
console.log
{ email: 'test@test.com', password: 'test' }
JSON
복사

4.2. 요청 예시 (유효하지 않은 이메일)

{ "email": "test", "password": "test" }
JSON
복사
응답 예시
{ "statusCode": 400, "message": [ "email must be an email" ], "error": "Bad Request" }
JSON
복사

4.3. 요청 예시 (필드 누락)

{ "email": "test@test.com" }
JSON
복사
응답 예시
{ "statusCode": 400, "message": [ "password must be a string" ], "error": "Bad Request" }
JSON
복사

4.4. 요청 예시 (추가 필드 포함)

whitelist: true 설정으로 인해 role 필드는 자동으로 제거된다.
{ "email": "test@test.com", "password": "test", "role": "admin" }
JSON
복사
console.log
{ email: 'test@test.com', password: 'test' }
JSON
복사