Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class AuthCommandService {
private final TokenBlacklistPort tokenBlacklistPort;
private final JwtProperties jwtProperties;
private final OAuthStateManager oAuthStateManager;
private final GithubTokenCleaner githubTokenCleaner;
private final GithubUserTokenRefresher tokenRefresher;

/**
* GitHub OAuth 로그인
Expand Down Expand Up @@ -171,42 +171,24 @@ public void linkGithubAppByCode(Long installationId, String code) {
*/
@Transactional
public String refreshGithubUserToken(Long userId) {
// 같은 요청 안에서 다른 경로가 이미 갱신했을 수 있다. 그 갱신은 REQUIRES_NEW 로 커밋되지만
// 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 여전히 만료로 보이므로, 커밋된 최신
// 상태를 따로 읽어 먼저 확인한다. 이 확인이 없으면 두 번째 호출이 이미 회전돼 무효가 된
// 리프레시 토큰을 들고 GitHub 에 가서 bad_refresh_token 을 맞고, 아래 catch 가 사용자의
// GitHub 연동을 통째로 지운다.
// 빠른 경로 — 다른 흐름이 이미 갱신했으면 잠금까지 가지 않는다. 그 갱신은 별도
// 트랜잭션으로 커밋되지만 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 여전히
// 만료로 보이므로, 커밋된 최신 상태를 따로 읽어 확인한다.
//
// 이 확인이 없으면 두 번째 호출이 이미 회전돼 무효가 된 리프레시 토큰을 들고 GitHub 에
// 가서 bad_refresh_token 을 맞고, 그 catch 가 사용자의 GitHub 연동을 통째로 지운다.
// 실제로 그렇게 날아갔다(2026-08-18 운영). 프로젝트 개요 조회 하나가 GitHub 을 두 번
// 호출하는데(최근 커밋 · 저장소 상태) 둘 다 이 경로를 타서, 화면을 여는 것만으로 연동이
// 끊기고 응답은 500 이 됐다.
Optional<String> alreadyRefreshed = githubTokenCleaner.readValidAccessToken(userId);
// 호출하는데(최근 커밋 · 저장소 상태) 둘 다 이 경로를 탔다.
Optional<String> alreadyRefreshed = tokenRefresher.readValidAccessToken(userId);
if (alreadyRefreshed.isPresent()) {
log.info("GitHub App User Token 갱신 생략 — 이미 갱신됨: userId={}", userId);
return alreadyRefreshed.get();
}

User user = userRepository.findById(userId)
.orElseThrow(() -> new NotFoundException("유저를 찾을 수 없습니다: " + userId));

if (user.getGithubUserRefreshToken() == null) {
throw new ForbiddenException("GitHub App이 연동되지 않았습니다. GitHub App을 다시 설치해 주세요.");
}

try {
GithubAppPort.GithubUserTokenInfo tokenInfo = githubAppPort.refreshUserToken(user.getGithubUserRefreshToken());
LocalDateTime expiresAt = LocalDateTime.now().plusSeconds(tokenInfo.expiresInSeconds());
githubTokenCleaner.saveAndCommit(userId, tokenInfo.accessToken(), tokenInfo.refreshToken(), expiresAt);
log.info("GitHub App User Token 갱신 완료: userId={}", userId);
return tokenInfo.accessToken();
} catch (IllegalStateException e) {
if (e.getMessage() != null && e.getMessage().contains("bad_refresh_token")) {
// REQUIRES_NEW 트랜잭션으로 커밋 — 현재 트랜잭션이 롤백되어도 클리어는 유지됨
githubTokenCleaner.clearAndCommit(userId);
throw new ForbiddenException("GitHub App 연동이 만료되었습니다. GitHub App을 다시 설치해 주세요.");
}
throw e;
}
// 느린 경로 — 유저 행을 잠그고 갱신한다. 위 확인과 실제 갱신 사이에는 틈이 있어서,
// 동시에 들어온 두 흐름이 둘 다 여기까지 올 수 있다. 잠금이 없으면 그 둘이 같은
// 리프레시 토큰을 들고 GitHub 에 가고, 뒤에 도착한 쪽이 연동을 지운다(#162).
return tokenRefresher.refreshWithLock(userId);
}

private String issueRefreshToken(Long userId) {
Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package com.example.dvely.auth.application.command;

import com.example.dvely.auth.application.port.out.GithubAppPort;
import com.example.dvely.auth.domain.model.User;
import com.example.dvely.auth.domain.repository.UserRepository;
import com.example.dvely.common.exception.ForbiddenException;
import com.example.dvely.common.exception.NotFoundException;
import java.time.LocalDateTime;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;

/**
* GitHub App User Token 갱신을 소유한다.
*
* GitHub 은 리프레시 토큰을 쓸 때마다 회전시킨다. 그래서 같은 유저의 갱신이 겹치면 뒤에 도착한
* 쪽이 이미 무효가 된 값을 들고 가 bad_refresh_token 을 맞고, 그 catch 가 연동을 통째로 지운다 —
* 앞선 흐름이 방금 받아온 멀쩡한 토큰까지 함께 사라진다. 사용자는 아무 잘못 없이 재인증을 해야
* 한다. 겹치는 조합은 드물지 않다. 사용자 요청뿐 아니라 도메인 검증·배포 회수 워커도 같은 경로를
* 타고, 그것들은 1분마다 돈다.
*
* 그래서 갱신 구간 전체를 유저 행 잠금으로 직렬화한다. 뒤에 온 쪽은 앞이 끝날 때까지 기다렸다가
* 잠금을 얻은 뒤 상태를 다시 보고, 이미 갱신돼 있으면 GitHub 을 부르지 않고 그 토큰을 그대로
* 쓴다.
*
* 잠금은 저장과 같은 트랜잭션에 있어야 한다. 바깥에서 잠그고 저장만 REQUIRES_NEW 로 떼면, 안쪽
* 트랜잭션이 바깥이 쥔 잠금을 기다리다 교착된다. 그래서 이 메서드 하나가 잠금·조회·GitHub 호출·
* 저장을 모두 REQUIRES_NEW 안에서 끝낸다. 호출한 쪽이 롤백돼도 갱신은 남아야 하기 때문이기도
* 하다 — GitHub 은 이미 옛 리프레시 토큰을 버렸다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class GithubUserTokenRefresher {

private final UserRepository userRepository;
private final GithubAppPort githubAppPort;

/**
* 커밋된 최신 액세스 토큰을 잠금 없이 읽는다. 아직 만료되지 않았을 때만 값이 있다.
*
* 호출자의 영속성 컨텍스트에는 옛 UserEntity 가 남아 있을 수 있어 그냥 findById 하면 이미
* 커밋된 새 토큰이 안 보인다. 새 트랜잭션은 새 영속성 컨텍스트를 쓰므로 커밋된 값을 본다.
* 잠금을 잡기 전의 빠른 경로다 — 대부분의 호출은 여기서 끝나고 DB 잠금까지 가지 않는다.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, readOnly = true)
public Optional<String> readValidAccessToken(Long userId) {
return userRepository.findById(userId)
.filter(user -> !user.isUserAccessTokenExpired())
.map(User::getGithubUserAccessToken)
.filter(token -> token != null && !token.isBlank());
}

/**
* 유저 행을 잠근 채 갱신한다.
*
* ForbiddenException 은 롤백 대상에서 뺀다. bad_refresh_token 을 맞았을 때 토큰을 지운 뒤
* 예외를 던지는데, 롤백되면 그 지움이 사라져 다음 호출이 같은 무효 토큰으로 또 GitHub 에
* 간다.
*/
@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = ForbiddenException.class)
public String refreshWithLock(Long userId) {
User user = userRepository.findByIdForUpdate(userId)
.orElseThrow(() -> new NotFoundException("유저를 찾을 수 없습니다: " + userId));

// 잠금을 기다리는 동안 앞선 흐름이 갱신을 마쳤을 수 있다. 그 경우 GitHub 을 부르면
// 이미 회전된 리프레시 토큰을 쓰게 되므로, 여기서 멈추는 것이 이 잠금의 핵심이다.
String current = user.getGithubUserAccessToken();
if (!user.isUserAccessTokenExpired() && current != null && !current.isBlank()) {
log.info("GitHub App User Token 갱신 생략 — 잠금 획득 후 이미 갱신됨: userId={}", userId);
return current;
}

if (user.getGithubUserRefreshToken() == null) {
throw new ForbiddenException("GitHub App이 연동되지 않았습니다. GitHub App을 다시 설치해 주세요.");
}

try {
GithubAppPort.GithubUserTokenInfo tokenInfo =
githubAppPort.refreshUserToken(user.getGithubUserRefreshToken());
user.updateUserToken(
tokenInfo.accessToken(),
tokenInfo.refreshToken(),
LocalDateTime.now().plusSeconds(tokenInfo.expiresInSeconds())
);
userRepository.save(user);
log.info("GitHub App User Token 갱신 완료: userId={}", userId);
return tokenInfo.accessToken();
} catch (IllegalStateException exception) {
if (exception.getMessage() != null && exception.getMessage().contains("bad_refresh_token")) {
user.clearGithubAppToken();
userRepository.save(user);
log.warn("GitHub App 토큰 초기화 — 리프레시 토큰이 거부됨: userId={}", userId);
throw new ForbiddenException("GitHub App 연동이 만료되었습니다. GitHub App을 다시 설치해 주세요.");
}
throw exception;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@
public interface UserRepository {
Optional<User> findByGithubId(GithubId githubId);
Optional<User> findById(Long id);

/**
* 유저 행을 잠근 채 읽는다. GitHub 토큰 갱신처럼 "읽고 → 외부에 물어보고 → 쓰는" 구간을
* 직렬화할 때 쓴다. 잠금은 호출한 트랜잭션이 끝날 때까지 유지되므로, 그 트랜잭션 안에서
* 저장까지 마쳐야 한다.
*/
Optional<User> findByIdForUpdate(Long id);
Optional<User> findByGithubInstallationId(Long githubInstallationId);
User save(User user);
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,20 @@
package com.example.dvely.auth.infrastructure.persistence.repository;

import com.example.dvely.auth.infrastructure.persistence.entity.UserEntity;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;

public interface SpringDataUserRepository extends JpaRepository<UserEntity, Long> {
Optional<UserEntity> findByGithubId(String githubId);
Optional<UserEntity> findByGithubInstallationId(Long githubInstallationId);

// SELECT ... FOR UPDATE. 같은 유저의 토큰 갱신이 겹치면 뒤에 온 쪽이 여기서 기다린다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select u from UserEntity u where u.id = :id")
Optional<UserEntity> findByIdForUpdate(@Param("id") Long id);
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ public Optional<User> findById(Long id) {
.map(UserEntity::toDomain);
}

@Override
public Optional<User> findByIdForUpdate(Long id) {
return springDataUserRepository.findByIdForUpdate(id)
.map(UserEntity::toDomain);
}

@Override
public Optional<User> findByGithubInstallationId(Long githubInstallationId) {
return springDataUserRepository.findByGithubInstallationId(githubInstallationId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
Expand Down Expand Up @@ -43,7 +42,7 @@ class AuthCommandServiceTest {
@Mock private TokenPort tokenPort;
@Mock private TokenBlacklistPort tokenBlacklistPort;
@Mock private OAuthStateManager oAuthStateManager;
@Mock private GithubTokenCleaner githubTokenCleaner;
@Mock private GithubUserTokenRefresher tokenRefresher;

private AuthCommandService service;

Expand All @@ -60,7 +59,7 @@ void setUp() {
tokenBlacklistPort,
new JwtProperties("test-secret", 3_600_000L, 2_592_000_000L),
oAuthStateManager,
githubTokenCleaner
tokenRefresher
);
}

Expand Down Expand Up @@ -120,37 +119,32 @@ void unknownOwnerCannotLinkGithubAppOrCallExternalPort() {
verify(userRepository, never()).save(any());
}

// ── 한 요청에서 갱신이 두 번 일어나는 경우 ────────────────────────────────────────
// ── 갱신이 겹치는 경우 ──────────────────────────────────────────────────────────

@Test
void aSecondRefreshInTheSameRequestReusesTheTokenTheFirstOneCommitted() {
// 프로젝트 개요 조회 하나가 GitHub 을 두 번 호출한다(최근 커밋 · 저장소 상태). 두 번째
void aSecondRefreshReusesTheTokenTheFirstOneCommitted() {
// 프로젝트 개요 조회 하나가 GitHub 을 두 번 호출한다(최근 커밋 · 저장소 상태). 뒤에 오는
// 호출은 바깥 영속성 컨텍스트의 옛 UserEntity 를 읽어 여전히 만료로 판정하는데, 그대로
// 갱신하면 이미 회전된 리프레시 토큰을 들고 가 bad_refresh_token 을 맞는다.
when(githubTokenCleaner.readValidAccessToken(10L)).thenReturn(Optional.of("fresh-access"));
when(tokenRefresher.readValidAccessToken(10L)).thenReturn(Optional.of("fresh-access"));

assertThat(service.refreshGithubUserToken(10L)).isEqualTo("fresh-access");

// GitHub 을 다시 부르지 않는다 — 부르는 순간 리프레시 토큰이 또 회전한다.
// 잠금까지 가지 않는다 — 대부분의 호출이 여기서 끝나야 DB 잠금 경합이 생기지 않는다.
verify(tokenRefresher, never()).refreshWithLock(any());
verify(githubAppPort, never()).refreshUserToken(any());
// 그리고 무엇보다 사용자의 GitHub 연동을 지우지 않는다.
verify(githubTokenCleaner, never()).clearAndCommit(any());
verify(githubTokenCleaner, never()).saveAndCommit(any(), any(), any(), any());
}

@Test
void refreshStillHappensWhenNoOneElseHasRefreshedYet() {
when(githubTokenCleaner.readValidAccessToken(10L)).thenReturn(Optional.empty());
User user = user(10L, 55L);
user.updateUserToken("old-access", "old-refresh", LocalDateTime.now().minusMinutes(1));
when(userRepository.findById(10L)).thenReturn(Optional.of(user));
when(githubAppPort.refreshUserToken("old-refresh"))
.thenReturn(new GithubAppPort.GithubUserTokenInfo("new-access", "new-refresh", 28800L, 15811200L));
void aRefreshThatNoOneElseHandledGoesThroughTheLock() {
// 빠른 경로에서 못 찾으면 잠금 경로로 넘긴다. 동시에 들어온 두 흐름이 둘 다 여기까지
// 올 수 있고, 잠금이 없으면 그 둘이 같은 리프레시 토큰을 들고 GitHub 에 간다(#162).
when(tokenRefresher.readValidAccessToken(10L)).thenReturn(Optional.empty());
when(tokenRefresher.refreshWithLock(10L)).thenReturn("new-access");

assertThat(service.refreshGithubUserToken(10L)).isEqualTo("new-access");

verify(githubTokenCleaner).saveAndCommit(eq(10L), eq("new-access"), eq("new-refresh"), any());
verify(githubTokenCleaner, never()).clearAndCommit(any());
verify(tokenRefresher).refreshWithLock(10L);
}

private User user(Long id, Long installationId) {
Expand Down
Loading
Loading