들어가기전에…

현재 근무하는 회사에서 JWT 인증 관련된 문서를 본적도 있고, 쿠키에 인증정보를 담아 제공한다는 대략적인 내용등을 접해본 기억이 있다. 게다가 이번에는 SQLite 관계형데이터베이스도 다시한번 다뤄볼 수 있어 기대가 된다

1. 실습용 프로젝트 설정하기

실습에 사용할 프로젝트와 데이터베이스를 다음과 같은 절차로 만들고 설정한다

  • nest-cli로 프로젝트 생성
  • user 모듈 생성
  • SQLite 데이터베이스 설정

순서 1. nest-cli로 프로젝트 생성

이전에 개발했던 블로그 API는 기본적으로 설정되어 있는 모듈인 AppMoudle만 사용했지만, 더 복잡한 구조의 프로그램을 만들게 되므로 AuthModule과 UserModule을 추가한다. 각 모듈의 정의는 아래와 같다

  • AppModule: 전체 애플리케이션 관련 설정
  • AuthModule: 인증관련 기능
  • UserModule: 유저 데이터를 다루는 기능

image-20230828112113563

<참조: p.372(회원 가입과 인증 프로젝트 모듈 구성) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>

1번. AppModule 하위에 AuthModule과 UserModule을 둔다

2번. AuthModule은 AuthController, AuthService 클래스로 구성된다. AuthController에는 인증에 필요한 핸들러 메서드를 설정한다. AuthService에는 회원 가입과 회원 유효성을 검증하는 메서드를 추가한다. AuthService의 회원 가입 메서드에서 UserService를 주입해 사용한다. UserService에는 회원정보 추가, 수정, 삭제 등의 메서드가 있다


순서 2. User 모듈 생성하기

먼저 사이트에 방문한 유저가 회원 가입을 하도록 User 모듈을 만들어 회원 가입 서비스를 제공하자. 모듈(user.module.ts)을 만들고, 컨트롤러(user.controller.ts), 서비스(user.service.ts)를 차례대로 만든다

cd nest-auth-test

npx @nestjs/cli g module user // nest g module user 에러 발생 시, 사용

npx @nestjs/cli g controller user --no-spec

npx @nestjs/cli g service user --no-spec

image-20230828113233129

유저 정보를 데이터베이스에 저장하려면 리포지토리가 필요한데, 리포지토리는 생성하지 않고 TypeOrm 라이브러리에서 지원하는 Repository 클래스를 사용한다. 데이터베이스로 SQLite를 사용한다.

데이터베이스에 정보를 입력하고 가져오려면 SQL을 문법을 사용해야 하는데, 이 책으로 다 다루기에는 너무 방대하다고 하다. 또한 현업에서 직접 SQL 문을 작성해 데이터베이스를 사용하기보다는 객체 관계형 매핑 기술, 즉 ORM(Object Relational Mapping)을 지원하는 라이브러리를 사용해 객체를 다루 듯이 정보를 다루는 경우가 많다.

개발자 입장에서는 SQL 문법을 사용하지 않고 데이터베이스와 통신할 수 있어 편리하기 때문에 아주 복잡한 SQL문을 다루지 않을 때는 ORM 라이브러리를 사용한다. 이 책에서는 ORM 라이브러리로 TypeORM을 사용한다


순서 3. SQLite 데이터베이스 설정하기

typeorm을 nest에서 편하게 사용하기 위해, @nestjs/typeorm 을 설치한다

npm install sqlite3 typeorm @nestjs/typeorm

설치를 완료했다면, app.module.ts에 아래와 같이 설정한다

// app.module.ts

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

@Module({
  imports: [
    TypeOrmModule.forRoot({ // sqlite 설정 메서드
      type: 'sqlite', // 1. 데이터베이스의 타입
      database: 'nest-auth-test.sqlite', // 2. 데이터베이스의 파일명
      entities: [], // 3. 엔티티 리스트
      synchronize: true, // 4. 데이터베이스에 스키마를 동기화
      logging: true // 5. SQL 실행 로그 확인
    })
    , UserModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }

TypeOrmModule.forRoot() 메서드는 매우 많은 속성을 가지고 있고, type, database, entities 설정하면된다

1번. type은 데이터베이스의 타입

2번. database는 데이터베이스 파일명, SQLite는 확장자로 sqlite를 사용

[데이터베이스별 타입 및 확장자]

이름 타입
SQLite ‘sqlite’
MySQL ‘mysql’
포스트그레SQL ‘postgres’
CockroachDB ‘cockroachdb’
오라클 ‘oracle’
마이크로소프트 SQL Server ‘sqlserver’
SAP 하나 ‘sap’
sql.js ‘sqljs’

3번. entities에는 엔티티로 만드는 객체를 넣어준다. 엔티티는 아직 만들지 않아 비워두었다

‘엔티티(entity)’ 란? 데이터베이스에서 엔티티는 한 건의 자료를 구성하는 데이터를 의미한다. ORM 프레임워크에서는 테이블과 매핑하는 클래스를 엔티티라고 하며 TypeORM의 경우 @Entity() 데코레이터를 붙인 클래스를 의미한다

4번. synchronize를 true로 하면 서버 기동 시 서버가 엔티티 객체를 읽어서 데이터베이스 스키마를 만들거나 변경해준다. synchronize 옵션은 꼭 개발용으로만 사용해야한다. 왜나하면 프로덕션 서버에서 사용하면 서버 기동 시 의도치 않게 데이터베이스 스키마를 변경할 수도 있기 때문이다.

5번. logging: true로 설정 시, SQL 실행 로그를 확인할 수 있다. 개발 시에 유용하니 true로 해준다.


2. 유저 모듈의 엔티티, 서비스, 컨트롤러 생성하기

유저 모듈의 컨트롤러, 서비스, 엔티티 작업을 진행한다. 순서는 ‘엔티티 → 서비스 → 컨트롤러’로 만든다

순서 1. 유저 엔티티 만들기

유저 엔티티는 데이터베이스 테이블과 1:1로 매칭되는 객체이다. 작성할 유저 엔티티는 id, email, username, password, createdDt 속성을 가지고 있다.

// src > user > user.entity.ts

// 1. 데코레이터 임포트
import { Column, Entity, PrimaryGeneratedColumn } from 'typeorm';

@Entity() // 2. 엔티티 객체임을 알려주기 위한 데코레이터
export class User {
    @PrimaryGeneratedColumn()
    id?: number; // 3. id는 pk이며 자동 증가하는 값

    @Column({ unique: true })
    email: string; // 4. email은 유니크한 값

    @Column()
    password: string;

    @Column()
    username: string;

    @Column({ default: true }) // 5. 기본값을 넣어줌
    createdDt: Date = new Date();
}

1번. 엔티티 객체를 만들기 위한 데코레이터들을 임포트

2번. typeorm에서 가져와서 사용

3번. @PrimaryGeneratedColumn이 붙은 필드는 기본키이면서 자동증가하는 컬럼이 된다. id 뒤에 있는 ?는 객체 생성시 필수값이 아닌 선택적인 값임을 표시

4번. @Column이 붙으면 데이터베이스의 컬럼으로 인식. unique:true를 붙여주면 중복 데이터가 존재하는 경우 저장되지 않고 에러가 남

5번. 생성일 데이터는 기본값을 넣도록 default:true 설정을 추가


순서 2. 유저 서비스 만들기

// src > user > user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; // 1. 리포지토리 주입 데코레이터
import { User } from './user.entity';
import { Repository } from 'typeorm'; // 2. 리포지토리 임포트

@Injectable()
export class UserService {
    constructor(
        // 3. 리포지토리 주입
        @InjectRepository(User) private userRepository: Repository<User>,
    ) { }
}

1번. 리포지토리 의존성 주입 데코레이터

2번. typeorm의 리포지토리. 저장, 읽기 같은 기본적인 메서드들을 제공한다. 사용 시에는 Repository와 같이 엔티티 객체를 타입으로 추가해야 한다. < > 안에는 엔티티 객체 타입을 넣어준다

3번. 리포지토리를 주입하는 코드이다. @InjectRepository(User)로 User 타입의 리포지토리를 주입한다고 알려준다. 다음으로는 private userRepository로 변수를 선언한다. 해당 변수의 타입은 Repository타입이다.

메서드 설명
find SQL에서 select와 같은 역할을 하며 conditions에 쿼리 조건을 넣어준다.

find( conditions?: FindConditions ): Promise<Entity[ ]>

-반환값: Promise<Entity[ ]>
-사용 예시: find( { email: dkgkejdrb@gmail.com } )
findOne 값을 하나만 찾을 때 사용한다.

findOne( id?: string | number | Date | ObjectID, options?:
FindOneOptions )

findOne(options?: FindOneOptions)

findOne(conditions?: FindConditions, options?:
FindOneOptions)
findAndCount find로 쿼리해오는 객체와 더불어 count가 필요한 경우 사용한다.

findAndCount(options?: FindManyOptions)

findAndCount(conditions?: FindConditions)

-반환값: Promise<[Entity[ ], number]>
-사용 예시: findAndCount( { } )
create 새로운 엔티티 인스턴스를 만들 때 사용한다. user가 UserEntity 타입이라면 다음과 같이 사용할 수 있다.

user.create()
update 엔티티의 일부를 업데이트할 때 사용한다. 조건과 변경해야 하는 엔티티값에 대해 업데이트 쿼리를 실행한다. 엔티티데이터가 데이터베이스에 존재하는지는 확인하지 않는다.

update(조건, Partical, 옵션)

-결괏값: Promise
-사용 예시: update( { email: dkgkejdrb@gmail.com }, { username: 'test2' } )
save 엔티티를 데이터베이스에 저장한다. 엔티티가 없으면 insert를 하고 있으면 update를 한다.

save(entities: T [ ])

-반환값: Promise<T[ ]>
-사용 예시: save(user) 또는 save(users)
delete 엔티티가 데이터베이스에 있는지 체크하지 않고 조건에 해당하는 delete 쿼리를 실행한다.

delete(조건)

-반환값: Promise
-사용 예시: delete({ 'email': 'dkgkejdrb@gmail.com' })
remove 받은 엔티티를 데이터베이스에서 삭제한다.

remove(entity: Entity)

remove(entity: Entity[ ])

- 반환값: Promise<Entity[ ]>
-사용 예시: remove(user)

<참조: p.379(회원 가입과 인증 프로젝트 모듈 구성) Node.js 백엔드 개발자 되기, 골든래빗, 박승규 지음>


순서 3. 컨트롤러 만들기

유저 컨트롤러는 유저가 요청을 보냈을 때 실행되는 핸들러 메서드를 정의한다. 4가지 API(유저 추가, 로그인에 사용할 1명의 유저 찾기, 정보 업데이트, 삭제)에 대한 핸들러 메서드를 만든다.

image-20230828133614904

// src > user > user.controller.ts

import { Body, Controller, Get, Post, Param, Put, Delete } from '@nestjs/common';
import { User } from './user.entity';
import { UserService } from './user.service';

@Controller('user') // 1. 컨트롤러 설정 데코레이터
export class UserController {
    constructor(private userService: UserService) { } // 2. 유저 서비스 주입

    @Post('/create')
    createUser(@Body() user: User) { // 3. 유저 생성
        return this.userService.createUser(user);
    }

    @Get('/getUser/:email')
    async getUser(@Param('email') email: string) { // 4. 한 명의 유저 찾기
        const user = await this.userService.getUser(email);
        console.log(user);
        return user;
    }

    @Put('/update/:email')
    // 5. 유저 정보 업데이트
    updateUser(@Param('email') email: string, @Body() user: User) {
        console.log(user);
        return this.userService.updateUser(email, user);
    }

    @Delete('/delete/:email')
    deleteUser(@Param('email') email: string) { // 6. 유저 삭제
        return this.userService.deleteUser(email);
    }
}

1번. (설명 생략)

2번. userService를 주입받지만, 아직 관련 메서드를 정의하지는 않았음

3번. Body에 있는 내용을 User 객체에 담는다. 타입 유효성은 파이프를 사용해 검증

4번. userService의 getUser() 메서드에 email을 넘겨 찾음

5번. updateUser() 메서드는 유저 정보를 업데이트할 때 사용한다. 키로 email과 내용을 채워 넣는데 User 객체가 둘 다 필요

6번. deleteUser() 메서드는 email을 받아서 유저 정보를 삭제


순서 4. 서비스 만들기

순서 4-1. 서비스는 컨트롤러와 리포지토리를 이어주는 역할을 하는 서비스(user.service.ts)를 만든다

// src > user > user.service.ts

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from './user.entity';
import { Repository } from 'typeorm';

@Injectable() // 1. 의존성 주입을 위한 데코레이터
export class UserService {
    constructor(
        @InjectRepository(User) private userRepository: Repository<User>,
    ) { }

    // 2. 유저 생성
    createUser(user): Promise<User> {
        return this.userRepository.save(user);
    }

    // 3. 한 명의 유저 정보 찾기
    async getUser(email: string) {
        const result = await this.userRepository.findOne({
            where: { email },
        });
        return result;
    }

    // 4. 유저 정보 업데이트
    async updateUser(email, _user) {
        const user: User = await this.getUser(email);
        console.log(_user);
        user.username = _user.username;
        user.password = _user.password;
        console.log(user);
        this.userRepository.save(user);
    }

    // 5. 유저 정보 삭제
    deleteUser(email: any) {
        return this.userRepository.delete({ email });
    }
}

1번. Injectable() 데코레이터가 있으면 ‘프로바이더’가 된다. 모듈에 설정하여 다른 객체에 주입할 수 있게 된다

2번. createUser() 메서드는 유저를 생성하는 역할을 하며, Promise 타입을 반환한다

3번. getUser()는 유저 한명을 찾는데 사용한다

4번. updateUser() 메서드는 유저 정보를 업데이트하는 역할을 한다. email은 식별자이고, username과 password만 변경한다.

5번. email은 식별자이고, 데이터를 찾아 삭제한다


순서 4-2. 유저 모듈(user.module.ts)에 리포지토리를 등록한다.

// src > user > user.module.ts

import { Module } from '@nestjs/common';
import { UserController } from './user.controller';
import { UserService } from './user.service';
import { TypeOrmModule } from '@nestjs/typeorm'; // 추가 1
import { User } from './user.entity'; // 추가 2

@Module({
  imports: [TypeOrmModule.forFeature([User])], // 리포지토리(User) 등록
  controllers: [UserController],
  providers: [UserService]
})
export class UserModule { }

순서 4-3. 엔티티(user.entity.ts)가 등록이 되어 있어야만 typeorm에서 해당 엔티티에 대한 메타 데이터를 읽을 수 있다. app.module.ts의 TypeOrmModule 설정에 User 엔티티를 추가한다

// src > app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { UserModule } from './user/user.module';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from 'src/user/user.entity'; // 추가

@Module({
  imports: [
    TypeOrmModule.forRoot({
      type: 'sqlite',
      database: 'nest-auth-test.sqlite',
      entities: [User], // 엔티티 등록
      synchronize: true,
      logging: true
    })
    , UserModule],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule { }


순서 5. 테스트하기

Postman으로 생성, 유저 정보 읽기, 업데이트, 삭제가 모두 잘 동작하는지 확인해보자

image-20230828145426200


image-20230828145629404


image-20230828145943354

image-20230828150009351


image-20230828154644989

image-20230828154721560


3. 파이프로 유효성 검증하기

앞에서 유저 모듈을 만들고 CRUD 테스트까지 진행했다. 이번에는 유효성 검사를 다루어보려 한다.

실제 서비스에서 사용자 입력값에 대한 유효성 검증은 필수이다. 익스프레스에서는 컨트롤러 역할을 하는 곳 또는 별도의 라이브러리를 사용해 검증을 하는 반면, NestJS에서는 파이프를 사용해서 유효성 검증을 한다

여기서는 ValidationPipe를 사용하고, 이를 위해 class-validator와 class-transformer를 설치하여 사용한다.

class-transformer는 JSON 정보를 클래스 객체로 변경한다. 받은 요청(payload)을 변환한 클래스가 컨트롤러의 핸들러 메서드의 매개변수에 선언되어 있는 클래스와 같다면 유효성 검증을 한다.

class-validator는 데코레이터를 사용해 간편하게 유효성 검증을 하는 라이브러리이다.

순서 1. 전역 ValidationPipe 설정하기

유효성을 검증하려면 ValidationPipe를 main.ts에 설정해야 한다.

순서 1-1. ValidationPipe 의존성 설치

npm install class-validator class-transformer

순서 1-2. ValidationPipe 설정

// src > main.ts

import { ValidationPipe } from '@nestjs/common'; // validationPipe 임포트
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe()); // 전역 파이프에 validationPipe 객체 추가
  await app.listen(3000);
}
bootstrap();

순서 2. UserDto 만들기

순서 2-1. 유저 디렉터리에 UserDto 객체를 만든다. 생성 시와 수정 시 검사 항목이 다르기 때문에 CreateUSerDto와 UpdateUserDto를 따로 만든다.

// 1. IsEmail, IsString 임포트
import { IsEmail, IsString } from 'class-validator';

// 2. email, password, username 필드를 만들고 데코레이터 붙이기
export class CreateUserDto {
    @IsEmail()
    email: string;

    @IsString()
    password: string;

    @IsString()
    username: string;
}

// 3. 업데이트의 유효성 검증 시 사용할 DTO
export class UpdateUSerDto {
    @IsString()
    username: string;

    @IsString()
    password: string;
}

UserDto를 만드는 것은 데이터 필드만 있는 클래스를 만드는 것과 비슷하다. 유효성 검증이 가능하도록 class-validator를 임포트해주고

1번. IsEmail, IsString을 임포트한다

2번. email 필드에는 @IsEmail을 그 외에는 @IsString을 붙여서 문자열이 들어갈 수 있게 했다

class-validator의 데코레이터는 아래 표와 같다

데코레이터 설명
@lsEmpty() 주어진 값이 null 또는 undefined, “ “인지 확인
@lsNotEmpty() 주어진 값이 빈 값(null, undefined, “ “,)이 아닌지 확인
@IsIn(values:any[ ]) 주어진 값이 values 배열에 있는 값인지 확인
@IsNotIn(values:any[ ]) 주어진 값이 values 배열에 없는 값인지 확인
@IsBoolean() 주어진 값이 boolean인지 확인
@IsDate() 주어진 값이 Date 타입인지 확인
@IsString() 주어진 값이 문자열인지 확인
@IsNumber(options: IsNumberOptions) 주어진 값이 number인지 확인
@IsInt() 주어진 값이 Integer인지 확인
@IsArray() 주어진 값이 배열인지 확인
@Min(min: number) 주어진 값이 min값보다 크거나 같은지 확인
@Max(max: number) 주어진 값이 max값보다 작거나 같은지 확인
@Contains(seed: string) 문자열에 매개변수로 주어진 seed값이 포함인지 확인
@NotContains(seed: string) 문자열에 매개변수로 주어진 seed값이 미포함인지 확인
@IsAlpha() 문자열이 a-z A-Z로만 되어 있는지 확인
@IsAlphanumeric() 문자열이 숫자나 알파벳인지 확인
@IsAscii() 문자열이 ASCII 문자인지 확인
@IsEmail(options?: IsEmailOptions) 문자열이 이메일인지 확인
@ISFQDN(options?: IsFQDNOptions) 문자열이 도메인 주소인지 확인(예: domain.com)
@IsIP(version?: “4”|”6”) 문자열이 IP 주소인지 확인(버전 4 또는 6)
@IsJSON() 문자열이 유효한 JSON인지 확인
@IsObject() 문자열이 객체인지 확인(null, 함수, 배열은 false 반환)
@Length(min: number, max? number) 문자열 길이의 최대 최솟값 확인
@MinLength(min: number) 문자열의 최소 길이 확인
@MaxLength(max: number) 문자열의 최대 길이 확인
@Matches(patter: RegExp, modifiers?: string) 문자열이 정규표현식에 매치되는 값인지 확인
@ArrayNotEmpty() 배열이 빈 배열이 아닌지 확인
@ArrayUnique(identifier?: (o) => any) 배열에 있는 모든 값들이 고유한 값인지 확인. 참조를 통해 비교한다

순서 2-2. user.controller.ts를 createUser, updateUser를 선언한 User 타입 부분을 각각 CreateUserDto, UpdateUserDto로 변경하여 아래와 같이 수정한다.

// src > user > user.controller.ts

... 생략 ...
import { CreateUserDto, UpdateUserDto } from './user.dto'; // 추가

@Controller('user')
export class UserController {
	... 생략 ...
    @Post('/create')
    createUser(@Body() user: CreateUserDto) { // 수정
        return this.userService.createUser(user);
    }

    // 5. 유저 정보 업데이트
    updateUser(@Param('email') email: string, @Body() user: UpdateUserDto) {
        return this.userService.updateUser(email, user);
    }

    @Delete('/delete/:email')
    deleteUser(@Param('email') email: string) { // 수정
        return this.userService.deleteUser(email);
    }
}


순서 2-3. 테스트하기

잘못된 이메일을 입력한 경우를 상정하여, 아래와 같이 테스트해보자. email 검증에서 오류가 걸리면 성공

image-20230828172616731


4. 인증 모듈 생성 및 회원 가입하기

인증은 ‘1. 정확성’과 ‘2. 시간 측면’에서 사용자의 자격증명을 확인하는 것이다

  • 정확성 측면: 사용자의 자격증명을 기존 정보를 기반으로 확인 후, 인증 토큰을 발긓바흔 것을 말한다
  • 시간 측면: 사용자에게 부여된 인증 토큰은 특정 기간 동안만 유효하다는 것을 말한다

인증을 만드는 방법은 2가지이다. ‘1. 쿠키 기반’과 ‘2. 토큰 기반’이다.

  • 쿠키: 서버에서 보내준 쿠키를 클라이언트(주로 브라우저)에 저장해 관리한다.
  • 토큰: ‘쿠키리스’방식이라고 부른다.
  쿠키 토큰
장점 -하위 도메인에서 같은 세션을 사용할 수 있음
-저장 공간을 적게 차지함
-브라우저에서 관리함
-httpOnly 설정을 하면 클라이언트에서 자바스크립트로 조작할 수 없음
-유연하며 사용이 간단함
-크로스 플랫폼 대응
-다양한 프론트 엔드 애플리케이션에서 대응
단점 -사이트 간 위조 공격(CSRF: 사용자가 자신의 의지와 무고나하게 공격자가 의도한 행위를 특정 웹사이트에 요청하게 만드는 공격)이 있을 수 있음
-서버에 저장해야 하므로 스케일링 이슈가 있음
-API 인증으로는 좋지 않음
-토큰 누출 시 권한 삭제가 어려움. 거부 목록을 따로 관리해야 하는데 그러면 무상태가 아니게 됨
-쿠키보다 공간을 많이 사용함
-JWT 내부 정보는 토큰 생성 시의 데이터이므로 최신 데이터를 반영하지 않을 수 있음
저장소 브라우저에서 관리해줌 커스텀 HTTP 헤더 사용(주로 Authorization)

그래서, 이번에 쿠키를 쓸거냐? 토큰을 쓸거냐?

이번 스터디는 교재에 따라, 먼저 쿠키를 사용해 인증을 구현한다. 토큰 방식은 OAuth를 사용한 소셜 로그인에서 스터디할 계획이다.

순서 1. 인증 모듈 만들기 및 설정하기

순서 1-1. 인증모듈을 만들기 위해, nest-auth-test 디렉터리에서 ‘1. 인증 모듈’, ‘2. service’, ‘3. controller’를 만든다

npx @nestjs/cli g module auth // nest g module user 에러 발생 시, 사용

npx @nestjs/cli g controller auth --no-spec

npx @nestjs/cli g service auth --no-spec

image-20230829084905102


순서 1-2. 인증 모듈의 구조는 아래와 같다

image-20230829085542271


순서 1-3. UserService를 AuthService에서 주입받을 수 있도록 user.module.ts에 exports 설정을 추가한다

// src > user > user.module.ts

... 생략 ...
@Module({
  imports: [TypeOrmModule.forFeature([User])],
  controllers: [UserController],
  providers: [UserService],
  exports: [UserService] // 1. UserService를 외부 모듈에서 사용하도록 설정
})
export class UserModule { }

1번. 인증 모듈의 AuthService에서는 회원 가입이나 로그인/로그아웃 처리에 UserService를 이용한다. @Injectable()이 붙어 있는 프로바이더의 경우 같은 모듈의 다른 클래스에서 주입해 사용할 수 있다. 다만 다른 모듈에서 사용하려면 @Module 데코레이터의 속성으로 exports에 프로바이더를 넣어주어야 한다


순서 1-4. AuthModule에 UserModule의 import 설정을 추가한다

import { Module } from '@nestjs/common';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { UserModule } from 'src/user/user.module'; // 추가 1

@Module({
  imports: [UserModule], // 추가 2
  controllers: [AuthController],
  providers: [AuthService]
})
export class AuthModule { }

순서 2. 회원 가입 메서드 만들기

회원 가입을 해야만 로그인을 할 수 있으니 회원 가입 메서드를 만들어보자

순서 2-1. UserService 클래스의 createUser를 사용해도 되지만, 비밀번호 같은 민감한 정보는 무조건 암호화해야 한다. 기존 코드로는 패스워드가 암호화되어서 저장되지 않으므로 암호화 코드를 추가한다. 암호화 모듈료 bcrypt를 사용한다. bycrypt를 설치한다

npm install bcrypt

npm install -D @types/bcrypt  // 타입스크립트에 타입을 제공하는 @types/bcrypt

순서 2-2. 서비스 → 컨트롤러의 순서로 코드를 작성하겠다. auth.service.ts에 코드를 작성한다.

// src > auth > auth.service.ts

import { HttpException, HttpStatus, Injectable } from '@nestjs/common';
import { UserService } from 'src/user/user.service';
import { CreateUserDto } from 'src/user/user.dto';
import * as bcrypt from 'bcrypt';

@Injectable() // 1. 프로바이더로 사용
export class AuthService {
    // 2. 생성자에서 UserService를 주입받음
    constructor(private userService: UserService) { }

    // 3. 메서드 내부에 await 구문이 있으므로 async 필요
    async register(userDto: CreateUserDto) {
        // 4. 이미 가입된 유저가 있는지 체크
        const user = await this.userService.getUser(userDto.email);
        if (user) {
            // 5. 이미 가입된 유저가 있다면 에러 발생
            throw new HttpException(
                '해당 유저가 이미 있습니다.',
                HttpStatus.BAD_REQUEST,
            );
        }

        // 6. 패스워드 암호화
        const encryptedPassword = bcrypt.hashSync(userDto.password, 10);

        // 데이터베이스에 저장. 저장 중 에러가 나면 서버 에러 발생
        try {
            const user = await this.userService.createUser({
                ...userDto,
                password: encryptedPassword,
            });
            // 7. 회원 가입 후 반환하는 값에는 password를 주지 않음
            user.password = undefined;
            return user;
        } catch (error) {
            throw new HttpException('서버 에러', 500);
        }
    }
}

1번. @Injectable() 데코레이터가 있어, 프로바이더에 등록하여 사용할 수 있음

2번. 생성자에서 userService 클래스 주입

3번. register 메서드는 내부에 await 구문이 있어, async를 붙임

4번. userService.getUser(email) 메서드를 사용해 이미 가입된 유저가 있는지 확인

5번. 이미 가입된 유저가 있다면 400 에러 발생시킴

6번. bcrypt를 사용해 패스워드를 암호화. hashSync의 첫 번째 매개변수는 암호화할 문자열이고, 뒤에 있는 10은 암호화 처리를 10번 하겠다는 의미. 숫자가 올라갈수록 해시값(암호화된 문자열)을 얻는 데 시간이 오래 걸림

7번. 회원 가입시에는 userService.createUser를 사용하므로 모든 데이터가 다 있는 user 객체를 준다. 패스워드 정보를 넘겨주는 것은 보안에 문제가 될 수 있으니 user.password 값을 undefined로 해 데이터를 넘겨주는 값에서 데이터를 삭제


순서 2-3. 다음으로 controller를 만든다.

import { Post, Body, Controller } from '@nestjs/common';
import { CreateUserDto } from 'src/user/user.dto';
import { AuthService } from './auth.service';

@Controller('auth') // 1. 컨트롤러 생성
export class AuthController {
    constructor(private authService: AuthService) { } // 2. AuthService를 주입받음

    @Post('register') // 3. register 주소로 POST로 온 요청 처리
    // 4. class-validator가 자동으로 유효성 검증
    async register(@Body() userDto: CreateUserDto) {
        return await this.authService.register(userDto);
        // 5. authService를 사용해 user 정보 저장
    }
}

1번. (설명 생략)

2번. AuthService를 주입받음

3번. **<서버 주소="">/auth/register** 이면 register 메서드를 실행한다

4번. Body가 CreateUserDto 타입의 데이터가 오면 class-validator가 유효성을 검증. authService.register가 async await을 사용하므로 Controller의 register() 메서드도 async await을 사용해야 한다. 이렇게 하지 않으면 Promise가 완료 상태가 되지 않아서 유저가 요청을 못 받게 됨.


순서 2-4. 테스트하기

처음 회원가입을 진행하고, 해당 유저를 찾아보면 비밀번호가 암호화되어있음을 확인할 수 있다

image-20230829094032954

image-20230829094055352


동일한 정보로 2번 회원가입을 진행하는 경우, 중복 유저 에러 발생

image-20230829094216356


순서 3. SQLite 익스텐션으로 테이블 확인하기

패스워드를 암호화해 저장했는데, AuthService 클래스의 register() 메서드의 반홥값에 있는 user 객체에서는 password값을 지워버리기 때문에 제대로 저장되었는지 알 수가 없다. SQLite를 사용해 데이터베이스(src/nest-auth-test.sqlite파일)에 저장했으니 SQLite 클라이언트를 사용해 테이블 내용을 확인하려고 한다.

순서 3-1. 아래 익스텐션(SQLite Viewer)을 설치한다

image-20230829101529823


순서 3-2. 루트 경로에 ‘nest-auth-test.sqlite’ 파일을 열면 아래와 같이 테이블을 확인할 수 있다

image-20230829101701099

다음으로 넘아가기 전에..

이로써, 회원가입 기능 구현이 마무리되었다. 다음 내용에서는 회원가입 기능에 이어 인증 구현을 다루어보자

참고

다음에 다루게 될 것

  • 회원 가입과 인증하기(2) - 쿠키로 10초간 유효한 인증 구현하기

댓글남기기