Skip to content

fix(auth): 동시 갱신이 겹쳐 GitHub 연동이 지워지던 문제 - #166

Merged
dldnsgkr merged 1 commit into
developfrom
unhak/token-refresh-race
Aug 19, 2026
Merged

fix(auth): 동시 갱신이 겹쳐 GitHub 연동이 지워지던 문제#166
dldnsgkr merged 1 commit into
developfrom
unhak/token-refresh-race

Conversation

@dldnsgkr

Copy link
Copy Markdown
Collaborator

Closes #162

문제

#158한 요청 안에서 갱신이 두 번 일어나는 경우를 막았다. 그러나 동시에 들어온 두 실행 흐름은 막지 못했다. 커밋된 토큰을 먼저 읽는 가드와 실제 갱신 사이에 잠금이 없기 때문이다.

// 수정 전
Optional<String> alreadyRefreshed = readValidAccessToken(userId);   // ① 둘 다 여기서 "없음"
if (alreadyRefreshed.isPresent()) return alreadyRefreshed.get();
...
githubAppPort.refreshUserToken(user.getGithubUserRefreshToken());    // ② 둘 다 같은 토큰으로 간다

GitHub 은 리프레시 토큰을 매번 회전시킨다. 먼저 도착한 A 만 성공하고 B 는 bad_refresh_token 을 맞는데, 그 catch 가 clearAndCommit 으로 A 가 방금 받아온 멀쩡한 토큰까지 지운다. 사용자는 아무 잘못 없이 재인증을 하게 된다.

창이 넓어졌다. 예전에는 사용자 요청뿐이었지만 지금은 DomainVerificationWorker(#160)와 StuckDeploymentRecoveryWorker(#161)가 1분마다 같은 경로를 탄다. 액세스 토큰 만료(8시간)마다 겹칠 기회가 열린다.

실제로 어제 dev 와 운영 양쪽에서 연동이 끊긴 것을 겪었다.

해결

갱신 구간 전체를 유저 행 잠금으로 직렬화한다.

@Transactional(propagation = REQUIRES_NEW, noRollbackFor = ForbiddenException.class)
public String refreshWithLock(Long userId) {
    User user = userRepository.findByIdForUpdate(userId)...;   // SELECT ... FOR UPDATE

    // 기다리는 동안 앞선 흐름이 갱신했을 수 있다 — 여기서 멈추는 것이 이 잠금의 핵심이다
    if (!user.isUserAccessTokenExpired() && hasToken) return user.getGithubUserAccessToken();
    ...
}

뒤에 온 흐름은 앞이 끝날 때까지 기다렸다가, 잠금을 얻은 뒤 상태를 다시 보고 이미 갱신돼 있으면 GitHub 을 부르지 않는다.

왜 한 메서드 안에서 다 끝내나

잠금은 저장과 같은 트랜잭션에 있어야 한다. 바깥에서 잠그고 저장만 REQUIRES_NEW 로 떼면, 안쪽 트랜잭션이 바깥이 쥔 잠금을 기다리다 교착된다. 그래서 잠금·조회·GitHub 호출·저장을 한 REQUIRES_NEW 안에서 끝낸다. 호출자가 롤백돼도 갱신이 남아야 한다는 기존 요구도 그대로 만족한다 — GitHub 은 이미 옛 리프레시 토큰을 버렸으니까.

ForbiddenException롤백 대상에서 뺐다. bad_refresh_token 일 때 토큰을 지운 뒤 예외를 던지는데, 롤백되면 그 지움이 사라져 다음 호출이 같은 무효 토큰으로 또 GitHub 에 간다.

빠른 경로는 유지

잠금 없이 커밋된 토큰을 읽는 경로(#158)를 그대로 뒀다. 대부분의 호출은 여기서 끝나고 DB 잠금까지 가지 않는다. 잠금은 실제로 갱신이 필요한 순간에만 잡힌다.

정리

GithubTokenCleanerGithubUserTokenRefresher 로 흡수했다. 이름과 달리 저장·삭제·조회를 모두 갖고 있었고, 이제 갱신까지 한 곳이 소유하는 편이 맞다. 이 클래스 외에 쓰던 곳은 없었다.

검증

  • 마이그레이션 없음. 잠금 조회(findByIdForUpdate) 하나만 추가
  • GithubUserTokenRefresherTest 7개: 잠금을 기다린 흐름이 승자의 토큰을 쓰는지(핵심) / 첫 흐름은 실제로 갱신·저장하는지 / 리프레시 토큰이 없으면 "연동되지 않았습니다" / 거부되면 연동을 지우는지 / 무관한 실패(5xx)로는 지우지 않는지 / 빠른 경로가 잠금을 잡지 않는지 / 만료면 빈 값
  • AuthCommandServiceTest 갱신 — 빠른 경로에서 끝나면 잠금 경로로 가지 않는지, 아니면 넘기는지
  • ./gradlew test 전체 통과

남는 것

여러 인스턴스로 늘리면 이 잠금은 그대로 유효하다(DB 행 잠금이라 인스턴스 수와 무관하다). 다만 잠금을 기다리는 스레드가 GitHub 호출 시간만큼 묶인다 — 갱신은 유저당 8시간에 한 번이라 지금 규모에서는 문제가 아니지만, 갱신이 잦아지면 대기 시간을 재봐야 한다.

🤖 Generated with Claude Code

#158 은 한 요청 안에서 갱신이 두 번 일어나는 경우를 막았지만, 동시에 들어온 두
실행 흐름은 막지 못했다. 커밋된 토큰을 먼저 읽는 가드와 실제 갱신 사이에 잠금이
없어서, 둘 다 그 틈을 통과하면 같은 리프레시 토큰을 들고 GitHub 에 간다. GitHub 은
리프레시 토큰을 매번 회전시키므로 먼저 도착한 쪽만 성공하고, 뒤에 온 쪽은
bad_refresh_token 을 맞은 뒤 catch 에서 연동을 통째로 지운다 — 앞선 흐름이 방금
받아온 멀쩡한 토큰까지 함께 사라진다.

갱신 경로에 진입하는 주체가 늘어 창도 넓어졌다. 예전에는 사용자 요청뿐이었지만
지금은 DomainVerificationWorker(#160)와 StuckDeploymentRecoveryWorker(#161)가
1분마다 같은 경로를 탄다.

갱신 구간 전체를 유저 행 잠금으로 직렬화한다. 뒤에 온 쪽은 앞이 끝날 때까지
기다렸다가 잠금을 얻은 뒤 상태를 다시 보고, 이미 갱신돼 있으면 GitHub 을 부르지
않고 그 토큰을 그대로 쓴다.

잠금은 저장과 같은 트랜잭션에 있어야 한다. 바깥에서 잠그고 저장만 REQUIRES_NEW 로
떼면 안쪽이 바깥의 잠금을 기다리다 교착된다. 그래서 GithubUserTokenRefresher 의
메서드 하나가 잠금·조회·GitHub 호출·저장을 모두 REQUIRES_NEW 안에서 끝낸다.
ForbiddenException 은 롤백 대상에서 뺐다 — bad_refresh_token 일 때 토큰을 지우고
예외를 던지는데, 롤백되면 그 지움이 사라져 다음 호출이 같은 무효 토큰으로 또 간다.

빠른 경로(잠금 없이 커밋된 토큰 읽기)는 그대로 남겨, 대부분의 호출은 DB 잠금까지
가지 않는다.

GithubTokenCleaner 는 이 클래스로 흡수했다. 이름과 달리 저장·삭제·조회를 모두
갖고 있었고, 이제 갱신까지 한 곳에서 소유하는 편이 맞다.

Closes #162

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@dldnsgkr
dldnsgkr merged commit f2c243e into develop Aug 19, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant