Creating App

onyoo·2023년 1월 14일
0

NestJs

목록 보기
5/8
post-thumbnail

App Overview

우리가 만들 어플리케이션에 대해서 간략하게 알아보자.

중고차 가격을 알려주는 API로 네가지의 핵심기능을 가질 것이다.

이메일을 이용한 유저 로그인

자동차의 모델 생산연도 등등을 바탕으로 자동차의 가치를 측정하는 기능

유저가 판매한 자동차의 정보를 제출하는 기능

보고된 판매에 대해서 관리자가 확인하는 기능

이 기능을 위해 작성해야 할 메서드들과 그 메서드들에게 필요한 데이터는 다음과 같다.

앞의 기능을 구현하기 위해서는 다음과 같은 모듈이 필요하다

UserController / UserService / UserRepository → User와 관련된 것들을 관리함

ReportsController / ReportsService / ReportsRepository → Reports와 관련된 것들을 관리함

우리는 이 세가지 뭉치들을 하나의 모듈로 관리할 것이다.

지금까지는 프로젝트 구조에 대해서 간단히 알아보았다. 다음으로는 프로젝트의 데이터를 어떻게 접근할지 어떤 기술을 사용할지 알아보자

DataBase

Nest에서 잘 작동하는 ORM은 두가지가 있다.

TypeORM 다양한 데이터베이스와 호환이 가능하다

Mongoose 몽고디비와 호환이 가능하다.

우리는 여기에서 다양한 데이터베이스와 호환이 가능한 TypeOrm을 사용할 것이고. 로컬에 파일을 저장하는 식으로 사용하는 SQLite를 이용하여 데이터베이스를 구현할 예정이다.

SQLite를 사용하면 이런 파일이 생기고 이 파일에서 데이터를 관리한다.

그러면,이제 본격적으로 데이터베이스와 연결하는 작업을 해보자.

nest create app-name 

cli 환경에 명령어를 입력해 app을 만든다. 그러면 기본적으로 작성된 app.module.ts 파일이 존재하고 있다.

거기에 다음과 같은 내용을 추가한다

import { Module } from '@nestjs/common';
**import { TypeOrmModule } from '@nestjs/typeorm';**
import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    **TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite',
      entities: [],
      synchronize: true,
    })**,
    UsersModule,
    ReportsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Bold 처리된 부분을 보면, TypeOrm 모듈을 이용하여 어떤 데이터베이스를 쓸지 명시하고 synchronize를 이용하여 데이터베이스를 연동했다. 프로젝트를 실행시켜서 오류가 나지 않았다면 성공!

이제 데이터베이스와 연결하기 위해 entity를 작성해야하지만 본격적인 코드를 작성하기 전 entity가 무엇인지 어떤 역할을 하는지에 대해서 간단하게 알아보자.

엔티티란 데이터베이스에 있는 데이터와 코드를 연결짓기 위해 존재하는 클래스다. 그렇기 때문에 entity 클래스에 우리는 데이터베이스가 가지고 있는 데이터와 연관관계를 작성해준다.

엔티티를 직접 작성해보며 엔티티가 무엇인지 어떤 기능을 하는지 직접 경험해보자.

일단 엔티티를 만드는 과정은 다음과 같다.

  1. 엔티티 파일을 만들고 클래스 안에 그 엔티티가 가질 프로퍼티를 작성한다
  2. 해당 엔티티를 가지고 있는 부모 모듈에 연결한 다음 리포지토리를 생성한다
  3. 마지막으로 엔티티를 루트 모듈에 있는 루트 커넥션에 연결한다.

첫번째, 엔티티 클래스를 작성해보자

user.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column()
  email: string;

  @Column()
  password: string;
}

첫번째로 엔티티 클래스임을 데코레이터를 붙여서 알려준다. 그 다음 각 컬럼에 들어가는 데이터를 나열하고 해당 컬럼이 가지는 특징을 데코레이터로 알려준다.

두번째, 부모 모듈에 우리가 작성한 엔티티를 알려주어야 한다.

user.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 './user.entity';**

@Module({
  **imports: [TypeOrmModule.forFeature([User])],**
  controllers: [UsersController],
  providers: [UsersService],
})
export class UsersModule {}

이런식으로 어떤 엔티티를 import 할지 모듈에 알려준다.

부모모듈인 User 모듈에 알렸다.

이제 루트 모듈에 커넥션을 생성해주면 마무리가 된다.

app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { User } from './users/user.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite',
      entities: [**User**],
      synchronize: true,
    }),
    **UsersModule**
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

볼드 처리된 부분이 새로 추가된 내용이다.

서버를 실행한뒤 에러로그 없이 잘 처리되었다면 데이터베이스 연결이 완료된것이다.

이와 비슷한 방식으로 report 모듈의 엔티티도 추가해보자.

reports.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column()
  price: number;
}

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 './reports.entity';

@Module({
  imports: [TypeOrmModule.forFeature([Report])],
  controllers: [ReportsController],
  providers: [ReportsService],
})
export class ReportsModule {}

app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
import { User } from './users/user.entity';
import { Report } from './reports/reports.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite',
      entities: [User, Report],
      synchronize: true,
    }),
    UsersModule,
    ReportsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

Type ORM 데코레이터 알아보기

지금까지 작성한 코드에 들어있는 typeorm의 데코레이터에 대해 자세히 알아볼 것이다.

첫번째로 syncronize 옵션이다. 어디서 본것같은데 어디있냐고 물으신다면 여기에 있다.

app.module.ts

import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UsersModule } from './users/users.module';
import { ReportsModule } from './reports/reports.module';
import { User } from './users/user.entity';
import { Report } from './reports/reports.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'db.sqlite',
      entities: [User, Report],
      **synchronize: true,**
    }),
    UsersModule,
    ReportsModule,
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

이 옵션에 대해서 설명하기 위해서는 일단, migration이라는 것에 대해서 알아야한다.

위의 그림 처럼 기존의 테이블에 username을 추가하고 싶다 이럴 경우 데이터베이스의 구조를 변경하는 것을 우리는 마이그레이션이라고 한다. 그러나 우리는 작업하는 도중 마이그레이션 작업을 하지 않았다. 그 이유는 바로 synchronize 옵션을 주었기 때문이다!

이 옵션이 동기화를 시켜주기 때문에 우리가 직접 마이그레이션 작업을 할 필요가 없는 것이다.

우리의 코드를 통해 동기화 작업이 어떻게 진행되는지 알아보자.

프로그램이 동작하기 시작하면 데코레이터를 읽기 시작한다. 처음에 작성된 entity 데코레이터를 읽고 user라는 테이블을 생성하고. primaryGeneratedColumn 데코레이터를 읽고 자동으로 생성되는 id 컬럼을 생성한다. 마지막으로 column 이라는 데코레이터를 읽고 나머지 두 컬럼을 생성한다.

프로그램이 실행될때 동기화라는 옵션이 켜져있으면 여기서 변경되거나 수정된 데코레이터를 다시 읽어들여 데이터베이스를 다시 업데이트 할 것이다.

사실 이러한 방식으로 작동하는 것이 매우 이례적이고, 일반적으로는 구조를 변경하는 마이그레이션 파일을 직접 작성해야한다!

그리고 개발에서만 동기화 기능을 사용해야한다. 프로덕션환경에서는 이 때문에 실수가 발생할 수 있기 때문에 매우 조심해서 사용해야한다. 그래서 엄격하게 구성해야할 경우 마이그레이션 작업을 직접 구성해주는게 맞다.

다음은 우리가 작성한 데이터베이스를 이용할 리포지토리 코드를 작성해보자!

Repository

우리가 작성할 코드의 구조는 이것과 같고

리포지토리를 이용해서 다음과 같은 함수를 실행할 수 있다 ! 자세한 내용은 이곳 참고하기

User

가장먼저 user 와 관련된 API를 만들어보려고 한다.

users.entity.ts

import { Entity, Column, PrimaryGeneratedColumn } from 'typeorm';

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

  @Column()
  email: string;

  @Column()
  password: string;
}

이 엔티티를 가지고 데이터를 조작하는 서비스 코드를 작성해보자

users.service.ts

import { Injectable } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
//사용자 저장소가 필요하다는 것을 종속성 주입 시스템에 알리는 것
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(@InjectRepository(User) private repo: Repository<User>) {}
  //리포지토리 생성
	create(email: string, password: string) {
    const user = this.repo.create({
      email,
      password,
    });
    return this.repo.save(user);
  }
	//리포지토리를 이용하여 create 후 save 
}

여기에서 우리는 InjectRepository 를 이용하여 여기에 리포지토리가 필요하다는 것을 알려줄 것이고. 해당 서비스 코드를 다른 곳에 주입한다는 표시 데코레이터 Injectable을 달아준다.

여기에서 create 와 save가 나누어서 이루어지는지 궁금할 것이다. create 없이 save에서 바로 객체를 날려버릴 수도 있지만.

두 함수가 하는 일은 명확하게 다르고, 각자의 역할이 나누어져있다.

보면, create는 데이터를 받아 사용자 엔티티의 새 인스턴스를 만든 다음 할당한다. save는 그것을 데이터베이스로 옮기는 역할을 한다.

그러면 왜 이것을 나누엇을까? 우리는 이후에 컨트롤러 파일을 작성하면서 dto를 작성할 예정이다. 만약 여기에서 둘의 기능을 분리하지 않으면, save 함수를 실행할때 검증을 해야한다. 하지만 그것은 불가능하기 때문에 create를 먼저 해서 사용자 인스턴스를 만드는 것이다. 그리고 save를 리턴할때 dto를 이용하여 유효성 검사를 하는 것이다.

여기에 대한 논의를 좀 더 추가해보자면, Hook 을 사용하는 것을 예시로 들어볼 수가 있다.

entity 코드를 다음과 같이 수정해보자.

import {
  AfterInsert,
  AfterRemove,
  AfterUpdate,
  Entity,
  Column,
  PrimaryGeneratedColumn,
} from 'typeorm';

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

  @Column()
  email: string;

  @Column()
  password: string;

  @AfterInsert()
  logInsert() {
    console.log('Inserted User with id', this.id);
    //this 는 여기에서 참조한 데이터이다.
  }
  //새사용자를 삽입할때마다 이 함수가 호출될것이다.

  @AfterUpdate()
  logUpdate() {
    console.log('Updated User with id', this.id);
  }

  @AfterRemove()
  logRemove() {
    console.log('Removed User with id', this.id);
  }
}

AfterInsert AfterUpdate AfterRemove

와 같은 Hook 을 이용하여 데이터베이스가 삽입,갱신,삭제될 때 마다 로그가 뜨도록 해주었다. 이것이 어떻게 예시가 될수 있겠냐고 말할수있지만 다음 그림응ㄹ 보면 납득이 될 것 이다.

save와 create가 정확하게 나누어져있다면, hook이 발생하는 시점이 정확하다.

그러나 오른쪽과 같이 create문만 작성한다면, create함수가 없고 entity를 직접 생성하는 것이기 때문에 hook의 시점이 정확하지 않다. 만약,우리가 위와 같이 코드를 작성한 다음 오른쪽의 경우처럼 create 없이 save를 한다면 데이터는 저장이 되겠지만 hook이 작동하지 않을 것이다.

즉, 일반개체로 save를 호출하면 후크가 실행되지 않는다는 소리이다.

이런 일반객체를 혼합하여 저장하기 시작하면, 버그를 감지하기가 매우 어려워진다. 만약에 비밀번호를 엔티티를 넣을때마다 암호화해서 넣는 hook을 사용한다고 가정해보자 오른쪽과 같이 작업을 했다면 후크가 실행이 되지않아 암호화가 되지않은 비밀번호가 저장될 것이다.

자 여기서 그럼 더 나아가 다음 사진을 보며 얘기를 해보자.

앞에서 말했던걸 생각해보자 save는 아까 엔티티 인스턴스를 호출하여 사용했다.이것들을 엔티티로 실행하면 hook 이 실행된다. 반면에 insert,update 혹은 delete 를 사용하여 레코드를 직접 삽입하거나 직접 업데이트를 하는 경우 hook은 실행되지 않는다.

즉, 차이가 없어보이지만 hook의 실행여부가 다르다는 아주 큰 차이점이 있다는 것이다!

그렇기 때문에 우리는 데이터를 삽입하려고 할 때 항상 엔티티 인스턴스를 가져와서 작업을 할 것이다.

즉, 일반개체를 저장하거나 후크를 실행하지 않는 다른 관련 메서드를 호출하는것을 지양해야한다. 후크가 없어 나중에 버그로 꼬이는 코드를 가지고 싶지 않다면!

create-user.dto.ts

import { IsEmail, IsString } from 'class-validator';

export class CreateUserDto {
  @IsEmail()
  email: string;

  @IsString()
  password: string;
}

검증을 위한 dto 클래스를 작성한다.

users.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('auth')
export class UsersController {
  constructor(private usersService: UsersService) {}
  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    this.usersService.create(body.email, body.password);
  }
}

dto를 이용하여 데이터를 검증한뒤 데이터를 저장한다.

Find User

다음으로는 유저를 찾아보려고 한다.

  • 유저 id를 기준으로 하나의 데이터만 조회하는 API
  • 유저 email 을 기준으로 해당 email 과 동일한 모든 데이터를 조회하는 API

아래에서는 typeorm 의 메서드를 사용할것이기 때문에, 메서드에 대한 자세한 설명은 링크를 참고하길 바란다.

Find Options

유저 id를 기준으로 하나의 데이터만 조회하는 API

먼저 데이터베이스에서 데이터를 조회해야하기 때문에 Repository를 사용하는 코드를 서비스에 작성할 것이다.

아래 코드들을 추가해주면 된다.

users.service.ts

findOne(id: number) {
    return this.repo.findOneBy({ id });
    //해당하는 단 하나의 레코드
  }

Repository를 이용하여 id에 해당하는 레코드를 추출합니다. 이 코드를 이용한 코드를 controller 에 작성합니다.

users.controller.ts

@Get('/:id')
async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

Get 데코레이터를 이용하여 라우팅 URL 에 id가 들어간다는 것을 알려줍니다. URL 로 들어오는 데이터를 함수의 입력값으로 받기 위해서 Param 데코레이터를 이용하여 id 값을 받습니다.

만약 user 데이터를 리포지토리로부터 가져오지 못한다면, 이를테면 해당하는 유저가 없을 경우. 해당 유저 데이터가 없다는 error를 캐치하여 예외처리를 하여줍니다.

유저 email 을 기준으로 해당 email 과 동일한 모든 데이터를 조회하는 API

users.service.ts

find(email: string) {
    return this.repo.find({ where: { email } });
    //해당하는 모든 레코드
  }

service에서 where을 이용하여 email 이 일치하는지 여부를 판단한다.

users.controller.ts

@Get()
findAllUsers(@Query('email') email: string) {
    return this.usersService.find(email);
 }

앞에서 작성했던 라우터와 동일하게 작동한다.

완성된 코드는 다음과 같다.

users.service.ts

import { Injectable, NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
//사용자 저장소가 필요하다는 것을 종속성 주입 시스템에 알리는 것
import { User } from './user.entity';

@Injectable()
export class UsersService {
  constructor(@InjectRepository(User) private repo: Repository<User>) {}
  create(email: string, password: string) {
    const user = this.repo.create({
      email,
      password,
    });

    return this.repo.save(user);
  }
  findOne(id: number) {
    return this.repo.findOneBy({ id });
    //해당하는 단 하나의 레코드
  }

  find(email: string) {
    return this.repo.find({ where: { email } });
    //해당하는 모든 레코드
  }
  
}

users.controller.ts

import {
  Body,
  Controller,
  Post,
  Param,
  Query,
  Patch,
  Get,
  Delete,
  NotFoundException,
} from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UsersService } from './users.service';

@Controller('auth')
export class UsersController {
  constructor(private usersService: UsersService) {}
  @Post('/signup')
  createUser(@Body() body: CreateUserDto) {
    this.usersService.create(body.email, body.password);
  }

  @Get('/:id')
  async findUser(@Param('id') id: string) {
    const user = await this.usersService.findOne(parseInt(id));
    if (!user) {
      throw new NotFoundException('user not found');
    }
    return user;
  }

  @Get()
  findAllUsers(@Query('email') email: string) {
    return this.usersService.find(email);
  }

}
profile
반갑습니다 ! 백엔드 개발 공부를 하고있습니다.

0개의 댓글