diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 9b45ae74..a756e185 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -150,6 +150,10 @@ private record TempExchangeData( LocalDateTime expiresAt ) {} + // ⚠️ 이 어노테이션을 지우면 클래스 레벨 @Transactional(readOnly=true)를 그대로 상속받아 + // SocialAuth INSERT/UPDATE가 read-only 트랜잭션에서 실패한다 (#94 배포 서버 503 원인). + // AuthServiceTest는 클래스 전체가 @Transactional로 감싸져 있어 이 회귀를 못 잡으니 주의. + @Transactional(propagation = Propagation.NOT_SUPPORTED) public AuthResponseDTO.LoginResponse exchangeTempCode(String tempCode) { cleanExpiredTempCodes(); if (tempCode == null || tempCode.isBlank()) { @@ -160,14 +164,20 @@ public AuthResponseDTO.LoginResponse exchangeTempCode(String tempCode) { throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } - return authTransactionService.completeTokenExchange( - data.userId(), - data.socialType(), - data.socialId(), - data.profileImgUrl(), - data.deviceInfo(), - data.isNewUser() - ); + try { + return authTransactionService.completeTokenExchange( + data.userId(), + data.socialType(), + data.socialId(), + data.profileImgUrl(), + data.deviceInfo(), + data.isNewUser() + ); + } catch (DataIntegrityViolationException e) { + // socialLogin()/socialLoginByCode()와 달리 이 경로는 새 트랜잭션에서 재조회할 + // 기존 계정 복구 로직이 없으므로, 곧바로 도메인 예외로 매핑한다. + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } } private void cleanExpiredTempCodes() { diff --git a/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java b/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java index 9a356ea2..6f0aea98 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthTransactionService.java @@ -59,28 +59,24 @@ public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialTy String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); - try { - Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, socialId); - if (optionalSocialAuth.isPresent()) { - SocialAuth socialAuth = optionalSocialAuth.get(); - socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); - } else { - SocialAuth newSocialAuth = SocialAuth.create( - user, - socialType, - socialId, - refreshTokenHash, - expiryTime, - deviceInfo - ); - socialAuthRepository.saveAndFlush(newSocialAuth); - } - } catch (DataIntegrityViolationException e) { - SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, socialId) - .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + // PostgreSQL은 제약 위반이 나는 즉시 트랜잭션 전체를 abort 상태로 만들어서, + // 같은 트랜잭션 안에서 복구 쿼리를 다시 시도하면 그 쿼리도 실패한다. + // 여기서 잡아 도메인 예외로 변환하지 않고 그대로 전파해 트랜잭션을 롤백시키고, + // 호출 쪽(AuthService의 동시 로그인 재시도 로직)이 새 트랜잭션에서 복구를 시도하게 한다. + Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, socialId); + if (optionalSocialAuth.isPresent()) { + SocialAuth socialAuth = optionalSocialAuth.get(); socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); - user = socialAuth.getUser(); - isNewUser = false; + } else { + SocialAuth newSocialAuth = SocialAuth.create( + user, + socialType, + socialId, + refreshTokenHash, + expiryTime, + deviceInfo + ); + socialAuthRepository.saveAndFlush(newSocialAuth); } AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java index 7a615c39..1a652505 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisRequestFactoryTest.java @@ -7,9 +7,9 @@ import com.mr.domain.analysis.exception.AnalysisErrorStatus; import com.mr.domain.analysis.factory.AnalysisRequestFactory; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.Level; -import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.Level; +import com.mr.domain.backingtrack.entity.enums.ScaleType; import com.mr.domain.playing.entity.MidiEventData; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.MidiType; diff --git a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java index e58c7bf6..d862f6df 100644 --- a/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java +++ b/src/test/java/com/mr/domain/analysis/service/AnalysisServiceTest.java @@ -25,8 +25,8 @@ import com.mr.domain.analysis.factory.AnalysisRequestFactory; import com.mr.domain.analysis.repository.AnalysisReportRepository; import com.mr.domain.analysis.repository.AnalysisRepository; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.ScaleType; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.ScaleType; import com.mr.domain.playing.entity.Playing; import com.mr.domain.playing.entity.enums.PlayingStatus; import com.mr.domain.playing.repository.PlayingRepository; diff --git a/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java new file mode 100644 index 00000000..2341eb2d --- /dev/null +++ b/src/test/java/com/mr/domain/auth/service/AuthTransactionServiceTest.java @@ -0,0 +1,80 @@ +package com.mr.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; + +import com.mr.domain.auth.dto.OAuthUserInfo; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.repository.SocialAuthRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.security.jwt.JwtTokenProvider; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.dao.DataIntegrityViolationException; + +@ExtendWith(MockitoExtension.class) +class AuthTransactionServiceTest { + + private static final Long USER_ID = 1L; + + @Mock + private SocialAuthRepository socialAuthRepository; + @Mock + private UserRepository userRepository; + @Mock + private JwtTokenProvider tokenProvider; + + @InjectMocks + private AuthTransactionService authTransactionService; + + @Test + @DisplayName("completeTokenExchange - SocialAuth 저장 중 동시 요청으로 유니크 제약 위반이 나면, " + + "이미 abort된 트랜잭션에서 복구 쿼리를 재시도하지 않고 DataIntegrityViolationException을 그대로 전파한다 " + + "(호출 쪽인 AuthService가 새 트랜잭션에서 복구를 시도할 수 있도록)") + void completeTokenExchange_socialAuthUniqueViolation_propagatesWithoutRetryingQuery() { + User user = mock(User.class); + given(user.getUserId()).willReturn(USER_ID); + given(userRepository.findById(USER_ID)).willReturn(Optional.of(user)); + + given(tokenProvider.createAccessToken(USER_ID)).willReturn("access-token"); + given(tokenProvider.createRefreshToken(USER_ID)).willReturn("refresh-token"); + given(tokenProvider.hashToken("refresh-token")).willReturn("hashed-refresh-token"); + given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); + + // 최초 조회 시점엔 없다고 판단해 신규 생성 분기로 들어가지만, + // 동시에 들어온 다른 요청이 먼저 저장을 마쳐서 실제 저장 시점엔 유니크 제약 위반이 남 + given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "social-1")) + .willReturn(Optional.empty()); + given(socialAuthRepository.saveAndFlush(any())).willThrow(uniqueViolation()); + + OAuthUserInfo userInfo = new OAuthUserInfo("social-1", "https://example.com/profile.png"); + + assertThatThrownBy(() -> authTransactionService.completeTokenExchange( + USER_ID, SocialType.KAKAO, userInfo.socialId(), userInfo.profileImgUrl(), "device", false)) + .isInstanceOf(DataIntegrityViolationException.class); + + // 복구를 위해 같은 트랜잭션에서 findBySocialTypeAndSocialId를 다시 호출하지 않는지 확인 + // (딱 1번, 최초 조회 시점에만 호출됨 — 예전 버그였다면 catch 블록에서 한 번 더 호출돼 2번이 됨) + verify(socialAuthRepository, times(1)).findBySocialTypeAndSocialId(SocialType.KAKAO, "social-1"); + } + + private static DataIntegrityViolationException uniqueViolation() { + SQLException sqlException = new SQLException("duplicate key value violates unique constraint", "23505"); + ConstraintViolationException constraintViolationException = + new ConstraintViolationException("duplicate key", sqlException, "social_auth_type_id_key"); + return new DataIntegrityViolationException("duplicate key", constraintViolationException); + } +} diff --git a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java index 907d96e8..ddb2a14c 100644 --- a/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java +++ b/src/test/java/com/mr/domain/playing/service/PlayingServiceTest.java @@ -1,8 +1,8 @@ package com.mr.domain.playing.service; -import com.mr.domain.backingTrack.entity.BackingTrack; -import com.mr.domain.backingTrack.entity.enums.AccessLevel; -import com.mr.domain.backingTrack.repository.BackingTrackRepository; +import com.mr.domain.backingtrack.entity.BackingTrack; +import com.mr.domain.backingtrack.entity.enums.AccessLevel; +import com.mr.domain.backingtrack.repository.BackingTrackRepository; import com.mr.domain.playing.dto.req.MidiEventSaveRequest; import com.mr.domain.playing.dto.req.PlayingStartRequest; import com.mr.domain.playing.dto.res.MidiEventSaveResponse;