Flutter 앱 자동 로그아웃 문제 해결기: Refresh Token Race Condition 디버깅

J_Eddy·4일 전
post-thumbnail

0.003초 차이가 만든 자동 로그아웃 버그

문제 발견

앱을 테스트하던 중 이상한 현상을 발견했습니다. 로그인 후 한 시간 정도 지나서 앱을 다시 켜면 정상적으로 작동하는데, 이 때 앱을 완전히 종료했다가 바로 다시 실행하면 로그아웃되어 있었습니다.

처음에는 클라이언트 쪽 문제인가 싶었는데, 서버 로그를 확인해보니 refresh token 갱신 과정에서 에러가 발생하고 있었습니다.

ObjectOptimisticLockingFailureException: Row was updated or deleted by another transaction

그리고 그 전날 로그에는 이런 에러도 있었습니다.

DataIntegrityViolationException: duplicate key value violates unique constraint

원인 파악

로그를 자세히 보니 패턴이 보이기 시작했습니다.

00:17:40.182 [exec-9] POST "/auth/refresh"
00:17:40.182 [exec-7] POST "/auth/refresh"  // 동시 요청

Access token이 만료되면 클라이언트에서 여러 API를 동시에 호출하는데, 이 API들이 모두 401 에러를 받으면서 거의 동시에 refresh token 요청을 보냅니다. 두 요청의 시간 차이는 정확히 0.003초였습니다.

JWT 생성 로직에도 문제가 있었습니다. Instant.now()를 사용해서 iat(발급 시각)을 설정했는데, Unix timestamp는 초 단위만 기록합니다. 같은 초에 생성되다 보니 동일한 JWT 토큰이 만들어져 에러가 발생했습니다.

T1 (17.529s): Request A - JWT 생성 (iat: 1765109537)
T2 (17.532s): Request B - 동일한 JWT 생성 (같은 초)
T9 (17.566s): Request B - 200 OK
T10 (17.569s): Request A - UNIQUE 제약 위반으로 500 에러

추가로 기존 토큰 갱신 로직에도 문제가 있었습니다.

  1. 기존 토큰을 DB에서 삭제
  2. 새 토큰을 INSERT

두 요청이 동시에 들어오면:

  • 첫 번째 요청: 토큰 삭제 성공 → 새 토큰 INSERT 성공
  • 두 번째 요청: 토큰 삭제 시도 → 이미 삭제됨 → ObjectOptimisticLockingFailureException

결국 한쪽 요청은 에러를 받고, 클라이언트는 이를 인증 실패로 판단해 로그아웃 처리했습니다. (클라이언트단에서 refresh token api 사용 시 에러가 발생하면 로그아웃 되게 만들었었습니다.)

해결 방법

문제는 두 가지였습니다:
1. 같은 초에 생성된 JWT가 완전히 동일함
2. DELETE-INSERT 과정에서 발생하는 동시성 문제

JWT에 고유 ID 추가

jti(JWT ID) claim에 UUID를 추가해서 같은 초에 생성된 토큰도 유니크하게 만들었습니다.

.setId(UUID.randomUUID().toString()) // jti claim 추가

이제 토큰의 발급 시간이 같아도 각각 다른 UUID를 가지기 때문에 UNIQUE 제약 위반은 사라졌습니다.

하지만 동시성 문제는 여전히 남아있었습니다. DELETE-INSERT를 UPDATE로 바꾸는 것만으로는 아래와 같은 문제가 있었습니다.

두 요청이 동시에 UPDATE를 시도하면:

  • 첫 번째 요청: 토큰 A 생성 → UPDATE
  • 두 번째 요청: 토큰 B 생성 → UPDATE (덮어씀)

결국 첫 번째 요청을 받은 클라이언트는 무효한 토큰 A를 받게 됩니다.

동시에 여러 요청이 와도 순서대로 처리되게 하는 것이 목표었습니다.

Pessimistic Lock으로 순서 보장

낙관적 락도 고민을 하였으나, 낙관적락을 사용할 경우 재시도를 해야하는데, 이미 클라이언트 단에 응답을 보냈던거라 고려대상에서 제외되었습니다.

결국 DB 레벨에서 row를 잠그도록 했습니다. 같은 사용자에 대한 요청이 동시에 오면, 첫 번째 요청이 처리되는 동안 두 번째 요청은 대기합니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT r FROM RefreshTokenEntity r WHERE r.userId = :userId")
Optional<RefreshTokenEntity> findByUserIdWithLock(@Param("userId") Long userId);

DELETE 제거, UPDATE만 사용

문제의 근본 원인이었던 DELETE를 지우고, 같은 레코드의 token_value만 UPDATE합니다.

private String createAndSaveRefreshToken(Long userId) {
    RefreshTokenEntity token = refreshTokenRepository.findByUserIdWithLock(userId)
        .map(existing -> {
            existing.setTokenValue(newTokenValue);
            existing.setExpiresAt(expiresAt);
            return existing;
        })
        .orElse(/* 첫 로그인 시만 새로 생성 */);

    refreshTokenRepository.save(token);
    return newTokenValue;
}

DB 제약 조건 추가

한 사용자당 하나의 refresh token만 존재하도록 DB 제약을 추가했습니다.

CREATE UNIQUE INDEX uk_user_id ON refresh_token(user_id);

배포 후

변경사항을 배포한 후 며칠간 모니터링했는데, 더 이상 예외가 발생하지 않았습니다. 앱을 종료하고 바로 다시 켜도 로그인 상태가 유지되는 것을 확인했습니다.

Pessimistic Lock으로 인한 성능 저하가 걱정됐지만, refresh token 갱신은 한 시간에 한 번 정도만 발생하고 user_id별로 lock이 걸리기 때문에 체감할 수 있는 영향은 없었습니다.

마무리

동시성 제어에 대해 다시한번 해결 할 수 있던 경험이었고, 로그를 통하여 문제의 원인을 파악했습니다. 0.003초 차이가 만든 문제를, UUID 하나와 DB Lock으로 해결한 이야기였습니다.

profile
논리적으로 사고하고 해결하는 것을 좋아하는 개발자입니다.

0개의 댓글