Debug
로그아웃 했는데 왜 이전 계정으로 로그인 될까? - JWT, Resid, SSO 세션 불일치 관찰 일기
귀신이니?

1. 분명 로그아웃했는데, 왜 다른 계정이 나타날까?
백엔드 개발을 하다 보면 로그인 기능을 구현할 일이 많다. 아이디와 비밀번호를 검증하고, JWT를 발급하고, 인증이 필요한 API에서 토큰을 검증하는 과정은 비교적 익숙하다.
그런데 회사에서 운영하는 서비스의 인증 구조는 생각보다 복잡했다. 기존 관리자 시스템이 있었고 별도의 통합 인증 시스템인 IDP(Identity Provider)를 통해 우리 서비스에 접근할 수 있었다. 현재 진행 중인 프로젝트 자체에서도 Redis를 이용해 인증 세션을 관리하고 있었다.
그러던 중 이상한 현상이 발생했다.
A 계정으로 로그인했다가 로그아웃한 뒤 B 계정으로 로그인했는데, 우리 서비스에서는 여전히 A 계정의 화면이 나타나는 것이었다.

처음에는 브라우저 캐시나 쿠키가 제대로 지워지지 않은 문제라고 생각했다. 하지만 단순히 브라우저를 새로고침한다고 해결되는 문제가 아니었다.
더 이상했던 점은 다음과 같았다.
- IDP에서 로그아웃을 했는데 우리 서비스에서는 즉시 로그아웃 상태로 전환되지 않는 현상
- 다른 계정으로 인증했지만 우리 서비스에서는 이전 계정의 세션이 사용되는 현상
- 인증 토큰 만료 후 갱신 과정에서
invalid_grant오류가 발생하는 현상 - 관리자를 통해 진입하는 경로에 따라 다른 인증 결과가 나타나는 현상
여기서 한 가지 의문이 생겼다.
“IDP에서는 로그아웃했는데, 우리 서버는 그 사실을 어떻게 알지?”
이 질문이 문제를 분석하는 출발점이었다.
2. 먼저 인증 구조부터 이해해야 했다
문제를 분석하기 전에 현재 인증 구조를 정리할 필요가 있었다. 당시 시스템에는 크게 세 가지 주체가 있었다.
에디터 API에서는 Redis를 이용해 인증 관련 세션과 토큰 정보를 관리하고 있었다. 전체적인 인증 흐름을 단순화하면 다음과 같다.

여기서 중요한 점은 IDP의 인증 상태와 에디터 API의 세션 상태가 반드시 같은 것은 아니라는 사실이다. IDP는 사용자가 정상적으로 인증되었는지 관리한다. 반면 에디터는 발급받은 토큰과 자체 세션을 이용해 사용자가 어떤 API에 접근할 수 있는지 판단한다.
즉, 두 시스템이 별도의 인증 상태를 관리하고 있는 것이다. 정상적인 상황에서는 문제가 없다. 문제는 한쪽의 인증 상태가 변경되었는데 다른 쪽에 반영되지 않을 때 발생할 수 있다.
<JWT와 Redis 세션을 함께 사용하는 이유>
JWT는 사용자 인증 정보를 담고 있는 토큰이다. 예를 들어 Access Token에는 다음과 같은 정보가 포함될 수 있다.
{
"sub": "user-A",
"iat": 1790000000,
"exp": 1790000600
}sub: 토큰의 주체인 사용자iat: 토큰 발급 시간exp: 토큰 만료 시간
JWT는 서명을 검증해 유효성을 확인할 수 있다는 장점이 있다. 하지만 이미 발급한 JWT를 즉시 무효화하는 것은 별도의 상태 관리가 없다면 어렵다. 이런 이유로 서비스에서는 Redis와 같은 저장소를 함께 활용하기도 한다.
Redis에 사용자 세션을 보관하면 다음과 같은 작업을 수행할 수 있다.
- 현재 사용자의 인증 상태 확인
- Refresh Token 관련 정보 관리
- 세션 만료 시간 관리
- 로그아웃 시 세션 무효화
- 동일 사용자의 세션 관리
다만 Redis를 사용한다고 해서 자동으로 로그아웃 문제가 해결되는 것은 아니다.
어떤 인증 이벤트에서 어떤 Redis 세션을 무효화할 것인지 명확하게 정의되어 있어야 한다.
3. 실제로 확인한 이상 현상
문제를 분석하면서 확인한 사례는 크게 두 가지였다.
Case 1. 전날 사용했던 계정의 토큰이 다음 날에도 사용되고 있었다
2026년 8월 8일, A 계정으로 에디터에 로그인한 뒤 명시적으로 로그아웃하지 않고 사용을 종료했다. 다음 날인 8월 9일, B 계정으로 접근하는 과정에서 인증 오류가 발생했다. 그런데 로그를 확인하니 특이한 점이 있었다.
새롭게 접근한 B 계정이 아니라, 전날 사용했던 A 계정의 토큰이 검증되고 있었다.
실제 로그에서 확인한 토큰 정보는 다음과 같았다.
여기서 중요한 점은 토큰 만료 자체가 비정상적인 상황은 아니라는 것이다. Access Token의 유효 기간이 10분이라면 다음 날 해당 토큰이 만료되어 있는 것은 당연하다.
정상적인 인증 시스템이라면 만료된 Access Token을 발견했을 때 Refresh Token을 통해 새로운 토큰을 발급받거나, 갱신할 수 없다면 재인증을 요구해야 한다. 실제로 당시에도 토큰 갱신을 시도했다. 하지만 IDP가 정상적인 토큰을 반환하지 않았다.
Case 2. 로그아웃한 계정이 다시 나타났다
또 다른 재현 사례에서는 다음과 같은 순서로 문제가 발생했다.
① A 계정 로그인
↓
② 관리자 시스템에서 로그아웃
↓
③ B 계정 로그인
↓
④ 에디터 접속
↓
⑤ 에디터에서는 A 계정이 나타남사용자 입장에서는 명백하게 이상한 동작이다. 하지만 서버 입장에서는 반드시 '다른 사용자의 토큰을 새로 발급했다'는 의미는 아니다. 기존 브라우저에 남아 있던 쿠키가 전송되었을 수도 있고, 에디터 API가 이전 Redis 세션을 재사용했을 수도 있다.
또는 관리자를 통한 에디터 진입 과정에서 새로운 인증 절차가 생략되었을 가능성도 있었다. 중요한 것은 이 단계에서는 원인을 단정할 수 없다는 사실이다. 그래서 브라우저에서 어떤 쿠키를 보내는지, API 서버에서 어떤 세션을 조회하는지, IDP에서 실제로 어떤 인증이 수행되었는지를 분리해 확인해야 했다.
4. 로그를 따라가 보니 JWT 검증에서 시작되고 있었다
먼저 인증 요청이 실패한 시점의 로그를 분석했다. 핵심적인 흐름은 다음과 같았다.
API 요청
|
v
Access Token 검증
|
v
TokenExpiredError
|
v
Refresh Token으로 재발급 시도
|
v
IDP 응답: HTTP 400
|
v
error: invalid_grant
|
v
Redis 인증 세션 삭제
|
v
API 응답: HTTP 401이 흐름에서 눈여겨봐야 할 부분은 두 가지였다.
첫 번째는 Access Token이 만료된 이후 Refresh Token으로 재발급을 시도했다는 점이다.
두 번째는 Refresh Token 재발급이 실패한 뒤에야 Redis 세션이 삭제되었다는 점이다.
실제로 확인된 해당 인증 실패 흐름에서는 Refresh Token 갱신 실패 후 Redis 인증 세션을 삭제하도록 처리되고 있었다. 즉, 적어도 그 시점까지는 기존 인증 세션을 조회해 갱신을 시도하고 있었다고 볼 수 있다.
TokenExpiredError는 무엇인가?
NestJS에서 JWT 인증을 구현할 때 JwtService 등을 이용해 토큰을 검증할 수 있다. 다음은 동작을 설명하기 위한 예시 코드다.
async function verifyAccessToken(
accessToken: string,
) {
try {
return jwtService.verify(accessToken);
} catch (error) {
if (error instanceof TokenExpiredError) {
// Access Token 만료
// Refresh Token 갱신 흐름으로 이동
}
throw error;
}
}TokenExpiredError는 JWT에 설정된 만료 시간인 exp가 지났을 때 발생한다. 하지만 여기서 한 가지 구분해야 하는 사실이 있다. Access Token이 만료되었다는 것과 사용자의 로그인 세션이 완전히 종료되었다는 것은 같은 의미가 아니다. Access Token의 유효 기간이 10분이라도 Refresh Token이 유효하다면 새로운 Access Token을 발급받을 수 있다.
이 구조는 보안성과 사용자 편의성을 동시에 고려하기 위해 사용된다. 그렇다면 당시에는 왜 갱신이 실패했을까?
5. Refresh Token 재발급에서 발생한 invalid_grant
IDP에 Refresh Token을 전달한 결과 다음 오류가 발생했다.
HTTP 400 Bad Request
{
"error": "invalid_grant"
}OAuth 2.0에서 invalid_grant는 전달한 Authorization Grant나 Refresh Token이 유효하지 않을 때 나타날 수 있는 오류다.
대표적인 경우는 다음과 같다.
- Refresh Token이 만료된 경우
- 로그아웃 등으로 Refresh Token이 폐기된 경우
- 이미 사용된 토큰의 재사용이 금지된 경우
- 다른 클라이언트에 발급된 토큰을 사용한 경우
- 토큰 자체가 유효하지 않은 경우
여기서 주의할 점이 있다.
invalid_grant가 발생했다는 사실만으로 '로그아웃 때문에 Refresh Token이 폐기되었다'고 확정할 수는 없다. 당시 확인한 로그로는 IDP가 Refresh Token을 거부했다는 것까지 알 수 있었다. 하지만 실제 거부 원인이 만료인지, 폐기인지, 재사용인지 확인하려면 IDP 측 토큰 발급·폐기 기록이 더 필요했다.
즉, 확인된 사실과 가설을 구분하면 다음과 같다.
이 구분은 장애 분석에서 상당히 중요하다. 로그에서 A 다음에 B가 발생했다고 해서 반드시 A가 B의 원인인 것은 아니기 때문이다. 특히 인증 시스템처럼 여러 서비스가 연결된 구조에서는 하나의 로그만으로 전체 원인을 단정하기 어렵다.
6. Redis 세션은 왜 남아 있었을까?
이제 Redis를 확인할 차례였다.
에디터에서는 인증 과정에서 Redis를 사용하고 있었고, 다음과 같은 형태의 키를 관리하고 있었다.
auth_idp_*
user_token_set
lock:refresh각 키의 성격을 정리하면 다음과 같다.
인증 세션은 일부 경우 24시간 단위로 관리되고 있었다. 이 부분에서 주목할 점이 있었다. Access Token의 유효 기간과 Redis 세션의 유효 기간은 서로 다를 수 있다는 것이다. 예를 들어 다음과 같은 구성을 생각해 보자.
Access Token TTL : 10분
Redis Session TTL: 24시간오후 5시에 로그인한 뒤 브라우저를 종료하고, 다음 날 오전 9시에 다시 접근했다고 가정해 보자. Access Token은 이미 만료되어 있을 것이다. 하지만 Redis 세션은 아직 살아 있을 수 있다.
이 자체는 잘못된 설계가 아니다.
문제는 Redis 세션이 살아 있다는 이유만으로 사용자의 인증 상태가 유효하다고 판단하는 경우다. 특히 IDP에서 사용자가 로그아웃했더라도 에디터 API가 그 사실을 전달받지 못했다면 로컬 세션은 남아 있을 수 있다.
인증 시스템의 상태를 따로 살펴보면
[IDP]
로그아웃 처리 또는 토큰 무효화
|
| 로그아웃 이벤트 동기화 필요
v
[에디터 API]
기존 로그인 세션이 남아 있을 수 있음
|
v
[Redis]
auth_idp_* 키가 아직 존재할 수 있음
|
v
[Browser]
기존 세션 쿠키를 계속 전송할 수 있음이러한 상황에서는 브라우저, 에디터 API, IDP가 서로 다른 인증 상태를 인식할 가능성이 있다. 다만 당시 발생한 계정 불일치의 직접적인 원인이 Redis TTL이었다고 확인된 것은 아니다. Redis 세션의 만료 정책과 무효화 시점은 검증해야 할 중요한 지점이었다.
7. 원인을 찾기 위해 인증 흐름을 다시 나눠 보았다
처음에는 로그인과 로그아웃을 각각 하나의 기능으로 생각했다. 하지만 실제로 문제를 추적하면서 인증 과정은 여러 개의 독립된 상태 변경으로 이루어져 있다는 사실을 확인했다. 대표적으로 다음 네 가지를 구분할 필요가 있었다.
① 브라우저의 쿠키
브라우저는 이전에 발급받은 쿠키를 보관하고 있다가 요청 조건에 따라 서버에 전송한다. 쿠키가 존재한다는 사실 자체가 인증 성공을 의미하지는 않는다. 하지만 서버가 쿠키를 세션 조회 키로 사용한다면, 어떤 쿠키가 전달되는지에 따라 조회되는 세션이 달라질 수 있다. 특히 서로 다른 관리자 도메인과 에디터 도메인을 사용하는 환경에서는 다음 속성이 중요하다.
DomainPathSameSiteSecureHttpOnlyMax-Age또는Expires
예를 들어 다음 쿠키를 살펴보자.
Set-Cookie: builder_session=abc123;
Path=/;
Secure;
HttpOnly;
SameSite=Lax위 예시는 Domain을 지정하지 않았기 때문에 쿠키를 설정한 호스트를 기준으로 적용된다. 반면 Domain을 명시한 경우에는 유효한 하위 도메인에도 쿠키가 전송될 수 있다. 또한 로그아웃할 때는 쿠키를 설정했던 도메인 및 경로 범위를 고려해 만료시켜야 한다. 관리자에서 로그아웃했다고 해서 별도 호스트에 존재하는 빌더 쿠키까지 자동으로 삭제되는 것은 아니다. 따라서 브라우저 개발자 도구를 통해 실제 쿠키 전송 여부를 확인하는 것이 중요하다.
② 에디터 API의 Redis 세션
Redis 세션이 남아 있다면 서버는 해당 세션을 조회할 수 있다. 따라서 로그아웃 과정에서 어떤 Redis 키를 삭제하는지 확인해야 한다. 예를 들어 인증 세션만 삭제하고 별도의 사용자-토큰 인덱스가 남아 있다면, 후속 조회 로직이 그 인덱스를 어떻게 사용하는지도 살펴봐야 한다.
단순히 DEL 명령어를 한 번 실행하는 것으로 끝나는 문제가 아닐 수 있다.
③ IDP의 인증 세션
IDP는 별도의 인증 상태를 관리한다. 사용자가 IDP에서 로그아웃했다면 IDP의 세션 또는 관련 토큰에 변화가 발생할 수 있다. 하지만 에디터가 관리하는 세션을 IDP가 직접 삭제해 주는 것은 아니다. 이를 위해서는 별도의 로그아웃 동기화 메커니즘이 필요하다.
④ 관리자에서 에디터로 진입하는 방식
관리자 시스템에서 에디터를 열 때 항상 새로운 인증 절차를 수행하는지도 확인해야 한다. 당시 확인한 흐름에서는 진입 경로에 따라 차이가 있었다.
일부 경로에서는 에디터 API의 인증 엔드포인트를 거쳐 새로운 IDP 인증 흐름을 수행했다. 반면 다른 진입 경로에서는 기존 에디터 세션을 먼저 사용했을 가능성을 확인해야 했다. 이 차이는 중요한 단서였다.
같은 사용자라도 어떤 URL과 인증 경로를 통해 진입했는지에 따라 서로 다른 세션 처리 로직을 탈 수 있기 때문이다.
8. 로그아웃 이벤트를 에디터에 전달하려면?
여기까지 분석하면서 가장 중요하게 생각한 부분은 로그아웃 동기화였다. IDP와 에디터 API가 각각 인증 상태를 관리한다면, 로그아웃 이벤트를 어떻게 전달할지 명확히 정해야 한다. OpenID Connect에서는 이를 위한 여러 방식이 정의되어 있다. 그중 하나가 Back-Channel Logout이다.
Back-Channel Logout이란?
사용자의 브라우저를 경유하지 않고 IDP와 서비스 서버가 직접 통신해 로그아웃 사실을 전달하는 방식이다.
전체 흐름은 다음과 같다.
[사용자]
|
| 로그아웃
v
[IDP]
|
| Logout Token 전송
| Server-to-Server
v
[Builder API]
|
| Logout Token 검증
| 대상 세션 식별
v
[Redis]
|
| 세션 무효화
v
[다음 API 요청]
|
v
401 Unauthorized
또는 재인증이 방식의 장점은 브라우저가 열려 있지 않더라도 서버 간 통신으로 로그아웃을 전달할 수 있다는 점이다. 일반적인 OIDC Back-Channel Logout에서는 IDP가 등록된 엔드포인트에 logout_token을 전송한다.
에디터는 전달받은 토큰을 검증하고 sid 또는 sub 등의 정보를 이용해 대상 세션을 찾아 무효화할 수 있다. 다만 여기서 중요한 구현 조건이 있다.
로그아웃 요청을 받았다고 해서 요청 본문에 들어 있는 사용자 ID만 믿고 세션을 삭제해서는 안 된다.
Logout Token의 서명과 발급자, 대상 클라이언트, 이벤트 정보, 만료 시간 등을 검증해야 한다.
NestJS에서는 어떻게 구현할 수 있을까?
다음은 OIDC Back-Channel Logout을 구현할 때 사용할 수 있는 구조적 예시다. 실제 프로젝트의 코드를 그대로 옮긴 것은 아니며, 설계 방향을 설명하기 위한 코드다.
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
) {}
@Post('backchannel-logout')
@HttpCode(200)
async backchannelLogout(
@Body('logout_token') logoutToken: string,
) {
await this.authService.handleBackchannelLogout(
logoutToken,
);
return { success: true };
}
}인증 서비스에서는 다음과 같은 처리가 필요하다.
async handleBackchannelLogout(
logoutToken: string,
): Promise<void> {
// 1. IDP 공개키를 이용해 서명 검증
// 2. iss, aud, iat, events 검증
// 3. nonce가 없는지 확인
// 4. jti 재사용 방지
// 5. sid 또는 sub 확인
const payload =
await this.verifyLogoutToken(logoutToken);
// sid가 있는 경우 해당 세션을 우선 식별
// sid가 없는 경우 issuer + sub 기준으로
// 대상 사용자 세션을 조회하는 정책 필요
await this.sessionService.invalidateSessions(
payload,
);
}실제로 구현할 때는 Redis의 세션 키와 IDP의 세션 식별자를 어떻게 연결할지도 설계해야 한다. 예를 들어 sid로 세션을 제거하려면 해당 sid를 내부 세션과 매핑할 수 있어야 한다. 또한 동일 사용자가 여러 브라우저나 기기에서 로그인한 경우, 특정 세션만 종료할지 전체 세션을 종료할지에 대한 정책도 필요하다. 이 코드는 로그아웃 동기화의 기본 구조를 설명하는 예시일 뿐, 이것만으로 완성된 OIDC 구현이 되는 것은 아니다.
9. 토큰 갱신 실패 처리도 중요했다
로그아웃 동기화뿐만 아니라 Refresh Token 갱신 실패 후의 처리도 점검할 필요가 있었다. 인증 미들웨어나 Guard에서 Access Token이 만료되었을 때 자동으로 재발급을 시도한다면, 실패 상황까지 고려해야 한다. 예를 들어 다음과 같은 정책을 생각해 볼 수 있다.
async function refreshAuthentication(
sessionId: string,
refreshToken: string,
) {
try {
const tokens =
await idpClient.refreshToken(refreshToken);
await sessionStore.updateTokens(
sessionId,
tokens,
);
return tokens;
} catch (error) {
if (isInvalidGrant(error)) {
await sessionStore.invalidate(sessionId);
throw new UnauthorizedException(
'Reauthentication required',
);
}
throw error;
}
}위 코드에서 핵심은 invalid_grant가 발생했을 때 기존 인증 상태를 계속 신뢰하지 않는 것이다. 재발급에 실패했음에도 기존 인증 세션을 사용하도록 두면 만료된 인증 정보를 반복해서 처리할 수 있다. 다만 모든 갱신 오류를 동일하게 처리해서는 안 된다.
예를 들어 IDP가 일시적으로 연결되지 않아 재발급에 실패한 경우와 IDP가 Refresh Token 자체를 거부한 경우는 성격이 다르다.
특히 여러 API가 동시에 호출되는 환경에서는 토큰 갱신 경쟁 상태도 고려해야 한다. 예를 들어 Access Token이 만료된 시점에 API 요청이 다섯 개 발생했다고 가정해 보자. 각 요청이 모두 Refresh Token을 이용해 재발급을 시도하면, IDP의 토큰 회전 정책에 따라 예상하지 못한 문제가 발생할 수 있다.
이런 상황에서는 Redis의 SET NX EX 등을 활용해 동일 세션의 갱신 요청을 제한하는 방법을 고려할 수 있다. 단, 락만 추가한다고 모든 경쟁 상태가 해결되지는 않는다. 토큰 갱신 성공 후 다른 요청이 최신 토큰을 읽는 과정까지 함께 설계해야 한다.
10. 재현 테스트를 어떻게 구성해야 할까?
인증 관련 문제는 한 번 재현되었다고 해서 모든 상황을 설명할 수 있는 것은 아니다. 특히 쿠키와 세션이 관련된 문제는 브라우저 상태에 따라 결과가 달라질 수 있다. 따라서 테스트 케이스를 명확히 분리하는 것이 중요하다.
계정 전환 테스트
여기서 반드시 확인해야 할 항목이 있다.
클라이언트가 표시하는 사용자 정보와 서버가 실제로 인증한 사용자 정보가 일치하는가?

프론트엔드의 사용자 표시만으로는 세션 문제가 해결되었다고 판단하기 어렵다. 로그인 후 인증이 필요한 API를 호출하고, 서버에서 검증한 사용자 식별자와 요청에 연결된 세션의 주체가 일치하는지 확인해야 한다.
로그는 어떤 정보를 남겨야 할까?
이런 문제를 다시 겪지 않기 위해서는 인증 로그를 구조적으로 기록하는 것이 좋다고 생각했다.
예를 들면 다음과 같다.
{
"event": "AUTH_REFRESH_FAILED",
"requestId": "req-123",
"sessionId": "masked-session-id",
"userId": "user-A",
"reason": "invalid_grant",
"action": "SESSION_INVALIDATED",
"timestamp": "2026-09-22T08:53:16+09:00"
}여기서 requestId는 하나의 요청이 어떤 과정을 거쳤는지 추적하는 데 도움이 된다.
sessionId와 userId 역시 실제 운영 환경에서는 개인정보와 보안 정책을 고려해 기록 범위를 제한하거나 가명화할 필요가 있다.
특히 Access Token, Refresh Token, 세션 쿠키 원문을 로그에 남기는 것은 피해야 한다. 가능하다면 인증 이벤트를 다음과 같이 구분해 기록하는 것도 좋다.
AUTH_LOGIN_STARTED
AUTH_LOGIN_SUCCEEDED
AUTH_SESSION_CREATED
AUTH_TOKEN_EXPIRED
AUTH_REFRESH_STARTED
AUTH_REFRESH_SUCCEEDED
AUTH_REFRESH_FAILED
AUTH_SESSION_INVALIDATED
AUTH_LOGOUT_RECEIVED이런 이벤트 로그가 있다면 장애가 발생했을 때 인증 흐름을 훨씬 빠르게 추적할 수 있을 것이다.
11. 이번 분석에서 확인한 것과 남은 것
블로그를 작성하다 보면 문제의 원인을 하나로 정리하고 싶어진다. 하지만 모든 장애가 로그 몇 줄만으로 완벽하게 설명되는 것은 아니다. 이번 문제에서도 확실하게 확인한 부분과 추가 검증이 필요한 부분이 존재했다.
확인한 내용
- 이전 계정의 토큰이 다음 날 인증 과정에서 검증되는 현상이 있었다.
- 해당 Access Token은 이미 만료된 상태였다.
- 서버는 Refresh Token을 통한 재발급을 시도했다.
- IDP가
invalid_grant오류를 반환했다. - 확인된 오류 처리 흐름에서는 Redis 인증 세션을 삭제하고 HTTP 401을 반환했다.
- 다른 계정으로 로그인한 뒤 이전 계정이 나타나는 재현 사례가 있었다.
- 관리자에서 에디터로 진입하는 인증 경로의 차이도 분석 대상이었다.
추가 확인이 필요한 내용
- IDP가 Refresh Token을 거부한 정확한 사유
- 이전 계정의 쿠키가 어느 요청에서 다시 전송되었는지
- 기존 Redis 세션이 새 로그인 흐름에서 선택된 정확한 코드 경로
- IDP 로그아웃 이벤트가 Builder API에 전달되는지 여부
- 관리자별 에디터 진입 경로에서 인증 세션을 생성·교체하는 조건
따라서 이 사례를 단순히 'Redis TTL 때문에 발생한 버그'라고 결론 내릴 수는 없다.
확실한 것은 인증 상태를 관리하는 여러 구성 요소가 서로 연결되어 있고, 이들의 상태 변경 과정을 함께 살펴봐야 한다는 점이었다.
12. 이번 경험을 통해 배운 것
처음 이 문제를 마주했을 때는 로그아웃 API가 정상적으로 실행되는지만 확인하면 될 것이라고 생각했다. 하지만 실제로는 그보다 훨씬 많은 부분을 살펴봐야 했다. 사용자가 로그아웃 버튼을 누르는 것과 인증 시스템 전체에서 해당 세션이 무효화되는 것은 다른 문제였다.
Access Token이 만료되었다고 해서 Redis 세션이 삭제되는 것은 아니며, IDP에서 로그아웃했다고 해서 다른 서비스의 세션이 자동으로 종료되는 것도 아니다.
이번 문제를 분석하면서 특히 크게 느낀 점이 세 가지 있었다.
첫째, 인증은 하나의 토큰을 검증하는 기능이 아니라 여러 상태의 일관성을 관리하는 과정이다.
JWT, Cookie, Redis, IDP가 각각 어떤 책임을 가지고 있는지 명확하게 구분해야 한다.
둘째, 인증 장애는 성공 흐름보다 실패 흐름이 더 중요할 때가 많다.
토큰이 만료되었을 때, 갱신이 실패했을 때, 사용자가 다른 계정으로 로그인했을 때 시스템이 어떤 상태로 전환되는지 고민해야 한다.
셋째, 로그에서 확인한 사실과 추측한 원인은 반드시 분리해야 한다.
invalid_grant가 발생했다는 사실은 알 수 있어도, 정확히 왜 발생했는지까지 알 수 있는 것은 아니다.
그 차이를 구분하는 것이 올바른 트러블슈팅의 시작이라고 생각한다.
마무리하며
개발을 하다 보면 로그인 성공 여부에만 집중할 때가 있다. 하지만 운영 환경에서 중요한 것은 그 이후의 과정이라는 생각이 들었다. 토큰이 만료될 때, 세션이 변경될 때, 계정을 전환할 때, 다른 서비스에서 로그아웃할 때도 인증 상태가 일관되게 유지되어야 한다. 이번 경험은 JWT와 Redis를 어떻게 사용하는지 아는 것에서 한 단계 더 나아가, 인증 시스템 전체의 상태가 어떻게 연결되는지를 생각해 보는 계기가 되었다.
그리고 여전히 가장 중요하다고 생각하는 질문은 이것이다.
지금 이 API 요청을 보내는 사용자가 누구인지, 서버는 무엇을 근거로 확신하고 있을까?
인증 시스템을 개발한다면 항상 고민해야 할 질문인 것 같다.
참고 자료
본 글은 실무에서 경험한 인증 이슈를 바탕으로 작성했으며, 내부 시스템 식별자와 계정 정보는 변경했습니다. 일부 구현 예시는 개념을 설명하기 위해 재구성한 코드입니다.
댓글
익명 캐릭터로 가볍게 이야기를 남겨 주세요.