본문 바로가기
블로그 이미지

방문해 주셔서 감사합니다! 항상 행복하세요!

  
   - 문의사항은 메일 또는 댓글로 언제든 연락주세요.
   - "해줘","답 내놔" 같은 질문은 답변드리지 않습니다.
   - 메일주소 : lts06069@naver.com


Node.js/Nestjs (Nest.js)

Nestjs 프레임워크 서버(패스포트, JWT) -9

야근없는 행복한 삶을 위해 ~
by 마샤와 곰 2022. 8. 22.

# 패스포트(passport, 여권)

 

패스포트 라이브러리(프레임워크)는 로그인과 관련된 기능을 제공합니다.
패스포트(여권)는 자바에서의 시큐리티(security), 파이썬의 플라스크로그인(flask-login) 과 비슷한 로그인 관련 모듈(프레임워크)입니다.
이러한 로그인 프레임워크(라이브러리)를 사용하지 않고 기능을 구현하기 위해서 아래와 같은 패턴으로 작업을 합니다.
 * 로그인 요청 처리
  1) 로그인 응답을 받는 메소드
  2) 요청된 값을 가지고 데이터베이스 조회하는 메소드
  3) 로그인 정보 기록(서버/클라이언트)
  4) 로그인 결과 전달

 

이렇게 4단계로 로그인 요청이 완료되고 나면 이제 권한에 따른 기능 작업을 해 주어야 합니다.
 * 권한에 따른 페이지 접근
  5) 로그인된 사용자면 접근 허용
  6) 로그인 + 정해진 권한에 따른 사용자면 접근 허용

로그인과 관련된 기능은 직접 구현해도 사실 상관은 없습니다.
굳이 로그인 기능을 모듈(프레임워크)형태로 사용하는 이유는 생산성과 유지보수에 목적이 있습니다.
다른 프로젝트에 투입되거나 신규 프로젝트를 진행하는 경우에 약속된 규칙과 잘 만들어진 정의문서(document)가 존재 한 다면 이러한 기능에 대한 협의시간을 줄일 수 있기 때문 입니다.

이제 위 단계를 패스포트를 통해 알아보겠습니다.
먼저 패스포트를 설치하여 줍니다.

npm install --save @nestjs/passport passport passport-local
npm install --save-dev @types/passport-local

 

설치가 완료되었으면 이제 1번과 2번작업을 해 보겠습니다.
먼저 로그인 요청을 받는 메소드를 추가하여 줍니다.
* 파일이름 : app.controller.ts

import { Request, Response } from 'express';
import { Controller, All, Res, Req, HttpStatus, UseGuards, Request as reqs  } from '@nestjs/common';

//생략...
@Controller()
export class AppController {
    //생략...

    @All('tryLogin')   //tryLogin 로 요청을 받습니다.
    async login(@reqs() req) {
        return req.user;
    }
}

 

login 메소드는 사용자가 받은 Request 객체에서 user라는 값을 반환하게 되어 있습니다.
해당 값은 나중에 패스포트에 의해서 생성되며 반환 될 예정 입니다.

다음으로 2번입니다.
데이터베이스는 사용자의 취향(?)과 환경(?)이 다르기 때문에 여기서는 간단한 객체로 대체하였습니다.
서비스 클래스에 간단하게 붙여 주었습니다.
* 파일이름 : app.service.ts

import { Injectable } from '@nestjs/common';

//정보에 대한 타입입니다
export type User = {
  userIndex?: number;
  username: string;
  password?: string;
  role: string;
};

@Injectable()
export class AppService {

  //저장된 공간 입니다
  private readonly DATA_BASE: Array<any> = [
    {
      userIndex: 1,
      username: 'admin',
      password: '1234',
      role: ['super'],
    },
    {
      userIndex: 2,
      username: 'user',
      password: '1234',
      role: ['normal'],
    },
    {
      userIndex: 2,
      username: 'user',
      password: '1234',
      role: ['normal', 'semiSuper'],
    }
  ];

  //정보를 찾습니다.
  async findInformation(username: string, password: string): Promise<User> {
    return this.DATA_BASE.find(
      (arg) => arg.username === username && arg.password === password
    );
  }

}

 

서비스에 데이터베이스의 정보를 DATA_BASE 라는 객체의 값을 조회하게 하였습니다.
나중에 DATA_BASE 부분을 모듈이나 다른 클래스로 분리하여 실제 데이터베이스에 조회하도록 바꾸면 될 것 입니다.
드디어 3번과 4번작업 입니다!

패스포트에서 로그인과 관련된 비지니스 성격의 클래스를 Strategy 클래스라 합니다.
Strategy의 뜻은 "병법", "계략" 이라는 뜻 입니다.
이러한 스트래터지(Strategy) 클래스의 내용은 아래처럼 PassportStrategy 함수를 상속 받아서 사용 합니다.
* 파일이름 : local.strategy.ts

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException, } from '@nestjs/common';
import { AppService } from './app.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {

  constructor(private service: AppService) {
    super();
  }

  //사용자 요청에서 username과 password 값을 받습니다.
  async validate(username: string, password: string): Promise<any> {
    const user = await this.service.findInformation(username, password);
    console.log('조회 결과 : ',user);
    if (!user) throw new UnauthorizedException();  //조회결과 값이 틀렸다면!
    return user;  //값이 존재 한 다면!
  }
}

 

validate 메서드는 PassportStrategy 함수를 상속받으면 사용할 수 있는 오버라이딩 메소드 입니다.
2개의 값을 받으며 username과 password라는 이름으로 값을 받도록 되어 있습니다.
이 2개의 값을 통해서 데이터베이스에 조회하는 서비스를 사용하여 값을 조회하게 하고,
값이 존재하면 사용자가 정의한 값을 반환, 값이 없다면 오류 또는 사용자가 정의한 값을 반환하도록 만들어 줍니다.

이렇게 만들어진 스트래터지 클래스는 가드(guard)클래스를 만들어 적용하여 줍니다.
* 가드(guard)는 7장에서 소개를 한 적이 있습니다. 
* 파일이름 : local-auth-guard.ts

import { Injectable } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';  //passport의 가드 입니다!

@Injectable()
export class LocalAuthGuard extends AuthGuard('local') {
    
}

 

해당 가드는 local 정책을 따르는 스트래터지 클래스를 참조 합니다.
LocalStrategy 라는 이름으로 만든 클래스의 파일명은 local.strategy.ts로 만들었습니다.
그러므로 따로 적용법을 하지 않더라도 LocalAuthGuard 가드는 본인 디렉토리 내부의 local로 시작하는 클래스를 찾아줍니다.
이렇게 만들어진 가드를 이제 모둘과 컨트롤러에 붙여 줍니다.
* 파일이름 : app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

@Module({
  imports: [
    PassportModule  //패스포트 모듈을 사용할꺼니까 추가!!
  ],
  controllers: [AppController],
  providers: [AppService, LocalStrategy],  // 만들어준 LocalStrategy클래스 추가!
})
export class AppModule {}

* 파일이름 : app.controller.ts

import { Request, Response } from 'express';
import { Controller, All, Res, Req, HttpStatus, UseGuards, Request as reqs  } from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';

//생략...
@Controller()
export class AppController {
    //생략...

    @UseGuards(LocalAuthGuard)   //가드
    @All('tryLogin')   //tryLogin 로 요청을 받습니다.
    async login(@reqs() req) {
        return req.user;
    }
}

 

여기까지가 4번까지 적용한 최종 모습입니다.
LocalStrategy 클래스(스트래터지)에서 우리는 username과 password 라는 값으로 데이터를 매핑하였기 때 문에 요청하는 곳 에서도 해당 값으로 요청하면 됩니다.
LocalStrategy 클래스에서 user라는 필드에 사용자 정보를 담아 반환하게 하였습니다.
* 이해가 어렵다면 LocalStrategy 클래스의 validate 메소드 부분을 살펴보세요.

 

한번 동작시켜 봅니다!

 

사용자의 요청에 대해서 가드가 스트래터지의 validate 메서드 내용을 훌륭하게 수행한 것을 볼 수 있습니다.
정리해보면, 패스포트의 기본 개발 구조는 아래와 같습니다.


DB연결 기능 파일 + 스트래터지 파일 + (패스포트) 가드 파일 + 적용하는 컨트롤러


이제 앞으로 사용자는 페이지 접근을 위해서 항상 username과 password를 붙여서 전송해야만 합니다!
get방식이든 post 방식이던지간에 항상 헤더 또는 바디에 값을 붙여야 합니다.
또한 그럴 때 마다 서버는 매번 데이터베이스에 조회해서 정보를 확인할 것 입니다.
그러나 항상 사용자의 아이디, 비밀번호를 조회하는 웹 어플리케이션은 많지 않습니다.
위에 설명한 패스포트 적용 방식은 최초 로그인시에 사용하기 위해서는 적합하지만, 로그인이 성공한 이후에 해당 정보를 계속해서 확인할 때 사용하기에는 조금 버거워 보입니다.

 

 


 

# JWT + 패스포트(JWT passport)

 

JWT 방식은 Json web token 의 줄임말로 서버에서의 사용자 정보를 유지하는 방식이 아닌 브라우저에서 사용자 정보를 유지하는 방식을 의미 합니다.


JWT 방식으로 로그인 여부를 확인하는 기능은, 서버에서 특정 토큰 값을 암호화하여 발행한 뒤에 브라우저에 심어두고 브라우저는 해당 값을 가지고 요청을 합니다.
이때 토큰값이 유효한지 여부만 서버에서 확인을 하는 로그인 패턴 입니다.


JWT방식을 사용하는 이유는, 간단하게 HTTP프로토콜의 헤더부분에 값을 붙여두고 사용자가 로그인 하였는지를 검증 할 수 있기 때문 입니다.
그러므로 서버에서의 세션같은 자원을 소모하지 않아도 되기 때문에 최근 많이 사용되고 있습니다.
JWT 패스포트를 사용하기 위해서 역시나 추가 모듈을 설치하여 줍니다.

npm install --save @nestjs/jwt passport-jwt
npm install --save-dev @types/passport-jwt


앞선 단계와 비슷한 방법으로 작업을 진행 합니다.
데이터베이스에 조회를 하던 서비스에 사용자 정보를 암호화하여 JWT 값으로 만들어주는 기능을 추가합니다.
* 수정하는 파일이름 : app.service.ts

import { Injectable } from '@nestjs/common';
import { JwtService } from '@nestjs/jwt';  //추가!!!!

//생략..

@Injectable()
export class AppService {
  
  //생략..

  //정보를 찾습니다.
  async findInformation(username: string, password: string): Promise<User> {
    return this.DATA_BASE.find(
      (arg) => arg.username === username && arg.password === password
    );
  }

  //토큰을 발행 합니다.
  async createToken(user: User): Promise<any> {
    const payload = { username: user.username, userIndex: user.userIndex, role : user.role };
    return {
      access_token: this.jwtService.sign(payload)
    };
  }  
}

 

기존에 작업하였던 LocalStrategy 클래스는 최초 로그인시 JWT 토큰을 발행해 주는 기능으로만 사용하겠습니다!

그러므로 LocalStrategy 클래스에서 사용자가 로그인에 성공하는 경우 user라는 정보를 반환하지 않고 만들어진 토큰을 반환하게 변경하여 줍니다.

* 수정하는 파일이름 : local.strategy.ts

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException, } from '@nestjs/common';
import { AppService } from './app.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {

  constructor(private service: AppService) {
    super();
  }

  //사용자 요청에서 username과 password 값을 받습니다.
  async validate(username: string, password: string): Promise<any> {
    const user = await this.service.findInformation(username, password);
    console.log('조회 결과 : ',user);
    if (!user) throw new UnauthorizedException();
    //return user;   ///기존에 사용자 정보를 주는 기능을 주석하고!!
    return this.service.createToken(user);  //향후 사용할 JWT 토큰을 리턴하게 합니다.
  }
}

 

여기까지 하게되면 사용자가 로그인에 성공하면 JWT 형식의 토큰을 받게 됩니다.
이 토큰 값은 헤더에서 Authorization 라는 키 값을 가지고 사용할 예정 입니다.
다음으로 이제 JWT 값을 검증하는 스트래터지(Strategy) 클래스와 가드(Guard)를 추가해줄 차례 입니다.
먼저 JWT 스트래터지 클래스를 추가 합니다.
* 파일이름 : jwt.strategy.ts

import { Request } from 'express';
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor() {
    super({  //jwt 설정
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      secretOrKey: "secretKey",   ////////secretKey 키 값은 모듈설정에서의 값과 일치해야 합니다!!!
      passReqToCallback : true
    });
  }

  async validate(req :Request, payload: any) {  //토큰 정보는 payload 값에 존재
    return { userId: payload.userIndex, username: payload.username, role : payload.role };
  }
}

 

역시나 PassportStrategy 함수를 상속 받아서 만들어 줍니다.
조금 다른 점은 validate 메서드를 통해서 받는 값이 JWT토큰을 분해하여 가져오는 페이로드 데이터 입니다.
페이로드 데이터는 사용자가 로그인시 발행한 토큰값의 키 값을 기준으로 생성되어 있습니다.


우리는 LocalStrategy 클래스에서 validate 메서드에서 사용자 정보를 객체로 반환하지 않고 서비스 클래스의 createToken 메서드를 통해서 JWT 토큰으로 리턴하게 하였습니다.

그러면 패스포트 모듈은 알아서 JWT스트래터지 형식의 클래스를 만나면 validate 메소드를 실행하여 분석된 JWT 토큰값을 넣어주게 되어 있습니다.
그렇기 때 문에 JWT 스트래터지 클래스는 위 방식처럼 구현해 주면 됩니다.

다음으로 가드를 만들어줍니다.
* 파일이름 : jwt-auth.guard.ts

import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {

    handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
        const request = context.switchToHttp().getRequest<Request>();

        //권한따른 페이지 체킹방식1
        if(request.url.indexOf("superOrSemi") > -1){  //요청 주소
            let roles : Array<any> = user.role;  //user 값은 JwtStrategy 클래스에서 반환한 값 입니다.
            let len = roles.filter(arg=> arg==='super' || arg==='semiSuper') ;  //내용 : "사용자 권한이 xx이라면~"
            if(len) throw new UnauthorizedException();
        }
        return user;
    }
}

 

JWT 가드에는 handleRequest 메서드를 오버라이딩 해서 사용 합니다.
JwtStrategy 클래스에서 validate 를 통해서 검증된 사용자의 정보가 handleRequest 메서드에서 페이지 주소, 권한 등에 따라서 검증받게 되어 있습니다.
이제 모듈과 컨트롤러에 적용합니다.
* 수정하는 파일 : app.module.ts

import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { PassportModule } from '@nestjs/passport';
import { LocalStrategy } from './local.strategy';

import { JwtModule } from '@nestjs/jwt';
import { JwtStrategy } from './jwt.strategy';

@Module({
  imports: [
    PassportModule,
    JwtModule.register({
      secret: "secretKey", ////////secretKey 키 값은 JWT스트래터지 에서의 값과 일치해야 합니다!!!
      signOptions: { expiresIn: '180s' },  //JWT 토큰값의 생명시간 입니다 : 여기선 3분
    })    
  ],
  controllers: [AppController],
  providers: [AppService, LocalStrategy, JwtStrategy],
})
export class AppModule {}

* 수정하는 파일 : app.controller.ts

import { Request, Response } from 'express';
import { Controller, All, Res, Req, HttpStatus, UseGuards, Request as reqs  } from '@nestjs/common';
import { LocalAuthGuard } from './local-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';

@Controller()
export class AppController {

  //생략..
  @UseGuards(LocalAuthGuard)
  @All('tryLogin')
  async login(@reqs() req) {     //로그인이 성공하면 JWT값이 반환 됩니다!
    return req.user;
  }

/**
 * 헤더 (Authorization : Bearer 블라블라)
 *  - 키 : Authorization
 *  - 값 : Bearer 발급받은값
 */
  @UseGuards(JwtAuthGuard)
  @All('profile')
  async 프로필(@reqs() req) {     //권한이 확인되면 내정보값을 반환 합니다.
    return req.user;
  }
}

 

이제 tryLogin에서 JWT을 받아서 profile 주소로 이동 해 보겠습니다.

로그인으로 토큰을 발급받고!
그 정보를 헤더에 넣어서 이동합니다! 앞에 Bearer가 한칸 띄고 붙습니다.

 

이러한 방식을 통해서 페이지별, 사용자 권한별 로그인 검증을 할 수 있습니다!
아래는 위 단계가 이해가 전부 되었다면 확인하여 주세요!

 


 

#커스텀 데코레이터 + JWT + 패스포트(JWT passport)

 

위 방식에서의 조금 아쉬운 부분은 JWT가드에서 조건문을 엄청 길게 주~욱 써야한다는 점 입니다.

이런 코드가 계속 늘어나겠지요????

 

아마 아래와 같은 조건문이 늘어날 것 입니다.

if (접속한 주소가 x이면서 권한이x면) {

} else if (접속한 주소가 x이면서 권한이x면) {

} else if (접속한 주소가 x이면서 권한이x면) {

} else if (접속한 주소가 x이면서 권한이x면) {

} ...

 

권한과 주소가 많아지면 아마 조건문이 엄청나질 것 입니다...
조금 더 깔끔한 코드를 위해서는 데코레이터 1개만 만들어주면 편리하게 코드를 줄일 수 있습니다!
아래 데코레이터를 만들어 봅니다.
* 파일이름 : new.커스텀데코레이터.ts

import { applyDecorators, SetMetadata, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from './jwt-auth.guard';

export function 권한관련커스텀데코레터(...컨트롤러의_데코레이터에서온값들: any) {
  return applyDecorators(
    SetMetadata('roles', 컨트롤러의_데코레이터에서온값들),
    UseGuards(JwtAuthGuard),
  );
}

 

common 패키지에서 제공하는 applyDecorators 함수를 사용하였습니다.
새로 만들어준 "권한관련커스텀데코레터데코레이터는 특정 값을 아무 타입(any)이나 n개 받을 수 있습니다.
그리고 roles라는 이름으로 해당 값을 적용하면서 방금 만들었던 JwtAuthGuard 가드를 적용하는 것을 볼 수 있습니다.
이를 적용하여 사용하는 컨트롤러를 먼저 살펴봅니다.
* 수정하는 파일 : app.controller.ts

import { Request, Response } from 'express';
import { Controller, All, Res, Req, HttpStatus, UseGuards, Request as reqs  } from '@nestjs/common';

import { LocalAuthGuard } from './local-auth.guard';
import { JwtAuthGuard } from './jwt-auth.guard';
import { 권한관련커스텀데코레터 } from './new.커스텀데코레더';

@Controller()
export class AppController {

  //생략...

  @권한관련커스텀데코레터('super','normal')
  @All('superAndNormal')
  async 슈퍼와노말만(@reqs() req) {
    return req.user;
  }

  @권한관련커스텀데코레터('super')
  @All('onlySuper')
  async 오직슈퍼유저만(@reqs() req) {
    return req.user;
  }  
}

 

뭔지는 몰라도 느낌상 해당 요청에 대해서는 권한(roles)값을 검증 할 거 같습니다!
이렇게 적용된 데코레이터는 실제로 메타데이터로는 컨트롤러에 설정한 데코레이터의 파라미터값을, 동작하는 가드는 JWT가드(JwtAuthGuard)를 동작시키게 합니다.
간단하게 설명하면,
 - superAndNormal 요청은 JwtAuthGuard 가드에게 super와 normal이라는 값을 전달 해 줍니다.
 - onlySuper 요청은 JwtAuthGuard 가드에게 super라는 값을 전달 해 줍니다.

이제 위 내용을 적용하기 위해서 JWT 가드를 수정하여 봅니다.
* 수정하는 파일이름 : jwt-auth.guard.ts

import { Request } from 'express';
import { ExecutionContext, Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
import { Reflector } from '@nestjs/core';

@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {

    constructor(private reflector: Reflector){
        super();
    }
    /*
    handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
        const request = context.switchToHttp().getRequest<Request>();

        //권한따른 페이지 체킹방식1
        if(request.url.indexOf("superOrSemi") > -1){  //요청 주소
            let roles : Array<any> = user.role;  //user 값은 JwtStrategy 클래스에서 반환한 값 입니다.
            let len = roles.filter(arg=> arg==='super' || arg==='semiSuper') ;  //내용 : "사용자 권한이 xx이라면~"
            if(len) throw new UnauthorizedException();
        }
        return user;
    }*/    

    handleRequest<TUser = any>(err: any, user: any, info: any, context: ExecutionContext, status?: any): TUser {
        const request : Request = context.switchToHttp().getRequest<Request>();

        //커스텀 데코레이터에게 넘어온 페이지 권한 입니다.
        //"커스텀데코레이터" 에서 roles라는 키 값으로 설정했습니다!
        const requiredRoles = this.reflector.getAllAndOverride('roles',[context.getHandler(), context.getClass()]);  
        console.log(requiredRoles);  

        let roles : Array<any> = user?.role;  //요건 발급받은 JWT토큰값에서의 사용자 권한입니다
                                              //서비스 클래스보면 role 이라는 배열값이 있습니다. ^^
        
        if(!roles) throw new UnauthorizedException();  //권한값이 있는지 확인 합니다.

        let amIOK = false;
        roles.forEach(b => {
            let check = requiredRoles.find( a=> a === b);  //해당 권한이 해당 페이지에 접근 가능하다면 
            if(check) amIOK = true;
        });

        if(!amIOK) throw new UnauthorizedException();  //접근 불가능한 사용자가 온 경우라면

        return user;
    }
}

 

이렇게 커스텀 데코레이터 방식으로 코드를 변경 하였다면 앞으로 이제 컨트롤러에는 커스텀데코레이터를 사용하여 권한값만 넣어주면 페이지별 접근제어가 가능 해 집니다.
권한별 코드검증 기능은 casl 라는 모듈(라이브러리)을 사용하여 좀더 멋지게 꾸밀수 있다고는 합니다만..ㅎ

여기까지가 로그인과 관련된 모듈(프레임워크, 라이브러리)에 대한 소개였습니다.
공식 사이트를 통해서 좀더 좋은 내용을 살펴볼 수 있습니다.
https://docs.nestjs.com/security/authentication

 

Documentation | NestJS - A progressive Node.js framework

Nest is a framework for building efficient, scalable Node.js server-side applications. It uses progressive JavaScript, is built with TypeScript and combines elements of OOP (Object Oriented Progamming), FP (Functional Programming), and FRP (Functional Reac

docs.nestjs.com

위 내용에 사용한 코드는 아래 제 깃허브에서 받아볼 수 있습니다.
https://github.com/TaeSeungRyu/NestProject/tree/main/step8

 

GitHub - TaeSeungRyu/NestProject: Nestjs 프로젝트

Nestjs 프로젝트. Contribute to TaeSeungRyu/NestProject development by creating an account on GitHub.

github.com

 

공부하면 할 수록 재미있고 즐거운 nestjs!!!
다음 포스팅에서는 보안과 관련된 설정에 대해서 살펴보겠습니다.
궁금한점 또는 틀린부분은 언제든 연락 주세요!

 

반응형
* 위 에니메이션은 Html의 캔버스(canvas)기반으로 동작하는 기능 입니다. Html 캔버스 튜토리얼 도 한번 살펴보세요~ :)
* 직접 만든 Html 캔버스 애니메이션 도 한번 살펴보세요~ :)

댓글