1. 개요

DoEatFit 서비스를 활발하게 이용하시는 유저분들로부터 30분 단위로 세션이 갑자기 끊기며 로그인 폼으로 강제 쫓겨난다는 당혹스러운 제보가 들어왔습니다. 더불어 로그아웃이 완료되어 UI 상으로는 로그인창이 노출되지만 브라우저 백그라운드에서는 실물 토큰 쿠키가 살아있어 뒷단 통신 API는 버젓이 성공하는 이른바 ‘좀비 로그인(Zombie Login)’ 기현상이 함께 발견되었습니다. 사용자 보안에 큰 위협을 가하던 이 정합성 결함을 백엔드의 성급한 세션 무효화 조건 수정 및 프론트엔드의 무조건적 클리너 강제화를 통해 완벽하게 해결한 과정을 꼼꼼히 정리합니다.


2. 핵심 내용

2-1. 🚨 주요 문제 현상 및 근본 원인 분석

  • 현상:
    • 정확한 30분 주기 세션 만료: 식단을 열심히 기록하던 도중 정확히 30분 주기로 로그인 세션이 풀리면서 화면이 백지화되거나 강제 추방되는 오동작이 다수 재발했습니다.
    • 좀비 로그인 (Zombie Login) 정합성 엇박자: 화면은 로그인 버튼을 띄우는 완전한 로그아웃 뷰포트 상태임에도, 일지를 등록하면 백엔드가 200 OK를 리턴하며 정상 작동하는 상태 분리가 확인되었습니다.
  • 원인:
    1. 백엔드: 토큰 Rotation의 과도한 중복 로그인 청소기 작동: Access Token(AT) 수명은 보안 정책상 30분이며 만료 시 Refresh Token(RT)을 사용해 자동으로 /reissue-token API를 날려 토큰 재발급(Rotation)을 받습니다. 이때 백엔드의 신규 토큰 발급 메서드가 작동할 때마다 유저의 기존 Redis 세션을 모조리 무효화하는 deleteAllTokensForUser 로직이 무차별적으로 호출되는 버그가 있었습니다. 멀티 탭이나 모바일 PWA 환경에서 한 탭이 토큰을 갱신하면 다른 탭의 세션이 일방적으로 폭파당했던 것입니다.
    2. 프론트엔드: LocalStorage와 Cookie의 절름발이 동화: 인증 에러(401) 감지 시 로컬 메모리(localStorageIsAuthenticated 플래그)를 먼저 소거하고 로그아웃 상태로 분기하는데, 이후 서버 측 세션 쿠키를 지워주는 logoutAction을 순차 처리해야 하지만 이미 로컬 상태가 false로 변했기 때문에 내부 예외 리스너 조건부 필터(if (!isAuthenticated) return)가 무의식중에 동작을 스킵하여 실물 토큰 쿠키가 브라우저에 좀비처럼 살아남았던 것입니다.

2-2. 💡 프론트엔드와 백엔드의 유기적 해결책

  • 백엔드: 세션 숙청 로직의 도메인 위치 격리: Redis 토큰 전체 무효화 함수(deleteAllTokensForUser)를 공통 토큰 발급기 내부에서 분리 차단하고, 오직 사용자가 직접 ID/PW를 치고 정식 진입하는 로그인 최초 시점에만 가동되도록 AuthService.login 내부로 격리 이관하여 멀티 세션 간 충돌을 방어했습니다.
  • 코드 명세:
// AuthService.java 로그인 시점에만 한정하여 기존 중복 로그인 토큰 청소 가동
@Transactional
public TokenResponseDto login(LoginRequestDto request) {
    Authentication authentication = authenticationManager.authenticate(
        new UsernamePasswordAuthenticationToken(request.getEmail(), request.getPassword())
    );
    
    // 💡 로그인 최초 시점에만 중복 세션 파괴 처리 진행
    tokenUtil.deleteAllTokensForUser(request.getEmail());
    
    TokenSetDto tokenSet = tokenUtil.issueTokens(request.getEmail());
    return TokenResponseDto.builder()
            .accessToken(tokenSet.getAccessToken())
            .expiresAt(tokenSet.getExpiresAt())
            .build();
}
  • 프론트엔드: 예외 감지 시 무조건적인 서버 청소 강제화: 인증 만료 감지 시 조건문으로 스킵을 걸어 두던 분기 필터를 제거하고, 내부 상태 유무에 무관하게 무조건 logoutAction과 쿠키 클리너를 호출하여 세션 정보의 흔적을 남김없이 폭파하도록 동화 리스너를 재정비했습니다.
  • 코드 명세:
// AuthContext.tsx
const handleSessionExpired = useCallback(async () => {
    console.warn("⚠️ 세션이 완전히 만료되었습니다. 안전한 로그아웃을 진행합니다.");
    try {
        // [개선] 내부 런타임 상태값 유무를 묻지 않고 무조건 서버 클리너와 쿠키를 함께 비웁니다.
        await logoutAction();
    } catch (error) {
        console.error("로그아웃 실행 중 예외:", error);
    } finally {
        setIsAuthenticated(false);
        setUser(null);
        localStorage.removeItem("is_authenticated");
    }
}, []);

2-3. ✅ 결과 검증 및 모던 오픈소스 라이브러리 검토

  • 최종 검증:
    • 멀티 탭 무장애 RTR: 여러 탭을 동시 다발적으로 활성화해 놓거나 PWA 백그라운드 전환 복귀를 행해도, 30분 경과 후 서로 다른 기기 세션을 건드리지 않는 정교한 RTR(Refresh Token Rotation) 프로세스가 물 흐르듯 수행됨을 검증했습니다.
    • 좀비 로그인 원천 봉쇄: 세션이 만료되는 즉시 브라우저의 쿠키와 로컬 스토어 로그인 플래그가 한 몸처럼 깔끔하게 “로그아웃” 상태로 리셋되며 UI 기현상이 완벽하게 퇴치됨을 실증했습니다.
  • 추천 오픈소스 라이브러리 검토:
    1. Axios Interceptors:
      • 평가: 클라이언트와 서버의 비동기 접점에서 401 Unauthorized 에러를 지연 가로채어, 사전에 세션 상태를 조율하고 Silent Refresh 처리를 유연하게 위임하기 위한 무조건적인 표준 미들웨어입니다.
    2. js-cookie:
      • 평가: 날 것의 document.cookie 문자열 파싱 렉과 누수를 배제하고, Cookies.remove 한 줄로 서브 도메인 및 경로 설정에 부합하게 토큰 쿠키 찌꺼기를 안전하게 긁어내 청소해주는 훌륭한 라이브러리로 추천합니다.