NestJS 환경에서 Redis를 활용한 인증 토큰 처리

구현 동기

개발 중인 타이머 서비스인 Pipe Timer에서 회원가입 이메일 인증, 비밀번호 초기화, 이메일 변경에 인증 토큰 발급하여 인증을 진행하고 있었다. 인증 절차는 다음과 같다.

  • 인증이 필요한 사용자 A의 인증 토큰을 발급한다. 토큰은 ulid 라이브러리를 사용해서 만든 고유한 문자열이다.
  • 토큰은 사용자 A의 정보와 함께 DB에 저장한다.
  • 토큰 정보를 포함하는 인증 링크를 생성한 뒤, 사용자 A가 입력한 이메일로 토큰을 전송한다.
  • 사용자 A는 이메일에 포함된 링크를 통해 서버에 인증 요청을 전송한다. 서버는 요청에서 토큰 정보를 추출하고, DB에 저장된 토큰과 일치하는지 확인한다. 일치 여부에 따라서 인가가 결정된다.

토큰 생성을 위해 사용하는 라이브러리는 충분히 고유한 값을 생성하므로 사용자 A의 인증에 유용하게 사용할 수 있다. 그러나 공격자에게 무한한 시간과 기회가 있으면 악의적인 사용자의에 의해 토큰이 탈취될 가능성이 있다(Brute-force). 보안성 향상을 위해 토큰에 적절한 유효 기간을 설정하고 만료된 토큰을 처리하는 작업이 필요하다.

Pipe Timer를 구현하며 토큰에 유효 기간을 부여하고 만료된 토큰을 어떻게 처리할지에 대해 고민했다. 해당 기능을 구현하기 시작했을 땐, Cron을 사용하는 방법 외에 다른 아이디어는 떠오르지 않았다. 이 방법은 우선 인증 토큰을 생성할 때 생성 시간을 DB에 기록해야 한다. 그 뒤엔 Cron으로 DB를 1분마다 스캔해서 현재 시간 - 생성 시간이 유효 기간보다 큰 토큰을 만료된 토큰으로 여겨 처리한다. 이는 만료된 토큰을 처리하는 가장 단순한 방법의 하나일 것이다.

그러나, Cron을 활용한 인증 토큰의 처리 방식을 바꿔야겠다는 생각이 들었다. 이러한 생각을 하게 된 이유는 다음과 같다.

  • 불필요한 자원 낭비 방지: Cron 방식은 새로운 토큰 발급이 전혀 없더라도 계속해서 만료된 인증 토큰을 스캔하기 위해 백그라운드에서 Cron이 작동해야 한다. 스캔 자체에서 큰 오버헤드가 발생하진 않지만, 불필요한 요청이 주기적으로 발생하지 않도록 하고 싶었다.
  • 서버 불안정 요소 사전 제거: 주기적으로 토큰을 처리하면 개별 토큰에 부여된 실제 만료 시간과 달리 토큰을 몰아서 처리해야 한다. 예를 들어, 1초 간격으로 토큰의 만료 시점이 분포해 있다고 한다면 1분 주기로 몰아서 토큰을 처리하는 경우엔 한 번에 60개의 토큰을 처리해야 한다. 이러한 이유로 주기적으로 토큰을 몰아서 처리하기보단, 만료 시점에 즉각적으로 처리하는 방향으로 토큰을 처리하고싶었다.

생각해 본 대안들

토큰을 효율적으로 만료시키기 위해 떠올렸던 대안들은 다음과 같다. 대안의 명칭들은 공식 명칭이 아니고 설명 편의를 위해 자체적으로 부여한 것이다.

  • 액세스 만료 방식: 토큰이 인증을 위해 서버에 접근할 때만 토큰의 유효 기간을 확인해서 만료 여부를 결정하는 방식이다.
    • 발급된 토큰이 항상 서버에 접근하리란 보장은 없다. 만료됐지만 제거되지 않은 토큰을 처리하기 위해 주기적인 스캔이 필요하다.
    • 만료됐지만 제거되지 않은 토큰을 고려해서 코드를 작성해야 한다는 것이 혼란을 불러와 생산성이 떨어질 것 같았다. 기존의 코드에도 상당한 변경이 필요할 거로 예상한다.
  • Nested Cron 방식: 외부 Cron 내부에 추가적인 Cron을 두는 방식으로, 큰 주기와 작은 주기의 조합을 통해 만료 예정인 토큰을 처리하는 방식이다.
    • 주기 설정과 토큰 발급 시간의 분포 양상에 따라 개선 효과가 전혀 없거나 제한될 수 있다.
  • 데이터베이스 분리 방식: 토큰 인증이 필요한 사용자 정보를 별도의 데이터베이스로 분리하여 쿼리 규모를 축소하는 방식이다.
    • 데이터베이스를 추가로 구성하고 분리된 데이터베이스를 조회해야 하므로 추가 리소스와 데이터베이스 설계 작업이 필요하다.
  • Redis TTL 기반 방식: Redis에서 키의 유효 기간을 설정하는 TTL 기능과 키 만료 시 알림을 전송하는 기능을 활용하여 토큰 만료를 처리하는 방식이다.
    • Redis는 메모리에 데이터를 저장하므로 용량 대비 가격이 비싸고, 데이터 유실 가능성을 염두에 두어야 한다.

이 중에서 실제 서비스에 적용한 방법은 글의 제목에서도 알 수 있듯이 ‘Redis TTL 기반’ 방식이다. 이미 사용자 리프레시 토큰 관리에 Redis를 사용하고 있었기 때문에 추가 비용 부담이 적었고, Cron 사용 최소화라는 구현 목적에도 부합한다고 판단했다.

구현 환경

구현 환경은 다음과 같다.

  • Node.js: 18.12.1
  • NestJS
    • @nestjs/platform-express: 9.3.0
    • @nestjs/common: 9.3.0
    • @nestjs/core: 9.3.0
  • ioredis: 5.2.4
  • TypeORM: 0.3.10
  • MySQL2: 2.3.3
  • AWS
    • EC2(AMI): ubuntu-focal-20.04-amd64-server-20230207
    • RDS(MySQL): 8.0.32
    • Elasticache(Redis): 7.0.7

구현

구현 설명에 앞서 미리 알아두어야 할 점들을 설명한다.

  • 토큰은 Redis와 MySQL DB에 각각 저장한다. 이는 토큰과 사용자 정보를 매핑하기 위해서이다.
  • 토큰이 Redis와 MySQL DB에 남아있다면, 인증해야 할 무언가가 있는 상황이다.
  • 토큰이 남아있지 않다면, 토큰이 필요한 작업을 요청한 적이 없거나 인증이 완료된 것이다.
  • 인증을 수행한다는 것은 Redis와 MySQL DB에서 토큰을 제거하는 것을 의미한다(MySQL DB에선 null로 덮어씌움).

이러한 가정하에, 인증 토큰이 이미 발급된 상황에서 인증 요청토큰 만료에 대한 처리 방법을 설명한다. 토큰을 발급하고 이메일로 전송하는 부분의 설명은 생략한다.

인증 요청

토큰 정보를 서버에 보내 인증을 요청하는 상황이다. 인증은 ‘인증 요청 발생’ → ‘Redis 토큰 가드 인증’ → ‘서비스 로직 수행’ → ‘Redis, MySQL DB에서 인증 추가 처리(토큰 제거)’ 순으로 진행하며, 설명도 이 순서에 맞추어 진행한다.

인증 요청은 토큰 발급과 동시에 이메일로 전송된 링크에서 발생한다. 링크의 쿼리 스트링 부분엔 토큰의 종류와 값을 담고 있다. 아래는 링크의 예시이다.

https://localhost/users/verify-change-email-token?changeEmailToken=01F99Z91VK9V7XHJVY0G9RGJ73

위 링크에서 changeEmailToken은 토큰의 종류(이벤트)이고, 01F99Z91VK9V7XHJVY0G9RGJ73는 토큰값이다.

링크를 누르면 서버에 요청이 전송되고, 요청 쿼리 부분에 토큰이 담겨 전송된다. 토큰 인증은 가장 먼저 Redis 토큰 가드에서 수행한다. Redis 토큰 가드는 전달받은 토큰과 같은 토큰이 Redis에서 조회되는지 확인한다. 이를 가드로 구현한 이유는 다음과 같다.

  • 코드 중복 개선: 토큰 인증 절차는 토큰의 종류에 따라 조금씩 다르지만, 토회 조회 작업이 가장 먼저 실행되어야 하는 점은 모두 같다. 토큰을 활용할 때마다 이 부분을 구현해야 하므로 코드 반복을 최소화하고 싶었다.
  • Execution context: 구현 초기엔 요청을 처리하는 핸들러의 이름을 기준으로 토큰을 구분하고, 토큰별로 적절한 인증 작업을 수행하려고 했다. 요청을 처리하는 핸들러의 이름을 알기 위해선 Execution context의 getHandler() 메서드를 활용해야 하므로, Execution context에 접근할 수 있는 수단이 필요했다.
    • 다만, 구현을 진행하며 핸들러 이름으로 토큰을 구분하는 것은 의외로 헷갈리는 경우도 많았고 유지보수에도 불편해서 지금은 Execution context를 활용하지 않는다.

이러한 이유에서 내린 결론은 인터셉터와 가드 중 한 가지를 선택하는 것이었다. 인터셉터는 주로 데이터를 가공하거나 로깅을 목적으로 사용하는 것이 더 적합하다고 판단해서 가드를 선택했다.

// redis-token.strategy.ts

@Injectable()
export class RedisTokenStrategy extends PassportStrategy(
  Strategy, // `passport-custom`
  "redis-token"
) {
  constructor(
    private authService: AuthService,
    private redisService: RedisTokenService
  ) {
    super();
  }

  async validate(req: Request): Promise<boolean> {
    // 1. 이벤트-토큰 분리
    const { event, token } = await this.authService.splitEventToken(req.query);

    // 2. 인증
    const isValid = await this.redisService.getValue(`${event}:${token}`);
    if (isValid !== null) return true;

    throw new BadRequestException(`Invalid ${event}`);
  }
}

Strategy는 passport-custom 라이브러리를 사용하면 개인 용도에 맞게 구현할 수 있다. 구현의 주요 내용은 다음과 같다.

  1. 이벤트-토큰 분리: splitEventToken 메서드는 요청의 query 에서 eventtoken 값을 추출하는 헬퍼 메서드이다. event는 토큰 종류, token은 토큰값을 나타내는 문자열 프로퍼티이다.

  2. 인증: redisServicegetValue메서드는 토큰을 Redis에서 조회한다. getValue 메서드로 Redis에서 키를 조회했을 때 문자열 값을 응답하면 유효한 토큰이고, 반대로 null을 응답하면 유효하지 않은 토크인다.

    • 토큰이 ${event}:${token} 형태로 저장하도록 설계한 이유는 expired 알림을 기반으로 만료된 토큰을 처리하기 때문이다. 관련 내용은 토큰 만료 케이스에서 추가로 설명한다.

가드를 통과한 요청은 핸들러에 도달한다. 아래 코드는 신규 회원의 이메일 인증을 처리하는 verifySignupToken 핸들러이다.

// user.controller.ts

@UseGuards(RedisTokenGuard)
@Get('verify-signup-token')
async verifySignupToken(@Req() req: Request): Promise<SuccessDto> {
    return await this.authService.verifySignupToken(req);
}

핸들러에서 서비스 로직을 수행하기 위해 AuthServiceverifySignupToken을 호출한다. 인증 토큰이 가드를 통과했기 때문에 이미 유효한 토큰임이 확인됐지만, Redis와 MySQL DB에서 토큰을 제거해야 하며 이벤트별로 필요한 추가 작업이 남아있다.

// auth.service.ts

async verifySignupToken(req: Request): Promise<SuccessDto> {
  // 1. 이벤트-키 분리
  const { event, token } = await this.splitEventToken(req.query);
  const key = `${event}:${token}`;

  // 2. 토큰 갱신 대상 확인
  const query = new CheckSignupTokenValidityQuery(token);
  const user = await this.queryBus.execute(query);

  if (user === null) {
    await this.redisService.deleteValue(key);
    throw new BadRequestException(`Invalid ${event}`);
  }

  // 3. 토큰 만료 시간 백업
  const expiredAt = await this.redisService.getPexpiretime(key);

  // 4. 인증
  const result = await this.userRepository.verifySignupToken(user.id, token);
  if (result.affected) {
    return { success: true };
  } else {
  // 5. 인증 실패
    await this.redisService.setPXAT(key, '1', expiredAt);
    throw new InternalServerErrorException(`Cannot verify ${event}`);
  }
}
  1. 이벤트-키 분리: Request 객체의 query를 이벤트와 키로 분리한다. splitEventToken메서드는 query를 이벤트와 키로 분리하는 헬퍼 메서드이다.

  2. 토큰 갱신 대상 확인: CheckSignupTokenValidityQuery은 MySQL DB에 토큰을 가지고 있는 사용자가 존재하는지 확인하는 쿼리이다. 가드를 통과한 유효한 토큰이더라도 MySQL DB에는 일치하는 토큰이 없을 수 있다. 예를 들면 회원 가입 후 메일 인증을 하지 않고 탈퇴하는 경우이다. 이 경우엔 저장 공간이 낭비되므로 Redis에서 토큰을 제거한 뒤 예외 처리한다.

  3. 토큰 만료 시간 백업: 토큰 만료 시간은 인증 작업 중 오류가 발생했을 때 사용하기 위해 미리 추출한다.

  4. 인증: 인증을 수행한다. Redis와 MySQL DB에서 토큰 제거를 시도한다. 성공하면 간단한 성공 메시지를 리턴한다.

  5. 인증 실패: Redis와 MySQL DB에서 토큰을 제거하던 중 에러가 발생해서 저장된 토큰을 제거하지 못한 상황이다. 다만 MySQL DB에선 트랜잭션을 사용하고 있어 에러가 발생하면 토큰이 제거되지 않은 상태임을 보장하지만, Redis는 정상 작동해서 Redis 토큰만 제거되는 경우가 있다. Redis와 MySQL DB의 데이터 불일치를 방지하기 위해 앞서 추출해 두었던 토큰 만료 시간을 사용하여 Redis에 저장된 토큰을 수동으로 롤백한다.

토큰 정보를 제거하는 userRepositoryverifySignupToken은 다음과 같다.

// user.repository.ts

async verifySignupToken(id: string, token: string): Promise<UpdateResult> {
  return await this.dataSource.transaction(async (manager) => {
    await this.redisService.deleteValue(`signupToken:${token}`);

    return await manager.update(UserEntity, { id }, { signupToken: null });
  });
}

토큰 만료 케이스

토큰 만료 케이스는 Redis에 저장된 토큰이 유효 기간이 지나 제거된 상황이다. Redis는 저장된 데이터에 변화가 생길 때, 알림을 발생시킬 수 있다. 키가 만료될 때 알림을 생성하려면 설정이 필요하다.

// redis-token-pub-sub.service.ts

// 1. Redis 설정
private setRedisTokenNotify(client: Redis, option: string): void {
  if (process.env.NODE_ENV === 'development') {
    client.config('SET', 'notify-keyspace-events', option);
  }
}
  1. Redis 설정: 이벤트 알림 기능을 활성화한다. 개발 환경에서만 config 메서드로 Redis를 설정하는 이유는 현재 사용 중인 AWS 환경에선 Redis 인스턴스 생성 시점 이후에 config 메서드를 활용한 설정이 제한되어 있기 때문이다. 보안상 위험을 초래할 수 있는 상황을 사전에 방지하기 위함으로 추측되며, AWS뿐만이 아니라 대부분의 CSP가 이러한 정책을 따른다는 언급을 보았다(링크).

    • 이 때문에 Redis 인스턴스는 파라미터 그룹을 사전 설정이 필요했고, 테라폼의 Redis 관련 코드에 아래와 같이 파라미터 그룹을 추가했다.

      # data-store/main.tf
      
      resource "aws_elasticache_parameter_group" "notify" {
        name   = "notify"
        family = "redis7"
      
        parameter {
          name  = "notify-keyspace-events"
          value = "AKE"
        }
      }
      

    option에 들어갈 파라미터를 Ex로 설정하면 키 만료에 대한 알림을 생성하고, AKE는 모든 알림을 생성한다. 개발 서버에선 작업의 편의성을 위해 AKE로 설정할 수 있지만, 프로덕션 환경에선 필요한 이벤트만 생성하는 것이 오버헤드나 관리 측면에서 효율적이며, 보안상으로도 더 안전하다고 생각한다.

다음은 Redis의 토큰 만료 메시지를 받아서 토큰 만료 처리를 하는 subRedisToken 메서드이다.

// redis-token-pub-sub.service.ts

private subRedisToken(
  client: Redis,
  message: string,
  eventList: string[]
): void {
  // 1. Subscribe 모드 설정
  client.subscribe(message);

  // 2. 메시지 수신 후, 만료 토큰 처리
  client.on('message', async (channel, key): Promise<void> => {
    if (channel === message) {
      const [event, token] = key.split(':');

      if (eventList.includes(event)) {
        const result = await this.expireToken(event, token);

        if (result === null) {
          this.logger.error(`Cannot expire token. ${event}:${token}`);
        }

        this.logger.verbose(`Token expired. ${event}:${token}`);
      }
    }
  });
}

메시지 수신에 사용할 Redis 클라이언트, 수신할 메시지, 그리고 만료시켜야 할 이벤트의 배열을 인자로 받는다. 알림 설정에 사용했던 Redis 클라이언트는 특정 모드로 고정된 상태가 아니므로 자원을 아끼기 위해서 이곳에서 재사용했다.

  1. Subscribe 모드 설정: Redis 클라이언트가 키의 만료 메시지를 수신하도록 설정하려면 message'__keyevent@0__:expired'를 주어야 한다.

  2. 메시지 수신 후, 만료 토큰 처리: 키 만료 메시지를 받으면 key에서 이벤트와 토큰을 추출한다. 수신한 모든 만료 메시지가 토큰 관련 메시지라는 보장은 없으므로, 이벤트가 tokenList배열에 포함된 경우만 expireToken 메서드를 실행한다. tokenList는 인증 토큰 목록의 배열이다. expireToken 메서드는 MySQL DB에서 만료시켜야할 토큰값을 null로 만든다.

참고

Redis에서 발생한 이벤트 알림을 기반으로 MySQL DB 갱신할 땐, 염두해야 할 사항이 있다. 만료 이벤트의 발생 시점과 이벤트가 실제로 만료한 시점이 정확히 일치함을 보장하지 못하는 점이다(링크). Redis 코드의 주석에 따르면, Redis가 만료 이벤트를 발생시키는 기준은 아래와 같다.

  • 외부 명령으로 키에 접근했는데 유효기간 만료 조건에 부합하는 경우
  • Redis의 백그라운드 시스템이 주기적으로 만료된 키를 찾아내어 알림을 발생시키는 경우(샘플링)

즉, 토큰의 만료 타이밍과 샘플링 시점의 차이만큼 오차가 발생할 수 있다. 만료 타이밍이 매우 중요한 토큰이거나, 오차로 인한 문제가 발생한 경우가 있다면 redis.conf 파일에서 hz값을 수정하는 시도를 해볼 수 있을 것이다(링크).

맺음말

지금까지 NestJS 환경에서 Redis를 활용하여 인증 토큰을 효율적으로 만료시키기 위해 구현했던 코드를 살펴보았다. 이번 구현을 통해 불필요하게 자원이 낭비되던 부분을 개선할 수 있었고, 토큰마다 발급 시간을 저장하느라 비대해졌던 엔티티를 경량화시킬 수 있었다. 또한, 단순히 리프레시 토큰 발급에만 사용하던 Redis에 대해서도 조금은 더 알게 됐다. 가장 큰 수확은 데이터 불일치 문제에 대해서 고민하게 된 점이다. 토큰을 Redis와 MySQL DB 두 곳에 저장할 때, 데이터 불일치가 일어날 수 있음을 고려해서 구현하지 않으면 데이터 불일치가 생각보다 쉽게 발생하는 것을 경험했다. 이번 구현을 계기로 앞으론 항상 이 점을 염두하고 개발을 진행할 것이다.