[NestJs] Refresh Token 활용한 Access Token 갱신 (Redis)

Jeong Choi(최현정)·2024년 4월 19일
3

현재 진행중인 프로젝트의 로그인 기능 중 Refresh Token의 저장소에 대해 고민한 내용을 담았다.

1) Session VS Jwt

정말 많이 던지는 질문이다. 나도 어떤 방식으로 로그인을 구현해야 할 지 고민이 많았는데, 이 부분은 본인이 진행하고 있는 프로젝트의 특성과 연관 지어 생각하면 된다.

가장 먼저 Session 방식에 대해서 이야기 해보겠다.

🌠 Session 로그인

<1> 추가적인 오버헤드 발생

Session 방식으로 로그인을 구현 시, 클라이언트에서 서버에 요청을 보낼때 마다 SessionID를 넘겨준다. 이 SessionID가 DB던 Cache등 해당 저장소에 있는 지 조회 해 사용자 인증을 한다. 매번 요청을 보낼때마다 SessionID 저장소에 접근해야 하기 때문에 추가적인 오버헤드가 발생한다.

<2> 서버를 한 대만 사용 할 것인가?

물론, 이런 사이드 프로젝트에서는 의미가 없을 수도 있겠지만 실무에서는 과연 서버를 한 대만 운영할까? 절대 아니다. 사용자의 트래픽을 감당하기 위해 로드 밸런싱을 도입한다면 Session 로그인 방식 시 다음과 같은 문제가 생길 수 있다.

서버A에는 1이라는 사용자가 로그인 되어있는 상황이다. 추가적으로 사용자 1이 서버B에 요청을 보냈을 때 서버B는 서버A에게 사용자 1이 로그인이 되어있는 지 모른다. 그러므로 서버들간의 동기화를 해야 하기 때문에 추가적인 작업이 수행된다.

위와 같은 문제들로 분산 서버일 경우 중앙 집중형 SessionID 저장소(Redis 등..)을 사용해 Session 정보들을 공유한다. 이 경우에는 모든 사용자 인증 작업이 중앙 저장소에 몰리기 때문에 성능 병목 현상이 생길 수 있다는 단점이 있다.

<3> 중앙 집중형인 Session

그렇다면 Session 방식은 어떤 어플리케이션 유형일때 효과적일까? 중앙에서 Session을 관리하기 때문에 필요한 경우 Session을 종료시켜 사용자를 관리할 수 있다.

내가 생각하기에는 수강신청, 콘서트 예매와 같이 동시 로그인을 방지해야 하는 상황일때 Session 방식이 효율적이라 생각한다.

🌠 Jwt 로그인

내가 진행하고 있는 프로젝트는 단순한 커뮤니티 어플리케이션이다.

그러므로 위에서 언급했듯이 Session 방식보다는 토큰 방식으로 구현하는 게 더 요구사항에 부합하다고 생각했다. 하지만 모든 기술이 완벽할 수 없듯이 토큰 방식에도 문제가 있었다.

<1> Token이 탈취된다면?

기본적으로 토큰 방식은 Stateless(무상태)다. Session에서는 사용자의 인증정보를 서버에서 저장하는 반면에 토큰 방식은 서버에서 사용자의 인증정보를 저장하지 않는다. 그렇기 때문에 클라이언트에서 서버에서 토큰을 넘겨줄때, 토큰 안에 최소한의 사용자의 정보가 들어가 있어야 한다.

만약 이 토큰이 탈취된다면 탈취자는 토큰이 만료되는 시점까지 토큰으로 어플리케이션에 접근할 수 있다. 게다가 토큰의 만료시간이 없거나 길다면? 보안적으로 심각한 문제가 발생할 수 있다.

<2> Token의 만료시간을 짧게 설정하자

위와 같은 보안적 측면을 해결 하기 위해서 토큰의 유효시간을 짧게 설정한다. 보통 30분에서 1시간정도로 잡는다. 하지만 이런 경우 다음과 같은 문제가 생긴다.

사용자가 어플리케이션을 사용하고 있는 와중에 토큰이 만료가 된다면 재로그인을 해야한다. 이 경우, 사용자 관점으로 볼 때 굉장히 번거로울 수 있다.

<3> Refresh Token으로 Token을 자동으로 갱신해주자

위에서 언급 했듯이 사용자의 편의를 위해서 토큰을 Access와 Refresh 총 2개로 구성한다. 사용자가 로그인 시 Access와 Refresh Token을 발급해주고, Refresh Token은 서버의 DB나 Cache에 저장한다.

Access Token은 사용자의 인증과 인가를 담당한다. Access Token이 만료되면 서버에서 401 에러를 반환하며, 그 후 클라이언트에서 갖고 있는 Refresh Token과 서버에 저장되어 있는 Refresh Token을 비교해 같다면 새로운 Access Token을 발급받아 클라이언트에게 전달해준다.

이렇게 Refresh Token으로 갱신을 해줄경우 사용자의 어플리케이션 편의성이 높아진다.

2) Refresh Token의 저장소는?

서버에서 Refresh Token을 어디에 저장할 지도 생각을 해봐야 한다. 인터넷 서핑을 조금이라도 해보면 대부분 Database VS Redis 로 갈린다.

어떤 저장소가 좀 더 효율적인지 판단하기 위해서 현재 내가 사용하고 있는 Database인 MySQL과 Redis 총 2가지의 저장소에 각각 Refresh Token을 저장해보고 성능체크까지 해보겠다.

⭐️ 로그인 API

MySQL에 Refresh Token 저장

사용자가 로그인 시 아래 사진과 같이 Refresh Token이 DB에 저장된다.

사용자가 로그인 요청 시 Access, Refresh Token을 발급받고 MySQL에 Refresh Token을 저장하는 로그인 API이다.

실행속도는 52ms로 확인된다.

Redis에 Refresh Token 저장

다음은 로그인 시 Redis에 저장된 Refresh Token이다.

마찬가지로 Redis에 Refresh Token을 저장한 로그인 API의 실행속도를 확인해보자.

실행속도는 26ms이다.

확실히 MySQL에 저장하는 것 보다 Redis에 Refresh Token을 저장하는 것이 성능면에서 우수하다는 것을 확인할 수 있다.

그렇다면 Access Token을 갱신하는 API에서는 어떤 방법이 더 우수할까?

⭐️ Access Token 만료 시 갱신하는 API

MySQL에 저장된 Refresh Token

MySQL에 저장할 경우 API의 실행속도를 확인해보면 29ms의 실행시간이 걸린다.

Redis에 저장된 Refresh Token

Redis에 토큰을 저장한 경우는 19ms이 걸리는 것을 확인할 수 있다.

🖨️ Database VS Redis

메모리 저장소인 Redis

왜 이러한 성능 차이가 나는걸까?

Redis는 데이터를 직접 메모리에 저장하기 때문에 디스크에 데이터를 쓰는 Database보다 훨씬 속도면에서 우수하며, 메모리 기반 저장소이기 때문에 데이터 접근 속도 또한 Database보다 성능 면에서 유리하다.

Refresh Token의 자동만료

Database에 Refresh Token을 저장한 경우, Refresh Token을 없애기 위해서는 주기적으로 스케쥴러를 돌려서 삭제를 시켜야 한다.

하지만 Redis에 Refresh Token을 저장한 경우에는 Refresh Token을 저장할 때 TTL을 설정자동 만료가 될 수 있도록 설정할 수 있기 때문에 Redis 편리하다 생각했다.

사진에 있는 Refresh Token은 84202초 뒤에 자동적으로 Redis에서 삭제된다.

이런 이유들을 바탕으로 나는 Refresh Token을 Redis에 저장하기로 결정했다.

3) Nest 구현 코드

마지막으로 Nest에서 어떻게 구현했는지 코드를 확인해보자.

🛠️ 로그인 API

AuthController.ts

    @UseGuards(UserLocalAuthGuard)
    @HttpCode(201)
    @ApiOperation({summary: '사용자 로그인'})
    @Post('/signin')
    async loginUser(
        @Body() authCredentialsDto: AuthCredentialsDto,
        @UserId() userId: number,
        @Res() res: Response
    ): Promise<void>{
        const {accessToken, refreshToken, regionName} = await this.authService.loginUser(userId);

        res.cookie('accessToken', accessToken, {httpOnly: true});
        res.cookie('refreshToken', refreshToken, {httpOnly: true});

        res.status(201).json({
            message: '로그인 성공',
            regionName: regionName
        });
    }

AuthService.ts

    async loginUser(id: number): Promise<UserLoginResultInterface> {
        const payload: {id: number} = {id};

        const accessToken = this.jwtService.sign(payload, {
            secret: this.configService.get('JWT_SECRET_KEY'),
            expiresIn: this.configService.get('JWT_EXPIRATION')
        });
        const refreshToken = this.jwtService.sign(payload, {
            secret: this.configService.get('JWT_REFRESH_SECRET_KEY'),
            expiresIn: this.configService.get('JWT_REFRESH_EXPIRATION')
        });

        const expiresIn = this.configService.get('JWT_REFRESH_EXPIRATION_TTL');
        await this.refreshTokenService.setKey(`refreshToken:${id}`, refreshToken, expiresIn);

        const user = await this.userRepository.findOne({where: {id: id}, relations: ['region']});
        const userRegionName = user.region.name;
    
        return {
            accessToken: accessToken,
            refreshToken: refreshToken,
            regionName: userRegionName,
        };
    }

Access와 Refresh Token을 생성해 Refresh Token은 Redis에 저장해주었으며, TTL은 86400초(1일)로 설정해주었다.

🛠️ 새로운 Access Token 재발급 받는 API

AuthController.ts

    @Post('/refresh')
    async refreshToken(
        @Body() refreshTokenDto: RefreshTokenDto,
        @Res() res: Response
    ): Promise<void>{
        const {newAccessToken} = await this.authService.refreshToken(refreshTokenDto);

        res.cookie('newAccessToken', newAccessToken, {httpOnly: true});

        res.status(201).json({
            message: 'accessToken 갱신 성공'
        });
    }

RefreshTokenDto.ts

export class RefreshTokenDto{

    @IsNotEmptyAndString()
    refreshToken: string;
}

AuthService.ts

    async refreshToken(refreshTokenDto: RefreshTokenDto): Promise<{newAccessToken: string}>{
        const {refreshToken} = refreshTokenDto;

        const payload = this.jwtService.verify(refreshToken, {
            secret: this.configService.get('JWT_REFRESH_SECRET_KEY')
        });

        const userId = payload.id;
        const storedRefreshToken = await this.refreshTokenService.getKey(`refreshToken:${userId}`);

        if(storedRefreshToken === null){
            throw new RefreshTokenExpiredException();
        }

        if(storedRefreshToken === refreshToken){
            const newPayload = {id: userId};
            const newAccessToken = this.jwtService.sign(newPayload, {
                secret: this.configService.get('JWT_SECRET_KEY'),
                expiresIn: this.configService.get('JWT_EXPIRATION')
            });
            return {newAccessToken};     
        }else{
            throw new RefreshTokenInvalidException();
        }
    }

클라이언트에서 보낸 Refresh Token과 서버의 Token이 다를 때 예외처리를 해줬으며, 만약 서버에 Refresh Token이 만료되어 없을 때도 예외 처리를 해주었다.

RefreshTokenService.ts

@Injectable()
export class RefreshTokenService {
    private readonly redisClient: Redis;

    constructor(private readonly refreshTokenService: RedisDao){
        this.redisClient = this.refreshTokenService.getClient();
    }

    async setKey(key: string, value: string, ttl: number): Promise<void> {
        await this.redisClient.set(key, value, 'EX', ttl);
    }

    async getKey(key: string): Promise<string|null> {
        return this.redisClient.get(key);
    }
}

Redis에 Refresh Token을 저장하고 조회하는 로직이다.

profile
Node와 DB를 사랑하는 백엔드 개발자입니다:)

0개의 댓글