과거에 이분의 글을 읽은 적이 있는데, 이럴때 도움이 될 줄은 몰랐다.
https://kdhyo.kr/59
이분의 글은 이 stackoverFlow에 있는 글을 한국어로 바꾼것이다.
https://stackoverflow.com/questions/41767860/spring-transactional-with-synchronized-keyword-doesnt-work
synchronized 와 @Transactional 을 동시에 사용하면 문제가 발생한다는 것이다.
@Transactional은 Spring AOP이고 이는 프록시를 기반으로 하기 때문에 적용메서드에 synchronized 가 적용되어있어도 Transational을 담당하는 프록시 코드에는 적용이 되지 않고, 아래와 같이 이뤄지게 된다는 것이다.
T1: |--B--|--R--|--C-->
T2: |--B---------|--A--|--C-->
B : Spring Transaction Begin
R,A : Synchronized Method
C : Spring Trnasaction End
이걸 읽고 아 그렇구나 조심해야지 하고 넘어갔었는데, 오늘 이를 마주했다.
간단한 회원가입 서비스에 대한 테스트 코드를 작성하는 중이었다.
@Service
@RequiredArgsConstructor
public class MemberSignUpService {
//...
public synchronized Member doSignUpWithSync(SignUpRequest dto) {
if (memberRepository.existsByEmail(dto.getEmail())) {
throw new OccupiedEmailException(dto.getEmail());
}
//암호화 후 엔티티로
Member memberEntity = dto.toEntity(
passwordEncoder.encode(dto.getPassword()),
Authority.ROLE_USER
);
return memberRepository.save(memberEntity);
}
간단한 서비스인데 synchronized를 추가했다. 그래서 이에 관련된 테스트 코드를 작성했다.
테스트 코드라기 보다는 어떤 thread-safe 방식이 적합한지 테스트 하려고 시간을 재도록 만든 코드이다.
그냥 쓰레드 풀로 같은 요청을 10번 보내는 테스트 이다.
@DisplayName("MemberSignUpService 테스트")
class MemberSignUpServiceTest extends IntegrationTest {
@Test
@DisplayName("thread-safe test")
public void speedTest(){
//이미 가입된 회원
SignUpRequest sameSignUpRequest = MemberTestUtil.createSignUpRequest();
memberSignUpService.doSignUp(sameSignUpRequest);
//쓰레드풀
ExecutorService executorService = Executors.newFixedThreadPool(4);
List<Future<Member>> futures = new ArrayList<>();
long startTime = System.currentTimeMillis();
//여러번 같은 이메일로 가입을 시도
for (int i = 0; i < 10; i++){
futures.add(executorService.submit(() -> {
return memberSignUpService.doSignUpWithSync(sameSignUpRequest);
}));
}
StringBuilder sb = new StringBuilder();
int no = 1;
for (Future<Member> future : futures){
try {
future.get();
System.out.println();
sb.append("request no" + no++ + " : success \n\n");
} catch (InterruptedException e) {
//do nothing
} catch (ExecutionException e) {
//실행중 에러가 생겼을 때
sb.append("request no" + no++ + " : " + e.getMessage() + "\n\n");
}
}
long endTime = System.currentTimeMillis();
System.out.println("\n---------------------result is----------------");
System.out.println("TimeDuration : " + (endTime - startTime));
System.out.println(sb);
}
결과는 :
---------------------result is----------------
TimeDuration : 20813
request no1 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no2 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no3 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no4 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no5 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no6 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no7 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no8 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no9 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
request no10 : org.springframework.transaction.TransactionSystemException: Could not roll back JPA transaction
시간도 매우 오래걸렸고 모두 JPA 롤백을 실패했다고 떴다.
한참동안 그 이유를 찾아보았다. 처음에는 Lock 을 얻기 위한 timeout 문제인가? 라고 생각해서 삽질을 계속했다.
그러다가 든 생각이 테스트 코드 클래스에 붙어있는 @Transactional 때문인가? 이다.
@SpringBootTest
@AutoConfigureMockMvc
@ActiveProfiles(TestProfiles.TEST)
@Transactional
@Disabled
public class IntegrationTest {
@Autowired protected MockMvc mockMvc;
@Autowired protected ObjectMapper objectMapper;
}
통합테스트를 상속받아 쓸 수 있게 만든 부모클래스이다. 이 코드에 보면 @Transactional 이 붙어있다.
즉 위와 같은
T1: |--B--|--R--|--C-->
T2: |--B---------|--A--|--C-->
상황이 펼쳐졌던 것이다.
일단 @Transactional 을 주석처리하고 진행해보았다.
---------------------result is----------------
TimeDuration : 8
request no1 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no2 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no3 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no4 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no5 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no6 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no7 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no8 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no9 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
request no10 : com.oj.springchat.domain.member.exception.OccupiedEmailException: test@a.a
결과는 성공.
해결 방법은 크게 아래와 같았다.
하지만 모두 적합하지 않았다.
이는 테스트 코드이기에 원코드를 바꾸기 힘들었다.
그래서 나는 통합테스트의 부모클래스에 붙은 @Transactional 어노테이션을 제거하고, 필요한 경우에만 사용하도록 하였다