Skip to content
26 changes: 18 additions & 8 deletions src/main/java/com/mr/domain/auth/service/AuthService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,28 +59,24 @@ public AuthResponseDTO.LoginResponse completeTokenExchange(Long userId, SocialTy
String refreshTokenHash = tokenProvider.hashToken(newRefreshToken);
LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime();

try {
Optional<SocialAuth> 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<SocialAuth> 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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Loading