728x90

어떤 관계들이 존재하는지에 대해서는 생략하고, 바로 OneToMany와 ManyToOne의 관계에 대해서 알아보자.

 

여기서는 사용자와 해당 사용자가 작성하는 보고서에 대한 관계로 설명을 해보려고 한다.

 

우선 OneToMany 관계이다.

한명의 사용자가 많은 양의 보고서를 작성할거기에 One이 사용자, Many가 보고서이다.

따라서 OneToMany는 User 쪽에 작성하는 데코레이션이다.

 

    @OneToMany(() => Report, (report) => report.users)
    reports: Report[];

 

@OneToMany로 2가지의 파라미터를 넘긴다.

우선 첫번째 파라미터는 Many 쪽의 타입이다.

Many로 가져올 타입을 명시하며, () => Report 처럼 함수형으로 반환하는 이유는 곧 보겠지만

Report 쪽에도 이렇게 Users 타입을 주게 되는데, 이 때 두 Entity 간에 순환 참조 에러가 발생하게 된다.

그렇기에 함수의 리턴타입으로 명시해주는 것이다.

 

그리고 2번째 파라미터는, Many 쪽에서 해당 Entity를 가지고 있는 property다.

지금은 하나밖에 없어서 그냥 users로 명시하고 있지만

 

만약 updateUser가 추가된다면

    @OneToMany(() => Report, (report) => report.updatedUser)
    updateReports: Report[];

이렇게 updateUser와 같은 속성을 명시해주는 것이다.

 

이런 이유로 OneToMany에도 2가지의 파라미터를 더 넘기게 된다.

 

이제 Many쪽으로 넘어가보자.

    @ManyToOne(() => Users, (Users) => Users.reports)
    users: Users

Many쪽도 다음과 같은 이유로 2개의 파라미터를 작성한다.

 

그리고 이렇게 작성을 하면 Many 쪽에는

다음과 같이 user쪽의 PK를 가지는 column이 추가된다.

 

근데 여기서 다음과 같은 문제가 발생한다.

우선 지금과 같이 작성해서는

    @Get("/check")
    @UseGuards(AuthGuard)
    async checkLogin(@Authentication() authentication: Users): Promise<Users>{
        console.log(authentication.reports)
        return authentication
    }

해당 코드에서 undefined가 출력되게 된다.

OneToMany 관계에서 Many를 불러오지 못하는 것이다.

 

불러오기 위해서는 다음과 같이 eager:true를 설정해줘야 한다.

@OneToMany(() => Report, (report) => report.users, {
        eager: true
    })
    reports: Report[];

이러면 출력은 된다.

 

하지만 또 문제가 발생한다.

이러면 로그인만 하더라도 모든 report까지 조회하게 된다.

로그인을 하면 user entity를 조회하게 될텐데, 그 때마다 report를 조회한다면 큰 오버헤드 일 것이다.

 

그렇기에 필요할 때만 가져오도록 만들어야 한다.

그러기 위해서는 해당 property를 다음과 같이 수정한다.

    @OneToMany(() => Report, (report) => report.users, {
        lazy: true
    })
    reports: Promise<Report[]>;

이렇게 타입을 Promise로 설정하고, lazy를 true로 하면 해당 property를 사용하려고 할 때 다시 쿼리를 날려서 report를 조회해온다.

 

테스트로 다음과 같이 코드를 작성하고 실행하면

    @Get("/check")
    @UseGuards(AuthGuard)
    async checkLogin(@Authentication() authentication: Users): Promise<Users>{
        console.log("before query")

        console.log(await authentication.reports)
        return authentication
    }

 

로그가 출력되고 그 다음에 다시 쿼리를 날리는 것을 볼 수 있다.

 

이 외에도 다양한 방법의 관계에서의 설정들이 있을텐데, 꾸준하게 nest를 공부하면서 알아보도록 하자.

728x90

기존에는 데이터베이스와 관련된 부분을 그냥 코드로 작성했었다.

 

이런 부분을 당연히 그대로 git에 올리면 안되기 때문에 환경변수로 만들어서 주입해야 한다.

 

우선 환경변수를 .env 파일로부터 가져오기 위해 다음 의존성들을 추가해준다.

 

그리고는 .env.{환경}으로 환경변수를 저장할 파일을 만들어준다.

 

당연히 올리면 안되기에 gitignore에 추가해준다.

 

그리고 데이터베이스는 같게 하더라도 스키마는 다르게 해주었다.

DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=1204
DB_DATABASE=usedCars_dev

.env.development

 

DB_HOST=localhost
DB_PORT=3306
DB_USERNAME=root
DB_PASSWORD=1204
DB_DATABASE=usedCars_test

.env.test

 

테스트에서 다른 데이터베이스를 사용하기 위해 구별해주었다.

 

이제 app.module.ts로 가서 해당 환경변수들을 주입해줘야 한다.

@Module({
  imports: [
      ConfigModule.forRoot({
        isGlobal: true,
        envFilePath: `.env.${process.env.NODE_ENV}`
      })
   ]
})
export class AppModule {}

우선 ConfigModule.forRoot()로 환경변수를 읽어올 파일을 지정해준다.

 

여기에서 .env의 뒷 부분을 모듈 런타임의 환경변수로 지정해서 development, test를 구별해주었다.

 

그 다음에는 기존에 사용하던 TypeOrmModule을 수정해줘야 한다.

forRoot가 아니라 forRootAsync로 가져와야 한다.

forRoot는 정적이거나 process.env로 접근할 때 사용하며

forRootAsync는 동적으로 config 파일을 가져올 때 사용한다고 한다.

 

      TypeOrmModule.forRootAsync({
        inject: [ConfigService],
        useFactory: (config: ConfigService) => {
          return {
            type: "mysql",
            host: config.get<string>('DB_HOST'),
            port: config.get<number>('DB_PORT'),
            username: config.get<string>('DB_USERNAME'),
            password: config.get<string>('DB_PASSWORD'),
            database: config.get<string>('DB_DATABASE'),
            entities: [__dirname + '/**/*.entity{.ts,.js}'],
            synchronize: true
          }
        }
      }),

inject를 통해서 환경변수를 가져올 서비스를 지정해준다.

 

이제는 해당 환경에 대한 정보를 서버 시작 전에 넘겨줘야 한다.

이 쪽 부분에 해당하는 내용을 말이다.

 

현재 npm을 통해서 서버를 실행하고 있으니

package.json의 이쪽 부분을 수정해주면 된다.

 

우리는 런타임 환경을 부여하기 위해 cross-env 라이브러리를 설치했었다.

우리는 NODE_ENV를 줘야 하기에 NODE_ENV={원하는 값}을 주입해준다.

 

한 번 실행해서 원하는 데이터베이스에 값이 생기는지 확인해보자.

이렇게 설정하고 create-user.http를 실행하니 데이터베이스에 값이 원하는대로 들어가는 것을 볼 수 있었다.

 

728x90

자 이제 로그인을 하면 세션에 사용자 정보를 넣어두고, 다른 API를 요청하면 그 사용자 정보를 가져오도록 해보자.

 

우선 사용자가 로그인을 하면 세션에 사용자의 id를 넣어줘야 한다.

    @Post("/signIn")
    async signIn(@Body() loginUserDto: LoginUserDto, @Session() session: any): Promise<Users>{
        const user = await this.authService.signIn(loginUserDto)

        session.userId = user.id;

        return user;
    }

이렇게 user의 id를 먼저 넣어준다.

 

그리고 로그인을 해보면, 우선 다음과 같이 쿠키가 생긴 것을 볼 수 있다.

 

이제 이 정보를 다른 API에서 불러와야 한다.

 

일단 로그인 체크 API를 만들고 거기에서 사용자의 아이디를 콘솔로 출력해보자.

    @Get("/check")
    async checkLogin(@Session() session: any): Promise<string>{
        console.log(`userId: ${session.userId}`);
        return ""
    }

일단 사용자의 아이디가 출력되었다.

 

사용자 로그아웃도 구현해보자.

 

그냥 userId를 null로 바꿔주면 끝이다.

    @Post("/signOut")
    async signOut(@Session() session: any): Promise<void>{
        session.userId = null;
    }

로그아웃 API를 요청하고 다시 check 해보면 userId가 null로 출력되고 있었다.

 

이렇게 만들었지만, 항상 컨트롤러에서 세션을 통해 userId를 가져올 수는 없을 것이다.

 

로그인을 하지 않았다면 해당 경로의 접근을 막고, 데코레이터를 통해 사용자의 정보를 가져오도록 수정해보자.

@Authentication이라는 데코레이션으로 User의 정보를 가져올 수 있도록 만들어보자.

 

export const Authentication = createParamDecorator(
    (data: any, context: ExecutionContext) => {
        
    }
)

우선 다음과 같은 방법으로 데코레이터를 작성한다.

 

여기서 data는 데코레이터에 넘기로 넘기는 값, ExecutionContext는 NestJs에서 제공하는 Http요청이나 Websocket등 컨텍스트의 실행 정보를 담고 있다고 한다.

 

getType()을 통해 연결종류로 'http', 'rpc', 'ws' 중 하나를 반환하며, 우리는 이 중 http를 사용하기에 switchToHttp()를 통해서 http context를 반환한다.

 

그리고 해당 데코레이터에서 반환환 값은 

여기에서 받아서 사용한다.

 

        const httpRequest = context.switchToHttp().getRequest();

        const userId = httpRequest.session.userId;

이렇게 다음과 같은 방법으로 http로 변환하고 거기서 session을 가져와서 userId를 추출할 수 있다.

하지만 우리는 이 userId를 바탕으로 사용자 정보를 가져오고 싶다.

그렇기에 UserService가 필요하다.

하지만 이 UserService는 그냥 가져오는 것이 아니라, 의존성을 통해 주입받아야 한다.

그렇기에 여기서 우리는 의존성을 주입받을 수 있는 인터셉터를 사용해야 한다.

 

인터셉터를 통해 Session을 읽고 사용자를 조회한다.

그리고 그 조회한 사용자를 데코레이터를 통해 읽어오도록 만드는 것이다.

 

다시 인터셉터부터 만들어보자.

@Injectable()
export class ExtractUserInterceptor implements NestInterceptor {

    constructor(private readonly usersService: UsersService) {}

    async intercept(context: ExecutionContext, next: CallHandler<any>): Promise<Observable<any>> {

        //HTTP Context를 가져온다
        const request = context.switchToHttp().getRequest();

        //session에서 userId를 가져온다.
        const {userId}: {userId: number} = request.session || {};

        //userId가 없으면 인증에러를 반환한다.
        if(!userId) {
            throw new UnauthorizedException();
        }

        //User 정보를 데코레이터에서 가져올 수 있도록 context에 넣기
        request.curUser = await this.usersService.findOne(userId);

        return next.handle()
    }
}

우선 이렇게 만들어두고, 의존성을 주입받을 수 있도록 모듈에 인터셉터를 추가해준다.

 

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

 

이제 User를 추출할 데코레이터를 다음과 같이 수정하고

export const Authentication = createParamDecorator(
    (data: never, context: ExecutionContext) => {

        const httpRequest = context.switchToHttp().getRequest();

        return httpRequest.curUser;
    }
)

 

 

인터셉터를 달아준 후 테스트 해보도록 하자.

 

이렇게 잘 나오는 것을 볼 수 있다.

 

이거를 컨트롤러마다 설정하기 복잡하다면, 다음과 같은 방법으로 모듈에 글로벌하게 설정할 수 있다.

@Module({
  imports: [TypeOrmModule.forFeature([Users])],
  controllers: [UsersController],
  providers: [
      UsersService, AuthService,
    {
      provide: APP_INTERCEPTOR,
      useClass: ExtractUserInterceptor,
    }],
})
export class UsersModule {}

 

경로 중 인증정보가 없다면 401, 403 에러를 반환하고 싶을 때가 있다.

그럴 때를 위해 CanActivate를 위해 AuthGuard를 만들어주자.

export class AuthGuard implements CanActivate {
    canActivate(context: ExecutionContext): boolean | Promise<boolean> | Observable<boolean> {
        const request = context.switchToHttp().getRequest();

        return request.session.userId;
    }
}

 

여기서는 userId가 존재하지 않으면 접근을 막는다.

 

다음과 같이 설정하고

 

로그아웃하고 API를 요청하면

이렇게 403으로 거부되는 것을 볼 수 있다.

 

 

728x90

사용자가 로그인 한 동안에는 사용자가 API 요청 할 때마다 사용자의 정보를 가져와서 처리해야 할 것이다.

 

보통 이런 경우에는 2가지 방법 중 하나를 사용하는데, 세션혹은 쿠키를 사용한다.

 

우선 세션을 먼저 알아보고, 해당 강의가 끝나면 JWT를 통한 쿠키 인증 방법을 알아보자.

 

우선 cookie-session을 사용할 것인데, 이 라이브러리는 기존의 세션과는 다르게 세션데이터를 클라이언트의 쿠키에 저장한다고 한다.

어쩌면 쿠키라고 말하는 것이 맞을 거 같기도 하다.

이런식으로 세션을 유지한다고 한다.

 

우선 cookie-session 라이브러리부터 가져오자.

 npm install cookie-session

 npm install @types/cookie-session

 

그 다음에 main.ts에 cookie-session을 설정해준다.

const cookieSession = require('cookie-session');

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
      new ValidationPipe({
        whitelist: true,
      })
  );
  app.use(cookieSession({
    keys:['seungkyu']
  }))
  await app.listen(process.env.PORT ?? 3000);
}
bootstrap();

cookie-session은 ts가 되지 않아, 저렇게 require를 사용해야 한다고 한다.

 

app.use로 cookieSession을 설정해주고, key를 넣어야 한다.

이 key는 cookie에 평문 그대로 넘겨주지 않으니 약간의 암호화를 하는데, 그 때 사용하는 키다.

지금은 연습단계니 간단하게 이름으로 추가했다.

 

이제 간단하게 userController에 /test 핸들러를 추가해서 세션을 조작해보자.

 

    @Get("/test/:name")
    testWriteSession(@Param('name') name:string, @Session() session: any){
        session.name = name;
    }

    @Get("/test")
    testReadSession(@Session() session: any){
        return session.name;
    }

이렇게 데코레이터로 Session을 가져온다.

우선 간단하게 쓰고, 읽도록 만들어보았다.

 

이제 다음과 같은 http call을 날려보자.

 

그 다음 위의 http call을 날려보면

이렇게 Set-Cookie가 온 것을 볼 수 있다.

쿠키의 위조를 막기 위해 하나는 실제 쿠키, 하나는 서명값이 온다고 하고

seungkyu가 온 것이 아니라 암호화가 되어 온 것을 볼 수 있다.

 

그 다음 밑의 http call은

이렇게 정상적으로 다시 해독해서 온 것을 볼 수 있다.

 

그리고 위의 http call을 다시 날려보면

Set-Cookie가 없는 것을 볼 수 있는데, 쿠키가 같으면 굳이 nest에서 새롭게 쿠키를 굳이 업데이트 하지 않기 때문이다.

 

일단 간단하게 nest에서 session을 사용하는 방식을 알아보았다.

이제 이 session을 사용해 사용자의 로그인을 유지해보자.

728x90

저번시간에는 salt를 통한 단방향 암호화로 데이터베이스에 사용자의 비밀번호를 저장했다.

 

해당 비밀번호는 해독이 불가능하기에 해당 비밀번호를 풀어서는 비밀번호가 맞는지 확인할 수 없다.

 

사용자가 입력한 비밀번호를 암호화해서 비교하는 방식을 사용해야 한다.

 

우선 사용자에게 로그인 정보를 받는 컨트롤러를 만들어보자.

 

    @Post("/signIn")
    signIn(@Body() loginUserDto: LoginUserDto): Promise<Users>{
        return this.authService.signIn(loginUserDto)
    }

loginUserDto에는 당연히 email, password를 받고 있다.

 

우선 입력받은 email을 바탕으로 사용자를 데이터베이스에서 찾아온다.

해당 사용자가 존재하지 않으면 에러를 반환한다.

	const {email, password} = loginUserDto;

        const user = await this.usersService.findByEmail(email);
        
        if(!user)
            throw new UnauthorizedException();

 

이 user class 안에는 암호화된 비밀번호와 salt값이 결합되어 있을 것이다.

 

그거를 분리해서 salt와 hashedPassword로 가져온다.

const [salt, hashedPassword] = user.password.split('.');

 

그리고 사용자가 입력한 비밀번호를 salt로 암호화하고 데이터베이스의 비밀번호와 비교한다.

 

        const hashed = (await scrypt(password, salt, 32)) as Buffer;

        if(hashedPassword != hashed.toString('hex'))
            throw new UnauthorizedException();

        return user;

 

일치하면 성공, 불일치하면 에러이다.

 

한 번 테스트를 해보자.

 

우선 다음과 같이 사용자를 생성한다.

그럼 다음과 같이 데이터가 생성된다.

해당 암호화값으로는 비밀번호를 알 수 없기에 로그인 API를 호출해보자.

 

우선 잘못된 값을 넣어보았다.

 

그러면 당연히 비밀번호가 일치하지 않아 인증 오류가 발생한다.

 

이번에는 제대로된 데이터를 넣어보자.

 

이렇게 제대로 유저가 응답되는 것을 볼 수 있다.

 

아마 이게 가장 정석적인 암호화 방법이라고 생각된다.

salt를 추가한 방법으로 로그인들을 구현하도록 하자.

728x90

 

기존에는 이렇게 비밀번호를 평문으로 저장하고 있다.

보안의 문제로 암호화해야 하기에 바로 변경해보도록 하자.

 

 

우선 인증은 크게 이렇게 동작한다.

쿠키는 회원가입 뿐만 아니라 로그인시에도 전달해주어야 한다.

 

일단 이번 글에는 생성되는 사용자에 따라, 비밀번호를 암호화하는 작업에 집중해보자.

 

우선 암호화는 복호화가 불가능해야한다.

이렇게 암호화는 가능하지만, 암호화된 값을 다시 원래 값으로 복원할 수 없어야 한다는 것이다.

 

하지만 문제가 있다.

mypassword를 암호화하면 언제나 저 값이기 때문에, $2a$12$rYxHFmJehRhNiMIVsmZeTeO2i314RIoe8gm5Frfb5VsxanaNyJ7/e 이 값을 가지고 있다면 역으로 mypassword의 값을 유추할 수 있는 것이다.

 

대표적으로 많이 쓰는 비밀번호들이 mypassword, password, asdf... 이런 값들에 대해 미리 암호화를 해두고, 암호화된 값을 비교해가며 원래 비밀번호를 유추하는 해커들이 존재한다.

그리고 그렇게 만들어둔 테이블을 레인보우 테이블이라고 한다.

 

그렇기 때문에 암호화하면서 무작위 값을 추가하여 암호화해야 한다.

그래야 원래 값이 같아도, 다른 암호화된 값이 나오기에 역으로 유추가 불가능하기 때문이다.

 

그 때 사용하는 값을 Salt라고 한다.

Salt가 ab152라고 한다면

ab152mypassword 이렇게 암호화를 진행하는 것이다.

그 암호화값이 $2a$12$/5YCKIUqJQoeOARsVvuDeuutm/2EKj4r14xXimYE6jt/MhyWJvO.i라면

$2a$12$/5YCKIUqJQoeOARsVvuDeuutm/2EKj4r14xXimYE6jt/MhyWJvO.i-ab152 이렇게 데이터베이스에 저장하고

로그인 할 때마다 salt를 분리하여 맞춰보는 방법을 사용한다.

이러면 기존이랑 값이 많이 달라지기에 레인보우 테이블로도 문제가 생기지 않는다.

 

우선 해당 작업을 위해, 기존에 사용하던 UserService와 분리된 AuthService를 생성한다.

확장을 위해 분리했으며, 관심사가 비슷한 User module에 생성하도록 한다.

 

클래스간의 의존성은 다음과 같다.

사이클이 생기지 않도록 잘 관리하도록 하자.

 

import {Injectable} from "@nestjs/common";
import {UsersService} from "./users.service";

@Injectable()
export class AuthService {
    constructor(private readonly usersService: UsersService) {}
}
@Module({
  imports: [TypeOrmModule.forFeature([Users])],
  controllers: [UsersController],
  providers: [UsersService, AuthService],
})
export class UsersModule {}

우선 AuthService를 추가해준다.

 

    async signUp(createUserDto: CreateUserDto) {
        const {email, password, name} = createUserDto;

        //해당 이메일의 중복 확인

        //사용자 비밀번호 암호화

        //사용자 정보 저장
        
        //사용자에게 응답
        return await this.usersService.create({email, password, name});
    }

우선 회원가입하는 로직은 다음과 같을 것이다.

 

우선 중복을 확인하고 중복이면 에러를 반환한다.

        //해당 이메일의 중복 확인
        const existedUser = await this.usersService.findByEmail(email);

        if(existedUser){
            throw new ConflictException('Email already exists');
        }

 

그 다음은 아래의 코드로 scrypt를 가져와준다.

import {randomBytes, scrypt as _scrypt} from 'crypto';
import {promisify} from 'util';

const scrypt = promisify(_scrypt);

 

그리고 다음과 같이 salt를 만들어서 암호화하고 이어준다.

        //사용자 비밀번호 암호화
        //SALT값 생성
        //8바이트로 16자리의 16진수 랜덤 문자열을 생성
        const salt = randomBytes(8).toString('hex');

        //SALT와 합쳐서 암호화
        //32자로 암호화된 값을 버퍼로
        const hash = (await scrypt(password, salt, 32)) as Buffer;

        //암호화된 값(16진수로 변환)과 SALT를 결합
        const result = salt + '.' + hash.toString('hex');

 

전체 메서드는 다음과 같다.

    async signUp(createUserDto: CreateUserDto) {
        let {email, password, name} = createUserDto;

        //해당 이메일의 중복 확인
        const existedUser = await this.usersService.findByEmail(email);

        if(existedUser){
            throw new ConflictException('Email already exists');
        }

        //사용자 비밀번호 암호화
        //SALT값 생성
        //8바이트로 16자리의 16진수 랜덤 문자열을 생성
        const salt = randomBytes(8).toString('hex');

        //SALT와 합쳐서 암호화
        //32자로 암호화된 값을 버퍼로
        const hash = (await scrypt(password, salt, 32)) as Buffer;

        //암호화된 값(16진수로 변환)과 SALT를 결합
        const result = salt + '.' + hash.toString('hex');

        //사용자 정보 저장
        password = result;

        //사용자에게 응답
        return await this.usersService.create({email, password, name});
    }

 

Http로 테스트를 해보니 다음과같이 정상적인 데이터가 들어간 것을 볼 수 있었다.

 

저 비밀번호는 암호화가 불가능하다.

그렇기 때문에 비밀번호를 확인하기 위해서 사용자가 전달한 비밀번호를 암호화하고 비교해야 한다.

우선 여기까지만 글을 쓰고 다음 글에서 알아보도록 하자.

728x90

물론 response dto의 class를 만들고, 해당 클래스로 변환해서 응답하는 방법도 있다.

하지만 nest에서는 다른 방법도 많이 사용하는 것 같다.

 

우선 지금 GET /users/:id를 봐보자.

비밀번호가 그대로 나온다.

현재 인코딩을 하지는 않았지만, 인코딩을 한 비밀번호라도 사용자의 응답에는 노출되면 안 될 것이다.

 

이 비밀번호를 제거할 것인데, 방법은 2가지이다.

 

엔티티에 규칙을 추가하는 방법, 중간에 가로채는 방법이다.

 

우선 엔티티에 규칙을 먼저 추가해보자.

 

엔티티에 규칙을 추가

 

무려 nest 공식문서가 추천하는 방법이다.

 

우선 다음과 같이 제외할 properties에 @Exclude()를 붙여준다.

@Entity('users')
@Unique(['email'])
export class Users{

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column()
    email: string;

    @Column()
    @Exclude()
    password: string;

    @AfterInsert()
    logInsert(){
        console.log(`user inserted id: ${this.id}`);
    }
}

 

그리고 해당 컨트롤러의 핸들러에 다음과 같은 데코레이터를 붙여준다.

@UseInterceptors(ClassSerializerInterceptor)

    @Get("/:id")
    @UseInterceptors(ClassSerializerInterceptor)
    findUser(@Param('id') id: number): Promise<Users> {
        return this.usersService.findOne(id)
    }

 

이렇게 해야 @Exclude()가 작동한다.

 

이러고 바로 요청을 해보면

바로 password가 빠진 것을 볼 수 있다.

 

하지만 이 방법은 안타까운 부분이 있다.

다른 권한 혹은 경로로 요청을 할 때마다 보여줘야 하는 속성이 다른 경우가 있다.

관리자 권한으로 요청을 하면 이메일이 보여야 하지만, 일반 유저로 요청하면 이메일이 보이면 안되는 이런 경우 말이다.

이런 상황에서는 @Exclude()로 완벽하게 해결 할 수 없다.

 

Interceptor

원하는 상황에서 원하는 property만 넘길 수 있는 Interceptor를 만들어보자.

더 자유롭게 사용 가능하지만, 구현 난이도는 조금 더 높다.

 

AOP의 느낌으로 컨트롤러에서 client에게 가는 데이터를 가로채서 변환하는 방법이다.

Inteceptor는 NestInterceptor를 구현해서 만든다.

구현해야 하는 메서드는

intercept(context: ExecutionContext, next: CallHandler)

inercept는 자동으로 컨트롤러가 호출되면 전과 후에 호출되는 메서드이며, context는 도착한 request의 정보를 담고 있으며, next는 controller에서도 request를 처리해야 하는 handler의 정보를 담고 있다.

 

우선 간단하게 interceptor가 어떻게 동작하는지 보자.

import{
    UseInterceptors,
    NestInterceptor,
    ExecutionContext,
    CallHandler,
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { plainToClass } from 'class-transformer';

export class SerializeInterceptor implements NestInterceptor {

    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
        console.log("before");
        console.log(context);

        return next.handle().pipe(
            map((data: any) => {
                console.log("after");
                console.log(data);
                return data;
            })
        );
    }

}

이렇게 일단 로그만 출력하는 interceptor를 만들고,

 

    @Get("/:id")
    @UseInterceptors(SerializeInterceptor)
    findUser(@Param('id') id: number): Promise<Users> {
        console.log("i'm handler");
        return this.usersService.findOne(id)
    }

Get users에 interceptor 달아서 한 번 요청해보았다.

 

우선 가장 먼저 interceptor가 실행된다.

가장 위에 있는 부분이며, ExecutionContextHost에는 

이렇게 http 요청에 대한 정보들이 포함되어 있다.

 

그 다음으로 handler메서드가 실행되고, interceptor의 로그가 출력된다.

 

결국 아래의 그림과 같이 handler의 실행 전후를 감싸고 있는 구조인 것이다.

 

그럼 이제 이 interceptor를 사용해서 json을 변환해보자.

 

우선 해당 response를 명세할 dto는 필요하긴 하다.

중간에 interceptor가 가로채서 dto로 바꿔준다고 생각하면 된다.

 

import {Expose} from "class-transformer";

export class UserDto {
    @Expose()
    id: number;

    @Expose()
    email: string;
}

 

우선 무조건 UserDto로 변환하게 만들어보면

export class SerializeInterceptor implements NestInterceptor {

    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {

        return next.handle().pipe(
            map((data: any) => {
                
                
                return data;
            }),
        );
    }

}

여기의 map 안에서 해당 데이터의 properties 중에 UserDto에 해당하는 것만 가져오게 만드는 것이다.

 

export class SerializeInterceptor implements NestInterceptor {
    intercept(context: ExecutionContext, next: CallHandler<any>): Observable<any> {
        return next.handle().pipe(
            map((data: any) => {
                return plainToInstance(UserDto, data, {
                    excludeExtraneousValues: true
                });
            }),
        );
    }
}

이렇게 plainToInstance로 UserDto의 속성들만 뽑아낸다.

 

한 번 다시 요청해보자.

이렇게 원하는 속성들만 나오는 것을 볼 수 있다.

 

일단 원하는 속성들만 추출하도록 만들었으니, 이제 이 interceptor를 dto를 동적으로 받을 수 있도록 리펙토링 해보자.

 

우선 이 SerializeInterceptor에 무언가 값을 받을 수 있도록 해야겠다.

@UseInterceptors(new SerializeInterceptor(UserDto))

이런식으로 UserInterceptor에서 SerializeInterceptor를 생성하고, UserDto를 넘기는 방법 말이다.

 

그리고 SerializeInterceptor에서 생성자로 dto를 받아, 해당 dto로 변환하도록 만든다.

export class SerializeInterceptor implements NestInterceptor {
    constructor(private dto: any){}
    intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
        return next.handle().pipe(
            map((data: any) => {
                return plainToInstance(this.dto, data, {
                    excludeExtraneousValues: true,
                });
            }),
        );
    }
}

 

그리고 현재 데코레이터에 new SerializeInterceptor로 넘기고 있기에, 이거도 함수로 만들어서 코드를 줄여준다.

export function Serialize(dto: any){
    return UseInterceptors(new SerializeInterceptor(dto));
}

이렇게 SerializeInterceptor를 담은 UseInterceptor를 넘겨주고, 이 Serialize를 데코레이터로 넘기면 된다.

 

@Serialize(UserDto)

 

마지막으로 저 dto 자리의 type이 any이기 때문에, 사실 어떤 값(1, true)를 넣어도 동작하게 된다.

따라서 저기에 class만 넣을 수 있도록 만들어야 한다.

 

interface ClassConstructor{
    new (...args: any[]): any;
}

export function Serialize(dto: ClassConstructor){
    return UseInterceptors(new SerializeInterceptor(dto));
}

이렇게 생성자가 존재한다는 조건을 걸어주면 클래스만 인자로 받을 수 있게 된다.

 

이제 한 번 직접 실행해보자.

 

 

이렇게 원하는 응답만 오는 것을 볼 수 있다.

 

그럼 여기서 갑자기 궁금한 부분이 생긴다.

과연 데코레이터는 싱글톤일까?

 

알아보기위해 우선 데코레이터를 2군데에 작성했다.

export function Serialize(dto: ClassConstructor){
    console.log("function", Math.random().toString(36));
    return UseInterceptors(new SerializeInterceptor(dto));
}

 

그리고 해당 함수를 거칠 때마다 랜덤값을 출력하도록 하니

 

서버를 실행할 때만 이렇게 로그가 발생하고, 요청간에는 발생하지 않았다.

이것을 보아 싱글톤은 아니고, 핸들러마다 하나씩 생성되어 사용되는 것을 알 수 있었다.

728x90

저번에 User와 관련한 Entity를 만들었었다.

@Entity('users')
@Unique(['email'])
export class Users{

    @PrimaryGeneratedColumn()
    id: number;

    @Column()
    name: string;

    @Column()
    email: string;

    @Column()
    password: string;
}

 

이 Entity를 등록했었고, 이거를 바탕으로 nest repository를 서비스에 만들어보자.

 

@Injectable()
export class UsersService {
    
    constructor(@InjectRepository(Users) private readonly userRepository: Repository<Users>) { }
}

이렇게 UserService에 UserRepository의 의존성을 등록해준다.

 

컨트롤러의 내용은 제외하고 서비스의 코드만 채워볼것이다.

  • Create

우선 create부터 만들어보자.

async create(createUserDto: CreateUserDto) {
        const user = this.userRepository.create({
            name: createUserDto.name,
            email: createUserDto.email,
            password: createUserDto.password
        });
        
        return await this.userRepository.save(user);
    }

 

create와 save를 사용했는데, create가 데이터베이스에 값을 create하는 메서드는 아니다.

해당 entity 구조를 바탕으로 새로운 entity를 만들어주는 메서드이다.

save가 데이터베이스에 해당 값을 저장해주는 메서드이다.

 

사실 아래와 같은 방법으로도 저장은 가능하다.

this.userRepository.save({name: createUserDto.name, email: createUserDto.email,  password: createUserDto.password});

하지만 그럼에도 불구하고 entity를 생성해서 저장하는 이유는 entity에 비즈니스 로직들이 들어있으며, 데코레이터등을 사용해 값을 미리 검증할 수 있다.

그리고 insert, update와 같은 작업 이후에 특정 메서드가 동작하도록 할 수 있다.

 

    @AfterInsert()
    logInsert(){
        console.log(`user inserted id: ${this.id}`);
    }

이렇게 AfterInsert()를 사용하면 데이터베이스 저장 이후에 로그가 출력되도록 할 수 있다.

 

이렇게 entity의 기능들과 hook을 사용할 수 있도록, 직접 객체 리터럴로 저장하는 것이 아닌 create로 엔티티를 생성해서 저장하도록 하자.

 

  • Read

이번엔 read이다.

우선 id를 사용해 하나의 entity만 가져오는 메서드이다.

    async findOne(id: number){
        return this.userRepository.findOne({where: {id: id}});
    }

이렇게 findOne where절 내에 조건을 작성해주면 된다.

id는 PK이기 때문에 하나만 값을 가져와 findOne() 메서드를 사용했다.

여기서 만약 찾지 못하는 경우를 대비해 404 에러를 반환할수 있도록 하자.

 

    async findOne(id: number): Promise<Users> {
        const user = await this.userRepository.findOne({where: {id: id}});
        if(!user){
            throw new NotFoundException('User not found');
        }
        return user;
    }

 

이번에는 name을 사용해 많은 데이터들을 검색하는 메서드이다.

    async findAll(name: string): Promise<Users[]> {
        return await this.userRepository.find({where: {name}});
    }

역시 where절의 조건으로 name을 추가해주었고, 하나의 엔티티만 가져오는 것이 아니기 때문에 find() 메서드를 사용한다.

여기서는 만약 해당하는 값이 없다면 404에러가 아닌 빈 배열이 반환되게 될 것이다.

 

  • Update
    async update(id: number, userAttrs: Partial<Users>): Promise<Users> {
        const user = await this.findOne(id);
        Object.assign(user, userAttrs);
        return await this.userRepository.save(user);
    }

여기서 Partial 타입을 사용한 것을 볼 수 있는데

Users의 부분적인 속성들을 가지는 클래스이다.

{name}, {name, email} ... 등등 Users 클래스 내의 속성들을 부분적으로 가지고 있는 클래스이다.

그 값을 Object.assign을 통해 기존의 user 데이터에 덮어쓰고, save() 메서드를 통해 데이터베이스에 저장한다.

 

export class UpdateUserDto {

    @IsString()
    @IsOptional()
    name: string;

    @IsEmail()
    @IsOptional()
    email: string;

    @IsString()
    @IsOptional()
    password: string;
}

dto에서 @IsOptional()을 사용하면, json에서 선택적으로 값을 넣어 요청할 수 있다.

 

  • Delete
    async delete(id: number): Promise<Users> {
        const user = await this.findOne(id);
        return this.userRepository.remove(user);
    }

 

저번과 마찬가지로 조회 후 값을 확인하고 삭제한다.

remove()는 아이디가 아닌 entity를 넣어서 삭제한다.

 

 

그럼 여기서 궁금한점이 있다.

save() 메서드는 언제 insert로 동작하고, 언제 update로 동작하는걸까?

다음과 같은 3가지 상황을 만들어서 save()를 호출해봤다.

모두 find를 호출하지 않은 상태로 save()를 호출하는 것이다.

  • save()로 넘긴 entity에 id가 없는 경우
  • entity에 id가 있지만 데이터베이스에는 해당 id가 없는 경우
  • entity에 id가 있으며, 데이터베이스에도 해당 id가 존재하는 경우

우선 현재 데이터베이스에는 해당 값만 존재한다.

 

첫번째 경우는 역시 성공한다.

POST http://localhost:3000/users/test
Content-Type: application/json

{
  "name": "승규",
  "email": "sda1fs@gmail.com",
  "password": "1234"
}

그냥 평범하게 insert 쿼리가 날아간 것을 볼 수 있다.

 

두번째 경우도 성공한다.

POST http://localhost:3000/users/test
Content-Type: application/json

{
  "id": 123,
  "name": "승규",
  "email": "sda1123fs@gmail.com",
  "password": "1234"
}

 

쿼리를 보면 신기하게도 미리 조회를 해보고, insert 한 것을 볼 수 있다.

 

세번째 경우에서는 중복 에러가 발생할 줄 알았지만 성공했다.

POST http://localhost:3000/users/test
Content-Type: application/json

{
  "id": 123,
  "name": "승규",
  "email": "sda112123s@gmail.com",
  "password": "1234"
}

 

이렇게 요청을 하니

이거도 미리 조회를 하고, 값이 있기에 update로 바꾼 것을 볼 수 있었다.

 

save() 메서드는 id가 존재하는 경우에 데이터베이스에 2번 접근하는 것을 알 수 있었다.

+ Recent posts