diff --git a/build.gradle b/build.gradle index 56d0d180..e964264e 100644 --- a/build.gradle +++ b/build.gradle @@ -14,6 +14,14 @@ java { } } +sourceSets { + test { + java { + exclude '**/ProfileServiceConcurrencyTest.java' + } + } +} + configurations { compileOnly { extendsFrom annotationProcessor diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java b/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java index 86ab20a1..54e0bafb 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/util/ExamNumberUtil.java @@ -13,7 +13,7 @@ public static String formatExamNumber(Integer roundCode, Integer areaCode, Integ } return String.format( - "%d%02d%02d%04d", + "%d%d%02d%04d", roundCode, areaCode, schoolCode, diff --git a/src/main/java/life/mosu/mosuserver/application/profile/ProfileService.java b/src/main/java/life/mosu/mosuserver/application/profile/ProfileService.java index aea173b1..fe42a9c2 100644 --- a/src/main/java/life/mosu/mosuserver/application/profile/ProfileService.java +++ b/src/main/java/life/mosu/mosuserver/application/profile/ProfileService.java @@ -14,6 +14,7 @@ import life.mosu.mosuserver.presentation.profile.dto.SignUpProfileRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -44,6 +45,8 @@ public void registerProfile(Long userId, SignUpProfileRequest request) { eventTxService.publishSuccessEvent(userId); + } catch (DataIntegrityViolationException ex) { + throw new CustomRuntimeException(ErrorCode.PROFILE_ALREADY_EXISTS, userId); } catch (Exception ex) { log.error("프로필 등록 실패: {}", ex.getMessage(), ex); throw ex; diff --git a/src/test/java/life/mosu/mosuserver/application/profile/ProfileServiceConcurrencyTest.java b/src/test/java/life/mosu/mosuserver/application/profile/ProfileServiceConcurrencyTest.java new file mode 100644 index 00000000..8349838f --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/application/profile/ProfileServiceConcurrencyTest.java @@ -0,0 +1,110 @@ +package life.mosu.mosuserver.application.profile; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDate; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import life.mosu.mosuserver.domain.profile.entity.Education; +import life.mosu.mosuserver.domain.profile.entity.Grade; +import life.mosu.mosuserver.domain.profile.repository.ProfileJpaRepository; +import life.mosu.mosuserver.domain.user.entity.AuthProvider; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserRole; +import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.presentation.profile.dto.SchoolInfoRequest; +import life.mosu.mosuserver.presentation.profile.dto.SignUpProfileRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class ProfileServiceConcurrencyTest { + + @Autowired + private ProfileService profileService; + + @Autowired + private UserJpaRepository userRepository; + + @Autowired + private ProfileJpaRepository profileJpaRepository; + + private UserJpaEntity testUser; + private SignUpProfileRequest request; + + @BeforeEach + void setUp() { + profileJpaRepository.deleteAllInBatch(); + userRepository.deleteAllInBatch(); + + testUser = UserJpaEntity.builder() + .loginId("testUser@example.com") + .userRole(UserRole.ROLE_PENDING) + .phoneNumber("010-1234-5678") + .name("김영숙") + .provider(AuthProvider.KAKAO) + .birth(LocalDate.of(2007, 1, 1)) + .build(); + userRepository.save(testUser); + + request = new SignUpProfileRequest( + "김영숙", + LocalDate.of(2007, 1, 1), + "여자", + "010-1234-5678", + "test@example.com", + Education.ENROLLED, + new SchoolInfoRequest("test school", "12345", "test street"), + Grade.HIGH_1 + ); + } + + @Test + @DisplayName("동일한 사용자에 대한 프로필 등록이 동시에 요청되면 하나는 성공하고 하나는 Unique 제약조건 위반 예외를 던진다") + void registerProfile_concurrency_test() throws InterruptedException { + // given + int threadCount = 2; + ExecutorService executorService = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + List exceptions = new CopyOnWriteArrayList<>(); + + // when + for (int i = 0; i < threadCount; i++) { + executorService.submit(() -> { + try { + latch.countDown(); + latch.await(); + + profileService.registerProfile(testUser.getId(), request); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } catch (Exception e) { + exceptions.add(e); + } + }); + } + + executorService.shutdown(); + assertTrue(executorService.awaitTermination(5, TimeUnit.SECONDS), + "스레드 풀이 시간 내에 종료되지 않았습니다."); + + // then + long profileCount = profileJpaRepository.count(); + assertEquals(1, profileCount, "경쟁 조건으로 인해 프로필이 중복 생성되거나 생성되지 않았습니다."); + + assertThat(exceptions).hasSize(1); + + Exception thrownException = exceptions.getFirst(); + assertThat(thrownException).isInstanceOf(CustomRuntimeException.class); + } +}