(20)

[NestJS] JWT Token 인증 - (3) Refresh Token 사용

이전 글에서 이어집니다. [NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2) 서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Sessio mag1c.tistory.com Access Token의 한계 Access Token을 통해 웹사이트를 이용하다, 토큰이 만료되면 다시 재로그인을 해야만 하는 불편함을 겪게 된다. 또한 Access Token 만료 전 토큰을 탈취당하게 된다면 토큰이 만료될 때까지 속수무책이라는 보안상의 문제도 있다..

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태 mag1c.tistory.com NestJS에서 제공하는 공식 문서의 인증 관련 페이지를 참조했다. Documentation | NestJS - A progressive Node.js framework Nest is a framework for b..

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태성 ( Stateless ) 클라이언트의 상태 정보를 가지지 않는 mag1c.tistory.com 쿠키는 인증이 필요한 요청을 할 때마다 쿠키를 던져 요청하는 동작 구조를 가진다. 쿠키를 통한 인증 시 다음과 같은 단점들이 존재한다. 쿠키의 단점 1. 민감 정보들을 노출당하기 쉽고 조작당하기도 쉽다. 2. 웹 브라우저마다 지원 형태가 다르기 때문에 공유가 불가능하다. 3. 쿠키의 사이즈 제한(4KB)이 있어 충분한 데이터를 담을 수 없다. 4. 서버에 매..

[NestJS] typeorm의 transaction

서론 JWT를 구현하는 도중, 아래와 같은 에러가 발생했다. //회원가입 및 JWT TOKEN 발급 코드 async signup(signInDto: UserSigninDto) { const user = await this.userService.findById(signInDto.id); const exist = user != null; if (exist) { throw new BadRequestException('중복된 아이디 존재'); } else { const valiUser = new UserEntity(); const hashedPassword = this.hash(signInDto.pw); valiUser.id = signInDto.id; valiUser.pw = hashedPassword; co..

[NestJS] 예외처리, Exception Filter (HttpException, Error Handling]

서론 노드로 전향한지 만 1개월이 되었다. 현재 근무하고 있는 곳의 애플리케이션 코드를 보면, 따로 예외처리를 해주는 부분이 없어 에러 핸들링과 에러 로깅 작업을 커스텀으로 진행하려고 한다. 이를 위해 공식 문서를 활용해가며 학습할 필요를 느껴서 공식문서에 해당 내용을 확인하고 학습해보자. NestJS의 예외 처리 Nest에는 애플리케이션 내에서 처리되지 않은 모든 예외를 처리하는 내장 예외 레이어가 존재한다. 애플리케이션 코드에서 예외 처리가 되지 않으면 예외 레이어에서 예외를 처리한다. 기본적으로 이 작업은 HttpException유형과 하위 클래스의 예외를 처리하는 내장 전역 예외 필터에 의해 수행된다. 예외를 인식할 수 없는 경우 다음과 같은 500에러를 내보낸다. { "statusCode": 5..

[NestJS] Docker와 Git Actions, EC2를 활용한 CI/CD 환경 구축 - 도커라이징하여 EC2에 배포하기

서론 사내 서비스가 Git Actions, Docker, EC2, PM2를 활용해 CI/CD 및 파이프라인을 구축하여 사용하고 있었기에 학습이 필요하다.. 간단하게 따라해볼 무언가를 찾아서 따라해보면서 학습할 것이 필요했는데 초행이다보니 같은 환경에서 진행한 분을 찾기 어려워 외국 유튜브 영상이나 기타 해외 자료들을 활용하며 따라하며 공부했다. CI/CD란? Continuos Integration / Continuous Delivery의 약자로 단어 뜻대로 지속적 통합과 배포를 뜻한다. DockerFile 작성 Docker에 이미지를 빌드하고 컨테이너 생성을 하는 과정은 단순하고 Nodejs docs에서도 가이드라인을 제시해준다. https://nodejs.org/ko/docs/guides/nodejs-..

[Javascript] using : 자바스크립트의 새로운 변수 키워드

서론 즐겨보는 개발 관련 유튜브 중 하나인 노마드코더에서 흥미로운 영상을 게시했다. 기존 자바스크립트의 변수 const, var, let을 대체할 강력한 키워드인 using 이라고 소개했다. 역시 프로유튜버답게(??) 어그로를 잘 끄셔서 자연스레 정주행했다. 현재 자바에서 타입스크립트로 전향한 지금, 꽤나 중요한 이슈사항이 될 것 같아서 포스팅하여 정리해보려고 한다. https://www.youtube.com/watch?v=-NmwyJ5S-IY&t=151s 기존의 자바스크립트 변수 간단하게 기존 변수들에 대해 알아보자. es6에서 포함된 기능중 하나는, 변수 선언에 사용할 수 있는 키워드인 let, const의 추가였다. 혹여 단순 using에 대한 정보만을 얻고자한다면, 스크롤을 많이 내려야 할 것 같..

[NestJS] JWT Guard 사용 중 에러 (metatype is not a constructor / In order to use "defaultStrategy", please, ensure to import PassportModule in each place where AuthGuard() is being used, Otherwise, passport won't work correctly.)

에러 메세지 JWT Guard설정 후 테스트를 위해 서버를 실행했을 때 발생 원인 그대로 번역하면 메타타입은 생성자가 아니라는 것 같음. metatype is not a constructor에러는 커스텀 가드를 사용할 때 발생할 수 있는 일반적 오류중 하나이며 @UseGuards() 데코레이터에 가드 클래스를 전달할 때 메타타입 정보가 올바르게 전달되지 않을 때 발생한다고 한다. 즉, 인스턴스를 전달하는 것이 아니라 생성자 함수를 전달해야한다. //before @UseGuards(AuthGuard) //after @UseGuards(AuthGuard()) 문제가 된 부분을 수정해주었음. 새로운 에러 In order to use "defaultStrategy", please, ensure to import..

[NestJS] NestJS 개발 환경 셋팅

서론 9개월 정도를 스프링만 사용하던 신입 개발자인 나에게 갑작스레 실무에서 Node.js를 사용해야하는 상황이 닥쳤다. 기존 backend 코드를 보니 모듈을 import하는 것들이 대부분 @nestJS/어쩌고로 되어있는 typescript였다. 실무를 위해 빠른 적응이 필요했다. 나중에 node와 typescript, nest등에 대한 적당히 자세한(?) 공부 및 포스팅을 진행할 것이다. 간단하게 알아본 바로는 다음과 같았다. NestJS는 서버 측 노드 애플리케이션을 구축하기 위한 프레임워크로 Express와 같은 HTTP Server Framework를 내장하고 있고, 대부분 TypeScript로 구성되어 있다고한다. 이미 셋팅되어있는 개발환경을 다시 뜯어보기전에, 어떻게 개발환경을 셋팅하는지 부..

[NestJS] JWT Token 인증 - (3) Refresh Token 사용

Tech/NodeJS 2023. 11. 25. 19:15
728x90
728x90

 

 

이전 글에서 이어집니다.

 

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

서론 지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다. [NestJS] JWT Token 인증 - (1) JWT 토큰이란? [NestJS] JWT Token 인증 - (1) JWT 토큰이란? 쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Sessio

mag1c.tistory.com

 

 

Access Token의 한계

Access Token을 통해 웹사이트를 이용하다, 토큰이 만료되면 다시 재로그인을 해야만 하는 불편함을 겪게 된다.

또한 Access Token 만료 전 토큰을 탈취당하게 된다면 토큰이 만료될 때까지 속수무책이라는 보안상의 문제도 있다.

 

이러한 문제들 때문에, 보통 Acces Token의 유효 시간을 짧게 해두고, Refresh Token의 유효기간을 길게 설정하여

Refresh Token이 만료되기 전에는, Access Token을 갱신하여 사용할 수 있다.

 

출처 : 인파(inpa)

 

정리
1. Access Token의 유효 기간을 짧게 설정, Refresh Token의 유효 기간은 길게 설정
2. 두 토큰 모두 서버에 전송하여 Access Token으로 인증하고, 만료 시 Refresh Token으로 재발급한다.
3. Access Token을 탈취당해도, 짧은 유효 기간이 지나면 사용할 수 없다.

 

 

하지만 만약 Refresh Token이 탈취당한다면..?

 

Is a Refresh Token really necessary when using JWT token authentication?

I'm referencing another SO post that discusses using refresh tokens with JWT. JWT (JSON Web Token) automatic prolongation of expiration I have an application with a very common architecture where my

stackoverflow.com

위 스택오버플로우의 글에서 제안하는 방법은 다음과 같다.

1. 데이터베이스에 Access Token, Refresh Token 쌍을 저장(1:1 매핑)

2. 공격자는 탈취한 Refresh Token으로 새 Access Token을 생성하여 서버에 전송하게 된다. 이 때, 서버에서 데이터베이스에 저장된 Access Token과 공격자에게 받은 Access Token이 다른 것을 확인하게 된다. 이 때, 저장된 토큰이 만료되지 않았을 경우 탈취당한 것으로 간주하고 두 토큰을 모두 만료시킨다.

3. 재로그인을 통해 인증을 다시 수행해야하지만, 공격자의 토큰 역시 만료됐기 때문에 공격자는 접근이 불가능해진다.

 


토큰은 클라이언트나 서버 전역에서 만료시킬 수 있는 개체가 아니기 때문에, 토큰의 유효 기간이 지나기 전까지는 데이터베이스에 저장하여 관리할 필요가 있다.

또한 Refresh Token도 Access Token과 같은 유효기간을 가지게 해서, 사용자가 한 번 Refresh Token으로 Access Token을 재발급을 받았을 때, Refresh Token도 재발급 할 수 있도록 권장하고 있다고 한다. (ietf문서 링크)

 

 

 

 

Refresh Token 이용하기(Refresh Strategy)

import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Request } from 'express';
import { UserService } from 'src/user/user.service';

@Injectable()
export class RefreshStrategy extends PassportStrategy(Strategy, 'refresh') {
  constructor(
    private readonly configService: ConfigService,
    private readonly userService: UserService
  ) {
    super({
      jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
        return request?.cookies?.refreshToken
      }]),
      ignoreExpiration: false,
      passReqToCallback: true,
      algorithms: ['HS256'],
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(request: Request, payload: any) {    
    const refreshToken = request.cookies['refreshToken'];
    return this.userService.refreshTokenMatches(refreshToken, payload.no);
  }
}
//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromExtractors([(request: Request) => {
    return request?.cookies?.refreshToken
}]),

//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,

//validate함수의 첫 번째 인자로 request 객체 전달 여부를 결정.
passReqToCallback: true,

//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],

//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),

 

 

 

validate의 refreshTokenMatches를 통해, 해당 user의 row에 있는 refresh token이 request의 refresh token과 일치한지 여부를 확인한다.

//JWT 관련 로직
async refreshTokenMatches(refreshToken: string, no: number): Promise<UserEntity> {
    const user = await this.findByNo(no);

    const isMatches = this.isMatch(refreshToken, user.refresh_token);
    if (isMatches) return user;
}

 

 

검증에 성공하면, 다시 accessToken을 반환하여, 클라이언트에서 이를 보관처리하고 다시 인증에 유효하게 사용할 수 있을 것이다.

@Post('/refresh')
@UseGuards(RefreshAuthGuard)
@ApiOperation({ description: '토큰 재발급' })
async refreshToken(@Request() req) {        
    const accessToken = await this.authService.createAccessToken(req.user);
    const user = new UserEntityBuilder()
        .withNo(req.user.no)
        .withId(req.user.id)        
    return { accessToken, user };
}

 

 

 

 

 

참조

https://docs.nestjs.com/security/authentication

https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps-00#section-8

https://stackoverflow.com/questions/32060478/is-a-refresh-token-really-necessary-when-using-jwt-token-authentication

https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Access-Token-Refresh-Token-%EC%9B%90%EB%A6%AC-feat-JWT

https://velog.io/@park2348190/JWT%EC%97%90%EC%84%9C-Refresh-Token%EC%9D%80-%EC%99%9C-%ED%95%84%EC%9A%94%ED%95%9C%EA%B0%80

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Token 인증(Access Token) 구현하기(with passport) - (2)

Tech/NodeJS 2023. 11. 18. 18:55
728x90
728x90

 

 

 

서론

지난글에 이어, Nest에서 JWT를 사용한 인증을 진행해보려한다.

 

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

 

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

쿠키와 세션을 통한 인증 쿠키와 세션 (Cookie & Session) HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태

mag1c.tistory.com

 

 

 

NestJS에서 제공하는 공식 문서의 인증 관련 페이지를 참조했다.

 

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 Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

설치한 모듈
passport (@nestjs/passport, passport, passport-local, @types/passport-local)
jwt (@nestjs/jwt, passport-jwt, @types/passport-jwt)

 

 

 

Passport

Node에서 인증을 적용할 때 사용할 수 있는 미들웨어로, 클라이언트가 서버에 권한을 요청할 자격이 있는지 인증할 때 passport 미들웨어를 사용한다. Nest에서는 토큰 인증에 있어서, passport의 사용을 권장하고 있다.

 

간단히 살펴보면, 유저의 자격 증명을 통해 사용자를 인증하며, 인증된 상태관리를 JWT와 같은 토큰을 통해 수행하며, Request에서 추가로 사용할 수 있게 사용자의 추가 정보를 첨부할 수 있다고 되어있다.

 

패키지 설치 시 @nestjs/passport 패키지를 반드시 요한다고 공식문서에 나타나 있다.

 

Nest에서는, PassportStrategy 클래스를 확장하여 구성하며, 하위 클래스에서 super() 메서드를 통해 파라미터를 전달하며 하위 클래스에서 validate() 메서드를 구현하여 인증에 대한 콜백을 수행한다고 명시되어 있다.

 

즉, 사용자가 존재하는지, 존재한다면 validate되는지를 따져 원하는 리턴값을 반환시킬 수 있고, 에러도 만들어 낼 수 있다.

 

Passport Strategy

이제 이 미들웨어의 전략을 구상(??)해야하는데, 기본적으로 공식 문서나 기타 개발 블로그 등 자료들에서 볼 수 있듯 기본 전략에서 내가 사용하는 서비스에 맞게 변경해주었다.

 

또한 들어가기에 앞서, @UseGuards 데코레이터를 사용하여 어떤 전략을 사용할 지 명시해주면 @nestjs/passport가 자동으로 명시된 전략을 호출한다.

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

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

 

예를들어, 위 AuthGuard는 local이라는 파라미터를 Passport에게 전달하여, 해당 Guard사용 시 LocalStrategy를 사용하게 한다.

 

 

 

 

그리하여 세가지 strategy를 만들 수 있었다.

Strategy

1. 로그인 시 사용하는 LocalStrategy
2. 로그인 후 인증 전반을 담당하는 JwtStrategy
3. Access Token 만료 시 Refresh Token을 검증하는 RefreshStrategy

 

 

로그인 (LocalStrategy)

로그인 시 수행하는 전략은 id와 pw를 가지고, 흔히 구현된 로그인 로직으로 보내어 유저 정보가 일치하는지 validate하는 전략이다.

import { Strategy } from 'passport-local';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, UnauthorizedException } from '@nestjs/common';
import { AuthService } from '../auth.service';
import { UserService } from 'src/user/user.service';

@Injectable()
export class LocalStrategy extends PassportStrategy(Strategy) {
  constructor(private authService: AuthService, private userService: UserService) {
    super({ usernameField: 'id', passwordField: 'pw' });
  }

  async validate(id: string, password: string): Promise<any> {
    const user = await this.authService.validateUser(id, password);
    if (!user) {
      throw new UnauthorizedException('아이디 혹은 비밀번호를 확인해주세요.');
    }
    return user;
  }
}

 

 

유저 검증을 위한 서비스의 validateUser 코드이며, 유저의 비밀번호는 암호화되어 있기 때문에 복호화 메서드를 통해 검증을 추가로 진행하고, 유저 정보를 리턴할 것이기 때문에 유저의 비밀번호는 삭제해주었다.

 

유저 객체 자체를 클라이언트로 반환하는 것은 아닌데, 굳이 비밀번호 삭제가 필요한 지는 의문이긴 하다. 토큰 생성 시에도 user의 PK와 id만 담긴다. 확실한 지식이 없어 혹여 불필요한 비밀번호가 유출 될까 삭제하긴 했는데.. 이 부분을 좀 더 알아봐야겠다. (조언좀 부탁드립니다!!)

 

//validate for stragety
async validateUser(id: string, password: string): Promise<any> {
    const user = await this.userService.findById(id);
    if (!user) {
        throw new UnauthorizedException('가입된 유저가 아닙니다.');
    }
    if (user && (await this.isMatch(password, user.pw))) {
        delete user.pw;
        return user;
    } else {
        throw new BadRequestException('패스워드를 확인해주세요');
    }
}

 

 

 

LocalStrategy에서, 올바르게 유저를 검증하는데 성공했다면, user객체를 반환하게 코드를 만들어놓았기 때문에, 로그인 유저 검증에 성공한다면 Request객체에 user가 담기게되고, 우리는 이를 이용해서 로그인 처리(토큰 생성)를 수행할 수 있게 된다.

@Post('/signin')
@UseGuards(LocalAuthGuard)
@ApiOperation({ description: '로그인' })
@ApiResponse({ status: 200, description: 'JWT_TOKEN을 발급합니다.', type: JwtTokenDto })
signin(@Request() req) {
    return this.authService.signin(req.user);
}

 

async signin(user: UserEntity) {
    //1. user validate with hashed pw        
    if (this.validateUser) {
        user.last_join_date = new Date();
        //1-1. 유저의 마지막 로그인 시점 업데이트
        const signinDateUpdate = this.userService.updateSignin(user);

        //1-2. when validated >> token created
        if (signinDateUpdate) return this.createToken(user);
    }        
}

//access token 발급
createToken(user: UserEntity) {
    const no = user.no;
    const tokenDto = new JwtTokenDto(this.createAccessToken(user), this.createRefreshToken(no));
    this.setCurrentRefreshToken(tokenDto.refresh_token, no);
    return tokenDto;
}

createAccessToken(user: UserEntity) {
    const cu = { no: user.no, id: user.id };
    return this.jwtService.sign(cu, {
        secret: this.configService.get('JWT_SECRET'),
        expiresIn: this.configService.get('expiresIn')
    });
}

createRefreshToken(no: number) {
    return this.jwtService.sign({ no }, {
        secret: this.configService.get('JWT_SECERT'),
        expiresIn: this.configService.get('expiresInRefresh')
    });
}

 

 

토큰 생성시, payload에는 민감정보를 전달하면 안되기 때문에, pk와 id만 담았다. (pk는 좀 애매할 수 있을것 같다.)

생성 시 서명(signature)으로 설정해둔 시크릿 키와, 유효기간을 담았다.

createAccessToken(user: UserEntity) {
    const cu = { no: user.no, id: user.id };
    return this.jwtService.sign(cu, {
        secret: this.configService.get('JWT_SECRET'),
        expiresIn: this.configService.get('expiresIn')
    });
}

createRefreshToken(no: number) {
    return this.jwtService.sign({ no }, {
        secret: this.configService.get('JWT_SECERT'),
        expiresIn: this.configService.get('expiresInRefresh')
    });
}

 

 

정상적으로 로그인이 되면 아래처럼, access_token과 refresh_token이 생성되고 리턴된다.

 

 

 

토큰 검증(JwtStrategy)

토큰 검증을 수행하기로 한 Strategy로, PassportStrategy에 JWT 전략에 필요한 옵션들을 보낸다.

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

@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
  constructor(configService: ConfigService) {
    super({
      jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
      ignoreExpiration: false,
      algorithms: ['HS256'],
      secretOrKey: configService.get('JWT_SECRET'),
    });
  }

  async validate(payload: any) {
    return { ...payload };
  }
}

 

//JWT의 추출방법을 지정한다.
//fromAuthHeaderAsBearerToken()을 사용하여 헤더에서
//Bearer Token을 Authorization 헤더에서 추출한다.
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),

//토큰 만료 기간을 무시할지? false는 만료된 토큰 자체를 거부한다.
ignoreExpiration: false,

//서명에 사용할 알고리즘을 지정한다.
algorithms: ['HS256'],

//서명에 사용되는 비밀키로 환경변수에서 가져왔다.
secretOrKey: configService.get('JWT_SECRET'),

 

 

이제, 헤더에서 토큰을 추출하여 토큰이 있을 경우, 디코딩된 payload를 validate로 가져올 수 있다.

여기서는, 유저의 PK와 ID가 담겨있고, iat와 exp가 추가로 담겨있다 (iat : 발급시간, exp : 만료시간)

 

 

마찬가지로, @UseGuard를 이용해서 사용할 수 있으며, 컨트롤러 전역에 토큰 인증이 필요할 경우, 전역으로도 사용할 수 있다. 아래는 토이 프로젝트의 유저 컨트롤러 일부이다.

import { Controller, Get, Post, Body, Patch, Param, Delete, Query, Req, UseGuards, Request, Put } from '@nestjs/common';
import { ApiBody, ApiOperation, ApiTags } from '@nestjs/swagger';
import { UserService } from './user.service';
import { Jwt } from 'src/decorator/CurrentUserDecorator';
import CurrentUser from 'src/auth/dto/currentUser.dto';
import { JwtAuthGuard } from 'src/auth/guard/jwt-auth.guard';
import UserInvestmentDataPutDto from './dto/user-investmentData.dto';

@ApiTags('user')
@Controller('/api/v1/user')
@UseGuards(JwtAuthGuard)
export class UserController {
  constructor(private userService: UserService) {}

  @Post('/check')
  @ApiOperation({ description: '토큰 검증' })
  async checkToken(@Request() req) {
    return req.user;
  }

  @Get('/myInvestmentData')
  @ApiOperation({ description: '투자내역 조회' })
  async getInvestmentDataByYyyyMm(@Jwt() cu: CurrentUser, @Query('yyyymm') yyyymm: string) {
     return await this.userService.getInvestmentDataByYyyyMm(cu.no, yyyymm);
  }

  @Put('/myInvestmentData')
  @ApiOperation({ description: '투자내역 입력(by day) '})
  async putInvestmentData(@Jwt() cu: CurrentUser, @Body() userInvDataPutDto: UserInvestmentDataPutDto) {
    return await this.userService.putInvestmentData(cu.no, userInvDataPutDto);
  }

  @Delete('/myInvestmentData/:yyyymm/:day')
  @ApiOperation({ description: '투자내역 초기화(day)' })
  async deleteInvestmentData(@Jwt() cu: CurrentUser, @Param() params) {
    const { yyyymm, day } = params;
    return await this.userService.deleteInvestmentData(cu.no, yyyymm, day)
  }
}

 

 

 

마무리

Refresh Token관련 내용은 다음 포스팅에서..

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Token 인증 - (1) JWT 토큰이란?

Tech/NodeJS 2023. 11. 15. 14:54
728x90
728x90

 

 

 

쿠키와 세션을 통한 인증

 

쿠키와 세션 (Cookie & Session)

HTTP 프로토콜의 특징 비연결성 ( Connectionless ) 클라이언트가 서버에 요청(Request)할 때, 그에 대한 응답(Response)을 한 후, 연결을 끊는다. 비상태성 ( Stateless ) 클라이언트의 상태 정보를 가지지 않는

mag1c.tistory.com

 

쿠키는 인증이 필요한 요청을 할 때마다 쿠키를 던져 요청하는 동작 구조를 가진다.

 

쿠키를 통한 인증 시 다음과 같은 단점들이 존재한다.

쿠키의 단점

1. 민감 정보들을 노출당하기 쉽고 조작당하기도 쉽다.
2. 웹 브라우저마다 지원 형태가 다르기 때문에 공유가 불가능하다.
3. 쿠키의 사이즈 제한(4KB)이 있어 충분한 데이터를 담을 수 없다.
4. 서버에 매번 쿠키 값을 넘겨 인증해야하며, 조작된 쿠키가 넘어오더라도 방지할 수 없다.

 

 

 

세션은 개인 민감 정보를 노출하는 쿠키의 단점을 막을 수 있다.

세션은 인증 정보 자체를 특정 세션에 저장하고, 쿠키에 담아 클라이언트가 쿠키를 요청할 때 마다 세션에 저장되어 있는 정보와 동일한 지 인증한다.

 

아래의 코드는 개발자 첫 입문 프로젝트 시 사용했던 스프링 로그인 관련 코드이다.

로그인 시 인증 객체를 통해 세션 ID를 만들어 user_email, nickname세션에 저장하고, 세션 ID를 쿠키에 담아 반환할 것이다.

@RequestMapping("signin_check")
public ModelAndView signin_check(@RequestParam String email, String password, UserDto userDto,
                    HttpSession session, ModelAndView mv) {
    String str = userService.login(userDto);
    if(str != null) {
        session.setAttribute("user_email", userDto.getEmail());
        session.setAttribute("nickname", str);
        session.setMaxInactiveInterval(60*30);
        mv.setViewName("redirect:/");
    }else {
        mv.setViewName("user/signin");
    }
    return mv;
}

//로그아웃
@RequestMapping("signout")
public String logout(HttpSession session) {
    session.invalidate();		
    return "redirect:/";
}

 

 

세션에 식별할 수 있는 인증 값을 넣어두고, 비교할 수 있기 때문에 보완이 쿠키에 비해서 좋다. 만약 보안 문제가 발생할 경우, 서버의 세션을 지워버리면 된다. 하지만 반대로 이야기하면, 세션의 장애가 발생할 경우, 인증 전반에 문제가 생겨 사용자의 인증이 불가능한 상황을 야기시킨다.

 

추가적으로 세션의 단점을 정리해보자면 다음과 같다.

세션의 단점

1. 위에서 언급했듯이, 세션 자체에 문제가 발생하면, 인증 체계가 무너진다.
2. stateful하기 때문에 http의 stateless에 위배된다.
   > (scale out 시 걸림돌이 될 수 있다. 세션 ID를 또 다른 서버에 저장해야함)
3. 인증 요청 시 매번 세션 저장소를 조회해야한다.
4. 세션 저장소를 사용하는 비용. 사용자가 증가할수록 메모리 사용량도 증가.

 

 

 

JWT

JWT(Json Web Token)은 인터넷 표준 인증 방식으로, Json 객체에 인증이 필요한 정보들을 담은 후 비밀키로 서명한 토큰이다. 말 그대로 인증에 필요한 정보들을 Token에 담아 암호화시켜 사용하는 토큰이다.

 

인증 구조 자체는 쿠키와 크게 다르지 않지만, 서명된 토큰이라는 점이 주요 차이점이다. public/private key를 쌍으로 사용하여 토큰에 서명할 경우, 서명된 토큰은 개인 키를 보유한 서버가 이 서명된 토큰이 정상적인 토큰인지 인증할 수 있다.

 

 

JWT의 동작 과정

 

동작 과정은 위와 같다.

사용자가 서버에 로그인 요청을 보내면, 서버는 secret key를 사용해 암호화한 JWT 토큰을 발급하여 내보낸다.

이렇게 받은 토큰을 클라이언트의 로컬에 저장해두고, 인증이 필요한 요청 시 토큰을 헤더에 실어서 보내 토큰 검증 과정을 거친다.

 

 

 

JWT의 구조

JWT는 Header, Payload, Signature의 세 구조로 되어있다.

 

 

 

Header

Header는 토큰의 타입, 서명 생성에 어떤 알고리즘이 사용되었는지를 저장한다.

 

 

 

 

Payload

Payload에는 Claim을 Key-Value 형태로 저장한다.

 

여기서 Claim이란 사용자 혹은 토큰에 대한 프로퍼티이며, 토큰에서 사용할 정보의 조각을 의미한다.

여기에는, 커스텀하여 값들을 넣을 수 있지만,  민감한 정보를 payload에 담지 않는 것이 중요하다. header와 payload는 json이 디코딩되어 있을 뿐, 특별한 암호화가 걸려있지 않기 때문에 누구나 값을 알 수 있기 때문이다.

JWT 표준 Claim

1. iss(issuer) - 토큰 발급자
2. sub(subject) - 토큰 제목(사용자에 대한 식별 값)
3. aud(audience) - 토큰 대상자
4. exp(expiration time) - 토큰 만료 시간
5. nbf(not before) - 토큰 활성 날짜
6. iat(issued at) - 토큰 발급 시간
7. jti(jwt id) - 토큰 식별자 (issuer가 여러 명일 때를 위한 구분 값)

 

 

 

 

Signature

헤더와 페이로드의 문자열을 합친 후, 헤더에서 선언한 알고리즘과 key를 통해 암호화한 값이다.

 

위에서 언급했듯, Header와 Payload는 단순히 Base64로 인코딩되어있어 누구나 쉽게 복호화할 수 있지만, Signature는 암호화 시 사용된 key가 없으면 복호화 할 수 없다. 이를 통해 JWT는 보안상 안전하다는 특성을 가질 수 있게 된 것이다.

 

 

 

정리

장점

1. 로컬에 저장하기 때문에 서버 스펙에 영향을 받지 않고, 네트워크 부하가 적다 (http 헤더를 통한 전송)
2. 공통 스펙으로 사용 가능한 확장성이 뛰어난 토큰
3. 여러 플랫폼 및 기기에서 동작 가능하며 서로 다른 도메인에서도 통신이 가능
4. 별도의 세션 저장소를 강제하지 않아 stateless하다.

 

단점

1. 토큰 만료 처리를 구현해야한다.
2. 토큰의 크기를 신경써야한다. (트래픽에 영향)

 

 

JWT Token을 활용해서, 개발하는 서비스의 성격에 맞게 보안과 사용자의 편의를 고려하여 조율하면서 좋은 인증 정책을 결정할 수 있도록 해야겠다.

 

 

 

 

참조

https://jwt.io/introduction

https://brunch.co.kr/@jinyoungchoi95/1

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] typeorm의 transaction

Tech/NodeJS 2023. 11. 9. 09:50
728x90
728x90

 

 

서론

JWT를 구현하는 도중, 아래와 같은 에러가 발생했다.

//회원가입 및 JWT TOKEN 발급 코드
async signup(signInDto: UserSigninDto) {
    const user = await this.userService.findById(signInDto.id);
    const exist = user != null;
    if (exist) {
        throw new BadRequestException('중복된 아이디 존재');
    }
    else {
        const valiUser = new UserEntity();
        const hashedPassword = this.hash(signInDto.pw);
        valiUser.id = signInDto.id;
        valiUser.pw = hashedPassword;
        const savedUser = await this.userService.saveUser(valiUser);
        return this.removePasswordFromUserData(savedUser);
    }
}

 

회원가입과 동시에 JWT Token을 발급하는 그런 로직을 짜고 있었는데, DB에는 들어왔지만, 에러는 발생한 그런 상황이다. 토큰은 발급되지 않았다.

 

 

 

 

@Transactional

자바를, 스프링을 사용했을 당시 AOP를 활용한 선언적 트랜잭션인 @Transactional 애너테이션을 사용하곤 했었다.

//게시판의 게시글을 삭제하는 메서드 
@Transactional
public void removeBoard(Long id)throws Exception{
    replyDAO.removeAll(id); //삭제할 게시글의 답글 삭제
    boardDAO.deleteBoard(id); //게시글 삭제 
}

 

 

 

typeorm에서는?

데커레이터는 권장하지 않는다고 하여 getManager().transaction()과 queryRunner중 queryRunner을 사용했다.

async signup(signInDto: UserSigninDto) {
    //typeorm transaction start
    const queryRunner = this.datasource.createQueryRunner();
    await queryRunner.connect();
    await queryRunner.startTransaction();
    try {
        const user = await this.userService.findById(signInDto.id);
        const exist = user != null;
        if (exist) {
            throw new BadRequestException('중복된 아이디 존재');
        }
        else {
            const hashedPassword = this.hash(signInDto.pw);

            const valiUser = new UserEntity();                
            valiUser.id = signInDto.id;
            valiUser.pw = hashedPassword;

            const savedUser = await this.userService.saveUser(valiUser);                
            const delPwUserData = this.removePasswordFromUserData(savedUser);               

            const token = this.createToken(delPwUserData)                
            await queryRunner.commitTransaction();

            return token;
        }
    } catch (error) {
        await queryRunner.rollbackTransaction();
        console.error(error);
    } finally {
        await queryRunner.release();
    }

}

 

 

 

 

 

 

트랜잭션 다루기 (feat. NestJS, TypeORM)

Nest JS에서 트랜잭션을 올바르게 다루기 위해 겪었던 고민과 문제에 대한 해결방법을 정리한 글입니다

www.timegambit.com

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] 예외처리, Exception Filter (HttpException, Error Handling]

Tech/NodeJS 2023. 10. 9. 16:46
728x90
728x90

 

 

 

서론

노드로 전향한지 만 1개월이 되었다.

현재 근무하고 있는 곳의 애플리케이션 코드를 보면, 따로 예외처리를 해주는 부분이 없어 에러 핸들링과 에러 로깅 작업을 커스텀으로 진행하려고 한다. 이를 위해 공식 문서를 활용해가며 학습할 필요를 느껴서 공식문서에 해당 내용을 확인하고 학습해보자.

 

 

 

NestJS의 예외 처리

Nest에는 애플리케이션 내에서 처리되지 않은 모든 예외를 처리하는 내장 예외 레이어가 존재한다. 애플리케이션 코드에서 예외 처리가 되지 않으면 예외 레이어에서 예외를 처리한다.

 

기본적으로 이 작업은 HttpException유형과 하위 클래스의 예외를 처리하는 내장 전역 예외 필터에 의해 수행된다. 예외를 인식할 수 없는 경우 다음과 같은 500에러를 내보낸다.

{
  "statusCode": 500,
  "message": "Internal server error"
}

 

 

 

HttpException

Nest는 @nestjs/common의 HttpException을 제공하며 이를 통해 다음과 같이 403에러에 대한 예외처리를 할 수 있다.

@Get()
async findAll() {
  throw new HttpException('Forbidden', HttpStatus.FORBIDDEN);
}
{
  "statusCode": 403,
  "message": "Forbidden"
}

 

기본 형태의 error response JSON을 재정의할 수 있다.

단 statusCode는 유효한 상태 코드여야한다.

@Get()
async findAll() {
  try {
    await this.service.findAll()
  } catch (error) { 
    throw new HttpException({
      status: HttpStatus.FORBIDDEN,
      error: 'This is a custom message',
    }, HttpStatus.FORBIDDEN, {
      cause: error
    });
  }
}
{
  "status": 403,
  "error": "This is a custom message"
}

 

@nestjs/common의 HttpStatus Enum을 사용하면 간편하게 상태코드를 정의할 수 있다.

 

 

Custom Exception

커스텀 Exception을 생성해야 하는 경우, HttpException에서 상속되는 예외 계층을 만드는 것이 좋다. 아래의 예시처럼 사용한다면, Nest가 사용자의 커스텀 Exception을 인식하고 오류 응답을 자동으로 처리한다.

export class ForbiddenException extends HttpException {
  constructor() {
    super('Forbidden', HttpStatus.FORBIDDEN);
  }
}
@Get()
async findAll() {
  throw new ForbiddenException();
}

 

또한 아래와 같은 내장 HttpException을 제공한다.

 

 

Exception Filter

Nest는 앞에서 언급한 것 처럼, 예외 필터 레이어가 있어서 원하는 대로 예외를 다룰 수 있다.

로그를 남기거나, 에러 메세지를 커스터마이징 할 수 있다.

 

아래의 예시는, 상태 코드 뿐 아니라, 에러 발생 시각과 에러가 발생된 요청 URL을 반환한다.

import { ExceptionFilter, Catch, ArgumentsHost, HttpException } from '@nestjs/common';
import { Request, Response } from 'express';

@Catch(HttpException)
export class HttpExceptionFilter implements ExceptionFilter {
  catch(exception: HttpException, host: ArgumentsHost) {
    const ctx = host.switchToHttp();
    const response = ctx.getResponse<Response>();
    const request = ctx.getRequest<Request>();
    const status = exception.getStatus();

    response
      .status(status)
      .json({
        statusCode: status,
        timestamp: new Date().toISOString(),
        path: request.url,
      });
  }
}

 

@Catch(HttpExceptioin) 데코레이터는 필요한 메타데이터를 Exception Filter에 바인딩하여 Nest에게 이 특정 필터가 위의 내장 HttpException 유형의 예외를 찾고 있으며, 다른 예외는 없음을 알려준다. 또한 쉼표로 구분지어 여러 유형의 예외에 대한 필터를 한 번에 설정할 수 있다.

모든 Custom Exception Filter는 일반 ExceptionFilter interface를 구현해야 한다.
위 Custom Filter에 구현된 catch 메서드를 제공해야 한다는 뜻이다.

또한 @nestjs/platform-fastfy를 사용하는 경우, response.json() 대신 response.send()를 사용할 수 있다.

 

필터는, @UseFilters 데코레이터를 이용하여 적용시켜주면 되는데, 이 때 원하는 엔드포인트에 사용할 수 있다.

@Post()
@UseFilters(new HttpExceptionFilter())
async create(@Body() createCatDto: CreateCatDto) {
  throw new ForbiddenException();
}

@Controller('user')
@UseFilters(HttpExceptionFilter)
export class UserController

 

애플리케이션 전역에서 작용하는 Filter로 설정하기 위해서는 main.ts 파일 안에서 다음과 같이 useGlobalFilters() 메서드를 사용한다.

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalFilters(new HttpExceptionFilter());
  await app.listen(3000);
}
bootstrap();

 

전역 범위의 필터는 애플리케이션 전역에 대해 동작하기 때문에, 아래와 같이 모든 모듈에서 직접 전역 범위 필터를 등록할 수 있게 설정해주어 종속성 주입 문제를 해결해주어야 한다.

import { Module } from '@nestjs/common';
import { APP_FILTER } from '@nestjs/core';

@Module({
  providers: [
    {
      provide: APP_FILTER,
      useClass: HttpExceptionFilter,
    },
  ],
})
export class AppModule {}

 

 

 

 

사용해보기

사내 백엔드 환경과 동일하게 셋팅한 후, 가볍게 복리계산 로직을 만들고, React로 간단하게 View를 만들어서 확인해보았다.

 

전역에서 사용할 것이기 때문에 위 포스팅의 전역 설정을 모두 마친 뒤, 아래처럼 코드를 작성했다.

async calculate(price: number, percent: number, time: number, type: string) {
    if (time <= 0 || price <= 0) return null;

    let priceArr = [price];
    let data = new Array<CPResponse>();
    let totalRevenue = 0;
    for (let i = 1; i <= time; i++) {
      const beforePrice = +priceArr[i - 1];
      const interest = beforePrice * (+percent / 100);
      const currentPrice = Math.round(beforePrice + interest);
      const revenue = currentPrice - beforePrice;
      const totalPercent = ((currentPrice - price) / (price / 100)).toFixed(2);
      totalRevenue += revenue;

      if (revenue === Infinity || !Number(revenue) || currentPrice === Infinity) {
        throw new BadRequestException();
      }

      priceArr.push(currentPrice);
      const cpResponse = new CPResponse(i + type, revenue, currentPrice, totalPercent + '%');
      data.push(cpResponse);
    }

    return { data, totalRevenue };

 

아래 부분에서, 숫자가 기하급수적으로 커질 때 Infinity 값이 출력될 수 있으므로, 수의 범위를 벗어나면(?) BadRequest를 반환하도록 했다.

if (revenue === Infinity || !Number(revenue) || currentPrice === Infinity) {
	throw new BadRequestException();
}

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] Docker와 Git Actions, EC2를 활용한 CI/CD 환경 구축 - 도커라이징하여 EC2에 배포하기

Tech/NodeJS 2023. 9. 22. 14:43
728x90
728x90

 

 

서론

사내 서비스가 Git Actions, Docker, EC2, PM2를 활용해 CI/CD 및 파이프라인을 구축하여 사용하고 있었기에 학습이 필요하다..

 

간단하게 따라해볼 무언가를 찾아서 따라해보면서 학습할 것이 필요했는데

 

초행이다보니 같은 환경에서 진행한 분을 찾기 어려워 외국 유튜브 영상이나 기타 해외 자료들을 활용하며 따라하며 공부했다.

 

 

 

 

CI/CD란?

Continuos Integration / Continuous Delivery의 약자로 단어 뜻대로 지속적 통합과 배포를 뜻한다.

 

 

 

 

 

DockerFile 작성

Docker에 이미지를 빌드하고 컨테이너 생성을 하는 과정은 단순하고 Nodejs docs에서도 가이드라인을 제시해준다.

https://nodejs.org/ko/docs/guides/nodejs-docker-webapp

 

Node.js 웹 앱의 도커라이징 | Node.js

Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.

nodejs.org

하지만 netsjs dockerfile을 사용하고 싶어 검색하여 사용하였다..

https://www.tomray.dev/nestjs-docker-production

 

Ultimate Guide: NestJS Dockerfile For Production [2022]

Learn how to write a Dockerfile that creates a production optimized image using the NodeJS Alpine image and multistage builds.

www.tomray.dev

 

나는 예제에다 alpine을 추가하여 사용했는데, 알파인은 클라우드 환경을 고려한 가벼운 리눅스 이미지다.

 

실습을 진행할 것이기 때문에 알파인으로 사용하였다.

DockerFile

# Base image
FROM node:18-alpine

# Create app directory
WORKDIR /usr/src/app

# A wildcard is used to ensure both package.json AND package-lock.json are copied
COPY package*.json ./

# Install app dependencies
RUN npm install

# Bundle app source
COPY . .

# Creates a "dist" folder with the production build
RUN npm run build

# Start the server using the production build
CMD [ "node", "dist/main.js" ]

 

.dockerignore

Dockerfile
.dockerignore
node_modules
npm-debug.log
dist

 

 

CI를 위한 설정파일 생성

github actions은 CI를 역할을 수행하는 강력한 도구중 하나이다.

github actions을 사용하기 위해 깃헙 내 레포에서 Actions에서 워크플로우를 생성했다.

git-action.yml

name: Docker Image CI

on:
  push:
    branches: [ "main" ]

jobs:

  build:

    runs-on: ubuntu-latest

    steps:
    - uses: actions/checkout@v3
    - name: Login Dockerhub
      env:
        DOCKER_USERNAME: ${{secrets.DOCKERHUB_USERNAME}}
        DOCKER_PASSWORD: ${{secrets.DOCKERHUB_PASSWORD}}
      run: docker login -u $DOCKER_USERNAME -p $DOCKER_PASSWORD      

    - name: Build the Docker image
      run: docker build -t manaweb-api .
    - name: taging
      run: docker tag manaweb-api:latest mag123c/manaweb-api:latest
    - name: Push to Dockerhub
      run: docker push mag123c/manaweb-api:latest

 

1. 메인 브렌치에 push될 때만 동작

2. 현재 레포의 코드를 체크아웃

3. 환경변수를 통한 도커 로그인

4. 도커 이미지 빌드

5. 푸시

 

환경변수는 내 깃허브 레포 > Secrets > Secrets and variable에서 변수 설정을 할 수 있다.

 

또한 여기서 푸시와 빌드 사이에  이미지 태깅에 대한 코드를 추가하였는데, 아래 트러블슈팅 1에서 다루었듯이 빌드 후 바로 푸시를 하게 되면 올바른 태그네임을 찾을 수 없다는 에러를 뱉어냈다.

 

 

 

Github Actions 동작 확인하기

계속된 실패로 지쳐있다가 태깅을 해주자 성공적으로 작동하는 모습이다.

 

 

 

AWS EC2 생성 및 Github Runner(self-hosted) 생성

ubuntu의 프리티어를 사용했으며 간단하게 인바운드 설정만 마친 뒤 인스턴스를 생성했다.

생성에 대한 포스팅은 생략하도록 하며 접속이 완료된 후 아래 명령어를 실행하자.

# root 계정으로 전환 + 현재 환경변수 사용
sudo su

# 패키지 버전 최신화
sudo apt update
sudo apt-get upgrade -y

 

깃허브의 레포에서 Settings - Actions - runner - New self-hosted runner에서 셋팅에 관련된 가이드라인을 받아볼 수 있다.

 

self-hosted의 가이드라인을 따라할 뿐이지만, 코드의 대략적인 기능을 파악해보았다.

#Download
## 1. 디렉토리 생성 및 이동
mkdir actions-runner && cd actions-runner
## 2. actions-runner 설치
curl -o actions-runner-linux-x64-2.309.0.tar.gz -L https://github.com/actions/runner/releases/download/v2.309.0/actions-runner-linux-x64-2.309.0.tar.gz
## 3.설치한 파일의 SHA-256 해시를 계산하여 파일 체크
echo "2974243bab2a282349ac833475d241d5273605d3628f0685bd07fb5530f9bb1a  actions-runner-linux-x64-2.309.0.tar.gz" | shasum -a 256 -c
## 4.설치
tar xzf ./actions-runner-linux-x64-2.309.0.tar.gz

####################################################

#Configure
## 1. 내 레포와 연결. 액세스 권한이 있는 상태로 설정됨
./config.sh --url https://github.com/mag123c/manaweb-api --token A4Y6RDGK6BFAYHTP2KKLL2LFAXJFC
## 2. 러너 실행
./run.sh
### ./run.sh & : 백그라운드로 실행
### nohup ./run.sh : 세션이 종료되어도 runner 유지 (ssh 세션 끊어도 유지)

정상적으로 ubuntu ip의 Runners가 등록된 모습

 

마지막 명령어인 ./run.sh를 사용하여 실행시키면 아래와 같은 모습이 된다.

 

 

 

CD pipeline 생성

workflows에 CD를 위한 yml파일을 생성했다.

name: CD Pipeline

on:
  workflow_run:
    workflows: ["CI Pipeline"]
    types:
        - completed

jobs:

  build:

    runs-on: self-hosted

    steps:
    - name: Pull Docker image
      run: sudo docker pull mag123c/manaweb-api:latest
    - name: Delete Old docker container
      run: sudo docker rm -f manaweb-api-container || true
    - name: Run Docker Container
      run: sudo docker run -d -p 8080:8080 --name manaweb-api-container mag123c/manaweb-api

 

1. github actions에서 CI pipline이 동작이 완료되었을 때 CD 파이프라인이 실행되게 이름 및 타입 설정.

on:
  workflow_run:
    workflows: ["CI Pipeline"]
    types:
        - completed

 

 

2. runs-on은 작업이 실행될 환경을 지정한다. 깃헙 가이드라인에 따르면, yaml 파일에 self-hosted를 붙이라고 되어 있다.

 

3. 이미지를 다운로드(Pull)하고, 이전 컨테이너를 삭제한 다음 새로운 컨테이너를 실행한다.

 

CD 파이프라인은 CI 파이프라인이 성공적으로 동작이 완료되면 Docker 이미지를 다운로드하고 이를 사용하여 새로운 컨테이너를 실행하여 배포하는 역할을 한다. 이전 버전의 컨테이너는 삭제되고 새로운 버전이 설정한 포트를 통해 실행될 것이다.

 

 

 

EC2에 Docker 설치

https://docs.docker.com/engine/install/ubuntu/

 

Install Docker Engine on Ubuntu

Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install.

docs.docker.com

 

위 공식문서를 활용하여 설치를 진행해보자.

# Docker 공식 GPG Key 추가
## 1. 패키지 목록 업데이트
sudo apt-get update
## 2. 필요한 패키지 설치(인증, 통신)
sudo apt-get install ca-certificates curl gnupg
## 3. Docker 패키지 관리에 사용할 dir 생성
sudo install -m 0755 -d /etc/apt/keyrings
## 4. GPG Key 다운로드 + 파일 경로 및 파일명 지정
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg

# Docker Repo를 Apt 소스에 추가
## 1. Apt 저장소를 /etc/apt/sources.list.d/docker.list 파일에 추가
## 현재 ubuntu 버전의 코드명을 가져와서 사용
echo \
  "deb [arch="$(dpkg --print-architecture)" signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
  "$(. /etc/os-release && echo "$VERSION_CODENAME")" stable" | \
  sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
## 2. Docker 패키지 사용을 위한 업데이트
sudo apt-get update
## 3. Docker 관련 패키지 설치 (도커엔진, 플러그인, compose 등)
sudo apt-get install docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin

 

설치가 완료되었으면 확인해보자.

# 현재 실행중인 Docker Container 목록 표시
sudo docker ps

정상적으로 찍히는 모습이다. (아직 실행중인 컨테이너가 없기 때문에 아무 값도 없다)

이후 docker login 명령어를 통해 도커에 로그인을 한다.

 

 

 

EC2에서 컨테이너 확인

Git Actions을 통해 CI/CD가 완료되고 나면 EC2에 정상적으로 컨테이너가 실행이 되어야 한다.

# 실행중인 docker container 확인
docker ps

하지만 명령어를 입력해도 실행되는것이 없어 5~6시간을 정상적으로 EC2에 배포가 되지 않은 것으로 착각했다.

 

# 실행중이거나 종료된 모든 컨테이너 확인
docker ps -a

모든 컨테이너를 확인해보니 아래처럼 컨테이너를 실행하자 마자 종료가 되는 모습이다.

해결한 과정은 아래 트러블슈팅의 2번 항목에서 확인할 수 있다.

 

 

5~6시간의 삽질 끝에 에러를 해결하고 나니 정상적으로 컨테이너가 실행되었다

 

curl 명령어를 통해 확인해보면 잘 동작한다.

또한 ec2의 public ip:port로 URL을 입력해도 잘 동작하는 모습.

 

 

이제 배포된 프로젝트의 API를 프론트에서 호출하여 사용하기만하면 끝!

 

 

 

 

 

 

트러블 슈팅

1. An image does not exist locally with the tag: [repo]/[image]

https://mag1c.tistory.com/466

 

[Git Actions / Docker] An image does not exist locally with the tag: [repo]/[image]

에러 메세지 An image does not exist locally with the tag: [repo]/[image] 아래와 같은 action.yml을 사용하고있었는데 계속해서 리파지토리에 이미지를 빌드한 후 태그를 못잡아 주는 것 같았다. name: Docker Image CI

mag1c.tistory.com

 

 

2. Docker Container 실행 후 바로 종료되는 현상

https://mag1c.tistory.com/469

 

 

 

 

참조

1. AWS EC2에 도커 설치하려다 에러 발생했을 때

https://boying-blog.tistory.com/82

 

ubuntu docker 설치시 Package 'docker-ce' has no installation candidate 해결

오랜만에 다른 서버에 도커를 설치할 일이 생겼는데 새 서버다 보니 이런 오류를 맞이했습니다 허허 Package 'docker-ce' has no installation candidate docker-ce 패키지를 사용할 수 없습니다. 하지만 다른 패

boying-blog.tistory.com

ssh를 password없이 restart할 때

$ sudo /etc/init.d/ssh restart

 

2. 'github action run after another' 키워드로 검색

https://stackoverflow.com/questions/62750603/github-actions-trigger-another-action-after-one-action-is-completed

 

Github Actions - trigger another action after one action is completed

I have one action (a yaml file) for deploying a docker image to Google Cloud Run. I would like to receive Slack or Email messages informing the build and push results. How could the message action be

stackoverflow.com

 

 

3. 궁금해서 찾아본 workflow 트리거들

https://docs.github.com/en/actions/using-workflows/triggering-a-workflow

 

Triggering a workflow - GitHub Docs

How to automatically trigger GitHub Actions workflows

docs.github.com

 

 

4. ubuntu에 docker 설치하기 (docker docs)

https://docs.docker.com/engine/install/ubuntu/

 

Install Docker Engine on Ubuntu

Jumpstart your client-side server applications with Docker Engine on Ubuntu. This guide details prerequisites and multiple methods to install.

docs.docker.com

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[Javascript] using : 자바스크립트의 새로운 변수 키워드

Tech/NodeJS 2023. 9. 18. 19:09
728x90
728x90

 

서론

즐겨보는 개발 관련 유튜브 중 하나인 노마드코더에서 흥미로운 영상을 게시했다.

 

기존 자바스크립트의 변수 const, var, let을 대체할 강력한 키워드인 using 이라고 소개했다.

역시 프로유튜버답게(??) 어그로를 잘 끄셔서 자연스레 정주행했다.

현재 자바에서 타입스크립트로 전향한 지금, 꽤나 중요한 이슈사항이 될 것 같아서 포스팅하여 정리해보려고 한다.

 

https://www.youtube.com/watch?v=-NmwyJ5S-IY&t=151s 

 

 

기존의 자바스크립트 변수

간단하게 기존 변수들에 대해 알아보자.

es6에서 포함된 기능중 하나는, 변수 선언에 사용할 수 있는 키워드인 let, const의 추가였다.

혹여 단순 using에 대한 정보만을 얻고자한다면, 스크롤을 많이 내려야 할 것 같다.

 

 

var의 문제점

기존의 var 변수가 함수 외부에서 선언될 때의 범위는 전역이고, 함수 내에서 선언될 때는 함수 범위 내로 지정된다.

var hi = "hey hi";

function test() {
    var hello = "hello";
}

//error: hello is not found
console.log(hello);

hello는 test() 함수 밖에서 사용할 수 없기 때문에 에러가 발생할 것이다.

 

또한 var 변수는 재선언이 가능하다.

var test = "test code"
var test = "test test"
var test2 = "test2 code"
test2 = "test2 test2"

 

var는 호이스팅 때문에 다음과 같은 코드를 작성하면 test is undefined를 발생시킨다.

console.log(test)
var test = "test"
//** test is undefined!!!!

//real action (Hoisting)
var test
console.log(test)
test = "test"

 

var의 이와같은 특징들을 활용하여 아래 코드를 살펴보자.

얼핏보면 괜찮은 var을 활용한 코드를 작성했다고 생각할 수 있다.

var test = "test"
var test2 = 10

if (test > 9) {
    var test = "hi hello"
}

console.log(test)
// >> "hi hello"

위 코드는 if문이 true기 때문에 test 변수는 재정의된다. 의도적으로 재정의한다면 괜찮겠지만, test 변수가 이미 정의되어 있다는 사실을 인식하지 못하는 경우에는 문제가 될 수 있다. 코드의 다른 부분에서 test 변수를 사용했다면 다른 출력값에 당황하게 될 수 있다.

 

요약해보면, var은 블록 스코프를 지원하지 않고, 재선언이 가능한 특성을 갖고 있다. 또한 호이스팅이 발생하기 때문에 가독성이 저하되고, 변수 선언 이전에 변수를 사용해도 오류가 발생하지 않는 등 많은 문제를 발생시킨다. 그리하여 const, let이 등장하게 되었다.

 

 

let

let으로 선언된 변수는 해당 블록 내에서만 사용이 가능하다.

let test = 'test'
let test2 = 10

if (test2 > 9) {
    let result = 'let is powerful'
    console.log(result) // 'let is powerful'
}
console.log(result) // result is not defined

result 변수가 정의된 블록 외부에서 해당 변수를 사용하면 에러가 발생된다.

 

또한 let은 업데이트는 가능하지만, 재선언은 불가능하다.

let test = 'test'
test = 'not test, its real'
let test = 'test'
let test = 'not test, its real'
// error: Identifier 'test' has already been declared

 

하지만, 다른 범위 내에서 재정의된다면 에러는 발생하지 않는다. 서로 다른범위를 가지므로 서로 다른 변수로 취급되기 때문이다.

let test = 'test'

if (true) {
    let test = 'its real test'
}
console.log(test) // 'its real test'

 

이처럼 let을 사용한다면, 변수가 범위 내에서만 존재하기 때문에, 이전에 이미 사용한 변수 명에 대해 더이상 신경쓰지 않아도 된다. 또한 범위 내에서 동일한 변수를 두 번 이상 선언할 수 없기 때문에 var의 문제가 발생하지 않는다.

 

또한 let의 경우, 호이스팅 시 초기화가 되지 않는다. 즉 호이스팅으로 선언은 되었지만 초기화가 되지 않았으므로 undefined의 값을 가지지 못한다. 그리하여 Reference Error를 뱉어낸다. test가 초기화되기 전에 사용되었기 때문이다.

console.log(test)
let test = "test"

//real action
let test
console.log(test) // Reference Error
test = "test"

 

여기서 계속 말하는 선언과 초기화는 다음 정의를 가진다.
선언(Declaration) : 스코프와 변수 객체 생성. 스코프가 변수 객체를 참조
초기화(Initialization) : 변수 객체 값을 위한 공간을 메모리에 할당된다 (undefined)

 

 

 

const

단어의 뜻에서도 알 수 있듯이 상수값을 유지하는 변수이다. 당연히 업데이트도, 재선언도 불가능하다.

const 또한 let처럼 선언된 블록 범위에서만 접근이 가능하다.

const test = 'i am constants'
test = 'change' //error: Assignment to constant variable
const test = 'i am constants'
const test = 'change' // error: Identifier 'test' has already been declared

 

하지만 객체의 프로퍼티의 "값"은 변경이 가능하다.

const test = {
    name: 'constants'
    age: 1
}

test.name = 'change'

 

const의 호이스팅도 let과 마찬가지다.

 

 

요약

구분 var let const
범위 전역 블록 범위 블록 범위
재할당 O O X
재선언 O X X
호이스팅 시 초기화여부 O X X
선언과 초기화 초기화없이 선언 가능 초기화없이 선언 가능 선언단계에서 초기화 필수

 

 

참조

https://www.youtube.com/watch?v=-NmwyJ5S-IY&t=151s 

https://letsusetech.com/introducing-javascripts-new-using-keyword-for-variables

https://www.freecodecamp.org/korean/news/var-let-constyi-caijeomeun/

 

 

 

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] JWT Guard 사용 중 에러 (metatype is not a constructor / In order to use "defaultStrategy", please, ensure to import PassportModule in each place where AuthGuard() is being used, Otherwise, passport won't work correctly.)

Tech/NodeJS 2023. 9. 10. 22:30
728x90
728x90

 

 

에러 메세지

JWT Guard설정 후 테스트를 위해 서버를 실행했을 때 발생

 

 

 

원인

그대로 번역하면 메타타입은 생성자가 아니라는 것 같음.

metatype is not a constructor에러는 커스텀 가드를 사용할 때 발생할 수 있는 일반적 오류중 하나이며

@UseGuards() 데코레이터에 가드 클래스를 전달할 때 메타타입 정보가 올바르게 전달되지 않을 때 발생한다고 한다.

즉, 인스턴스를 전달하는 것이 아니라 생성자 함수를 전달해야한다.

//before
@UseGuards(AuthGuard)

//after
@UseGuards(AuthGuard())

문제가 된 부분을 수정해주었음.

 

 

새로운 에러

In order to use "defaultStrategy", please, ensure to import PassportModule in each place where AuthGuard() is being used, Otherwise, passport won't work correctly.

 

defaultStrategy를 사용하려면 AuthGuard가 사용되는 곳에 PassportModule을 import해주어야 한다고 한다.

 

나의 경우 AuthModule에서 사용중이니 임포트 해주었다.

@Module({
    providers: [AuthService],
    controllers: [AuthController],
    exports: [AuthService],
    imports: [
      UserModule,
      //추가한 부분
      PassportModule.register({ defaultStrategy: 'jwt' }),
            
      JwtModule.registerAsync({
      (...생략...)
    ],
  })
  export class AuthModule {}

 

 

 

 

참조

https://stackoverflow.com/questions/67066064/error-metatype-is-not-a-constructor-when-using-instance-of-own-https-server-cla

 

Error: metatype is not a constructor when using instance of own HTTPS Server class

Good evening, I am playing around with nest and want to achieve an own HTTPS-Server that can be instantiated everywhere in other projects. Right at the beginning I get the following error-message:

stackoverflow.com

https://velog.io/@wanzekim/ERROR-ExceptionHandler-metatype-is-not-a-constructor

 

[Nest.js] ERROR [ExceptionHandler] metatype is not a constructor

@UseGuards(AuthGuard()) 로 발생한 이슈

velog.io

 

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

[NestJS] NestJS 개발 환경 셋팅

Tech/NodeJS 2023. 9. 9. 00:20
728x90
728x90

 

 

서론

9개월 정도를 스프링만 사용하던 신입 개발자인 나에게 갑작스레 실무에서 Node.js를 사용해야하는 상황이 닥쳤다.

기존 backend 코드를 보니 모듈을 import하는 것들이 대부분 @nestJS/어쩌고로 되어있는 typescript였다.

 

실무를 위해 빠른 적응이 필요했다.

나중에 node와 typescript, nest등에 대한 적당히 자세한(?) 공부 및 포스팅을 진행할 것이다.

 

간단하게 알아본 바로는 다음과 같았다.

NestJS는 서버 측 노드 애플리케이션을 구축하기 위한 프레임워크로

Express와 같은 HTTP Server Framework를 내장하고 있고, 대부분 TypeScript로 구성되어 있다고한다.

 

이미 셋팅되어있는 개발환경을 다시 뜯어보기전에, 어떻게 개발환경을 셋팅하는지 부터 알아야했다.

 

Nest는  Angular에서 영감을 받아 개발자와 팀이 고도로 테스트 가능하고 확장 가능하며 느슨하게 결합되고유지 관리가 쉬운 애플리케이션을 만들 수 있는, 즉시 사용 가능한 애플리케이션 아키텍처를 제공한다.

 

 

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 Programming), FP (Functional Programming), and FRP (Functional Rea

docs.nestjs.com

 

 

개발 환경 셋팅

노드 설치

노드를 우선 설치해야하는데, 노드 설치는 홈페이지 가서 OS에 맞는 것을 설치하면 된다.

 

NestJS CLI 설치

CLI란 명령 줄 인터페이스로 NestJS는 개발자가 좀 더 편리하게 개발하고 설정할 수 있도록 이러한 CLI를 제공한다.

npm i -g @nestjs/cli

혹여 권한 때문에 에러가 발생한다면 sudo 등의 권한을 부여하는 명령어를 같이 사용하자.

나는 그냥 VSCode를 관리자 권한으로 실행했더니 잘 됐다.

 

위 명령어를 통해 설치를 진행하며, 왜 꼭 전역으로 설치해야하는지 의문이 들어서 검색해보다가 결국 chatGPT에게 물어봤더니 아래처럼 대답해줬다.

 

 

프로젝트 생성

nest new projectName

원하는 경로로 이동하여 위 명령어를 실행하면 NestJS CLI가 자동으로 프로젝트 폴더를 생성해준다.

 

 

 

자동으로 프로젝트가 생성되었다.

728x90
300x250
mag1c

mag1c

2년차 주니어 개발자.

방명록