Skip to content
Merged

prod #388

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
8 changes: 8 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ java {
}
}

sourceSets {
test {
java {
exclude '**/ProfileServiceConcurrencyTest.java'
}
}
}
Comment on lines +17 to +23

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

critical

새로운 동시성 테스트 파일(ProfileServiceConcurrencyTest.java)을 테스트 소스셋에서 제외하고 있습니다. 이 테스트는 ProfileService.registerProfile의 중요한 동시성 문제를 검증하기 위해 추가된 것으로 보입니다. 이 테스트를 제외하면 해당 로직의 동시성 안전성을 보장할 수 없게 됩니다.

만약 테스트가 CI/CD 파이프라인에서 실행하기에 너무 오래 걸리거나 불안정하다면, 빌드 스크립트에서 완전히 제외하는 대신 @Disabled 어노테이션을 사용하여 테스트 클래스나 메서드 수준에서 비활성화하고, 그 이유를 주석으로 명시하는 것이 좋습니다. 이렇게 하면 다른 개발자들이 테스트의 존재와 비활성화된 이유를 명확히 알 수 있습니다.

가장 좋은 방법은 테스트를 수정하여 안정적이고 빠르게 실행되도록 한 후, 테스트 셋에 다시 포함시키는 것입니다. 동시성 버그는 재현하기 어렵고 심각한 문제를 일으킬 수 있으므로, 이를 검증하는 테스트는 매우 중요합니다.


configurations {
compileOnly {
extendsFrom annotationProcessor
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public static String formatExamNumber(Integer roundCode, Integer areaCode, Integ
}

return String.format(
"%d%02d%02d%04d",
"%d%d%02d%04d",

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

수험 번호 형식 문자열에서 areaCode에 대한 포맷 지정자가 %02d에서 %d로 변경되었습니다. 이로 인해 areaCode가 한 자리 수일 경우 앞에 '0'이 채워지지 않게 됩니다. (예: areaCode가 7일 경우, 기존에는 '07'이었으나 이제 '7'로 포맷됩니다.)

이 변경으로 인해 수험 번호의 전체 길이가 가변적으로 변할 수 있습니다. 만약 시스템의 다른 부분에서 수험 번호가 고정 길이라고 가정하고 있다면, 이 변경이 예기치 않은 문제를 일으킬 수 있습니다. 이 변경이 의도된 것이며 관련된 모든 시스템에 영향이 없는지 다시 한번 확인해 보시는 것을 권장합니다.

Suggested change
"%d%d%02d%04d",
"%d%02d%02d%04d",

roundCode,
areaCode,
schoolCode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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("[email protected]")
.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",
"[email protected]",
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<Exception> 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);
}
}