웹 서비스 개발 스택
- Node.js
- MongoDB
- Nuxt3
최근 업무를 보면서 우리 웹서비스의 인증 관련 이슈가 있었다. 특별한 이벤트를 하지 않았음에도 불구하고 지속적으로 401에러가 발생하면서 세션이 만료되는 문제가 있었다. 인증은 서비스의 가장 기반이 되는 영역이기 때문에 해당 이슈가 고객에게 큰 불편함을 줄 수 있어서 이 문제를 해결하기 위해 많은 시간을 할애했었다. 이 트러블 슈팅을 정리하는 김에 간단하게 JWT에 대해서도 정리해봤다.
JWT
웹 개발자, 특히 백엔드 개밸자로 커리어를 쌓기 시작하면서 필수적으로 배우는 지식 중 하나는 인증이고, 요즘은 토큰을 기반(주로 jwt)으로 한 인증 방식을 많이 선호하고 있다.
실제로 백엔드 관련 부트 캠프에서도 JWT를 많이 가르치고 있고, 실무에서 많이 사용하고 있다.
많은 개발자들이 JWT를 선호하는 이유로는 다음과 같다.
- 일반적으로 서버에 상태를 저장하지 않기 때문에(stateless) 로드 밸런싱이나 마이크로 서비스 아키텍처 등 분산 시스템에 있어서 개발 및 유지 보수가 간편해진다. 또한 구조 확장 측면에서도 유용하다.
- stateless한 특성으로 서버의 부하를 줄일 수 있어서 리소스 비용을 절감할 수 있다.
- 무엇보다 구현하기 쉬운 편이다.
jwt로 인증을 구현할 때 개념으로는 access token, refresh token이 있고 보통 이 두 개의 토큰을 구현하여 인증하도록 한다.
- access token
- 클라이언트에서 요청 시 유저가 누구인지 인증 및 인가를 담당하는 토큰
- 대체로 유효 기간을 짧게 설정
- refresh token
- access token을 갱신하는데 사용되는 토큰
- 유효 기간을 2주, 혹은 몇 달 정도로 설정
- 클라이언트의 쿠키, 혹은 로컬 스토리지 등에 저장
그러나 이 방식의 한계는 토큰이 탈취당했을 경우 대응하기가 매우 어렵다는 점이다. 만약 인증 상태가 서버에 저장되어 있다면 개발자가 의도적으로 인증을 만료시킬 수 있겠지만 클라이언트에만 인증을 저장하는 방식이라면 답이 없다.
실제 웹서비스에 적용된 인증 프로세스
그래서 실무에서는 어쩔 수 없이 인증 상태를 서버에 저장하는 방법으로 구현을 하는 경우가 많다고 한다. 우리팀 역시 마찬가지로 리프레시 토큰을 DB에 저장하고 있다. 인증 프로세스를 간단하게(?) 정리하면 다음과 같다.
즉 클라이언트의 요청 데이터 Request의 header에 access token의 유효 여부, 만료 여부를 파악한 후, 만약 만료되었다면 cookie의 refresh token 검증을 통해 새로운 acess token, refresh token을 모두 새로 생성하여 각각 응답 데이터의 header와 cookie에 담아서 갱신해준다.
access token 갱신할 때 401 에러 발생
그러나 이 과정에서 어떠한 문제로 인해 웹앱의 로그인이 자주 풀리는 아주 X같은 문제가 발생했다. 리뉴얼 이전에는 발생하지 않았던 문제여서 문제 원인을 발견하는데 다소 난항을 겪었다. 우선 401에러가 발생할 수 있는 케이스들을 생각해봤다.
- request의 header의 authorization에 access token이 없다
- cookie에 refresh token이 없다
- DB에 요청 받은 refresh token이 없다
- 응답 시 response의 header의 authorization에 access token이 정상적으로 담기지 않는다
- 응답 시 클라이언트의 쿠키에 refresh token이 정상적으로 담기지 않는다
- 그 외 서버 및 로직 문제 등등등
그리고 에러가 발생할 때 네트워크 상태들을 봤다. 어떠한 특정한 페이지에서 인증이 필요한 api들을 요청되었을 때 개발자도구에서 본 네트워크 상태다.
잘 안보이겠지만, 여하튼 이미지를 보면 각 api 요청마다 umA6... dHM3... 이렇게 두 개의 리프레시 토큰이 존재하는 것이 보인다.
그렇다면 DB 상태는 어떨까?
위의 로그는 refresh token이 갱신되고 db에 저장될 때마다 보이도록 한 것인데, 보면 db에 저장이 되는 토큰들의 다르다. 즉 DB의 일관성이 위배되면서 race condition 현상이 발생하고 있었다. 문제를 명확하게 파악하게 되다 보니 해결 방법은 너무 간단했다. 토큰을 갱신하는 코드를 동기화 처리하면 된다. 기존의 코드는 다음과 같다.
// 토큰이 있는 모델의 인스턴스 메서드
genAndSetRefreshToken(this) {
const token = cryptoRandomString({ ... }); // 새로 생성한 토큰
const expiresAt = new Date(Date.now() + ms(REFRESH_TOKEN_EXPIRED_AFTER));
this.refreshTokens.push({ token, expiresAt });
this.refreshTokens = this.refreshTokens
.slice(-1 * MAX_REFRESH_TOKEN_COUNT)
.filter(({ expiresAt }) => expiresAt.getTime() > Date.now());
return { token, expiresAt };
}
기존의 코드는 새로 생성한 토큰와 만료 기간에 대한 객체를 DB의 refreshTokens 필드에 추가하는 식이다. 여기서 MongoDB에서 제공하는 updateOne() 메서드와 자바스크립트의 async/await를 이용하여 데이터가 일관성을 보장하도록 수정했다.
// 토큰이 있는 모델의 인스턴스 메서드
genAndSetRefreshToken(this) {
const token = cryptoRandomString({ ... }); // 새로 생성한 토큰
const expiresAt = new Date(Date.now() + ms(REFRESH_TOKEN_EXPIRED_AFTER));
await this.updateOne({
$push: {
refreshTokens: {
$each: [{ token, expiresAt }],
$slice: -1 * MAX_REFRESH_TOKEN_COUNT,
},
},
});
return { token, expiresAt };
}
자바스크립트는 콜스택에 쌓인 요청들을 비동기적으로 처리하기 때문에 순차적인 작업이 필요한 경우 프로미스 객체를 제대로 처리할 줄 알아야 한다. 이는 자바스크립트 개발자로써 매우 기초적인 지식이라고 할 수 있다. 그럼에도 불구하고 다음과 같은 문제를 겪은 이유로는 리뉴얼 이전에는 동기화 처리하는 부분이 api 호출부에 있었는데, 기술들을 업그레이드 하는 리뉴얼 때 api 호출부에서 동기화하는 로직이 없어졌었다(기술적인 이슈 등 여러 조건들에 의해 의사 결정이 난 것으로 알고 있다). 또한 내가 작업했던 부분이 아니었다 보니 이런 이슈를 놓쳤었다. 그러나 뭐가 됐든 인증 부분 담당은 나이기 때문에 꼼꼼한 테스트 등을 통해 문제들을 최대한 없애야 했었다.
이번 기회에 인증과 보안에 대해서 지금보다도 더 깊게 이해해야 할 필요성을 느꼈다. 기존에 만들어진 시스템을 맹신하지 말고 회의적인 관점에서 많은 물음표를 가져야 할 것 같다.
'개발자 도전기 > [STUDY] JS || TS' 카테고리의 다른 글
NestJS | JWT 인증, 로그인 기능 구현 (2) | 2023.03.06 |
---|---|
NestJS | Repository pattern (0) | 2023.03.05 |
NestJS | NestJS와 DB 연결, 환경 변수 설정, DTO, 회원가입 기능 구현 (1) | 2023.02.27 |
NestJS | docs | Interceptors & AOP Pattern (0) | 2023.02.24 |
NestJS | docs | Guards (0) | 2023.02.24 |
댓글