05. JWT와 유저 기능
유저 기능을 구현하는 방식에는 여러가지가 있지만, 그 중에서도 나는 JWT 토큰을 만들어 쿠키로 구워주는 방식을 선호한다. 이유야 여럿 있겠다만, 가장 큰 이유 중 하나로는 클라이언트 측에서 따로 뭘 해줄 필요가 없다는 점을 꼽을 수 있겠다. 물론 이 방식에 단점이 없는 것은 아니지만 백엔드가 모든 것을 통제하는 상황은 나열할 수 있는 여타 단점들을 모두 상쇄하지 않나 싶다.
NestJs에서는 @nestjs/jwt 패키지를 사용해 토큰을 발급하고 사용한다. 이 패키지는 JwtModule과 JwtService를 제공하며, 이를 통해 다양한 기능에 쉽고 편하게 접근할 수 있다. JwtModule는 동적 모듈이기에 register 메소드를 호출하여 생성한다. 메소드에 옵션 객체를 제공하여 이것저것 설정할 수 있지만, 나는 아무 옵션도 제공하지 않고 생성하는 편이다.
import { JwtModule } from '@nestjs/jwt';
@Module({
controllers: [AuthController],
providers: [AuthService, AuthRepository],
imports: [
JwtModule.register({}),
],
})
export class AuthModule {}
import { JwtService } from '@nestjs/jwt';
import { jwtConfig } from 'src/config/jwt.config';
import { ConfigType } from '@nestjs/config';
@Injectable()
export class AuthService {
constructor(
@Inject(jwtConfig.KEY) private jwt: ConfigType<typeof jwtConfig>,
private authRepository: AuthRepository,
private jwtService: JwtService,
) {}
}
JwtService는 크게 두 가지 메소드를 제공한다. 하나는 secret을 사용해 payload를 토큰으로 구워주는 sign이고, 다른 하나는 토큰의 유효성을 검사하는 verify이다.
내 기억이 맞다면 express에서 주로 사용하는 jsonwebtoken 패키지의 verify 함수는 secret과 토큰을 사용해 payload를 반환하기만 했다. 그러나 JwtService의 verify 메소드는 토큰이 유효하면 payload를 반환하고, 유효하지 않다면 에러를 던진다. 주로 토큰이 만료되었을 때는 TokenExpiredError이고, 토큰 자체가 문제일 경우에는 JsonWebTokenError를 던진다.
나는 AuthService에서 아래의 세 메소드를 만들어 사용하고 있다. 토큰을 만들고 검증하며, 필요시에는 재발급하는 로직들이다.
generateToken(
payload: { email: string; nickname: string },
type: 'access' | 'refresh',
) {
const secret =
type === 'access' ? this.jwt.accessSecret : this.jwt.refreshSecret;
const expiresIn = type === 'access' ? '1h' : '30d';
return this.jwtService.sign(payload, {
secret,
expiresIn,
});
}
verify(token: string, type: 'access' | 'refresh') {
const secret =
type === 'access' ? this.jwt.accessSecret : this.jwt.refreshSecret;
const { email, nickname } = this.jwtService.verify(token, {
secret,
});
return { email, nickname };
}
refresh(refresh: string) {
const { email, nickname } = this.jwtService.verify(refresh, {
secret: this.jwt.refreshSecret,
});
const accessToken = this.generateToken({ email, nickname }, 'access');
const refreshToken = this.generateToken({ email, nickname }, 'refresh');
return { accessToken, refreshToken };
}
물론 AuthModule이 JwtModule을 export하면, AuthModule을 import한 다른 모듈에서도 JwtService를 사용할 수 있다. 아니면 아예 Jwt 로직이 필요한 모든 모듈에서 JwtModule을 import할 수도 있다(그림 왼쪽).
그렇지만 나는 유지보수의 관점에서 JwtService는 오직 AuthService에서만 사용하고, 다른 곳에서는 JwtService 대신 AuthService를 사용하도록 하고 있다. 이렇게 함으로써 jwt와 관련된 환경 변수는 모두 AuthService하게 되며, JwtService를 사용하는 여러 로직을 다양한 모듈에서 통일성 있게 사용할 수 있게 된다(그림 오른쪽).
이 포스트를 시작할 때 언급했던 내용이지만, 나는 백엔드에서 토큰 관리에 대한 전권을 갖는 걸 좋아한다. 따라서 httpOnly, secure, sameSite 설정을 통해 클라이언트는 토큰에 얼씬도 못하게 처리해주는 것이 중요하다. 일반적으로 NestJs에서는 컨트롤러의 메소드가 return하지만, 쿠키를 구워야 하기 때문에 @Res 데코레이터로 응답 객체를 가져와 쿠키를 심어준다.
@Post('signup')
async signUp(@Body() signUpDto: SignUpDto, @Res() res: Response) {
const { accessToken, refreshToken } =
await this.authService.signup(signUpDto);
res
.cookie('accessToken', accessToken, {
httpOnly: true,
secure: true,
sameSite: 'lex',
})
.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'lex',
})
.status(201)
.send({ message: '회원가입 성공' });
}
블로그의 정보
Ayden's journal
Beard Weard Ayden