[Spring] synchronized와 @Transactional 을 동시에 사용 시 문제점

유알·2023년 2월 25일
0

[Spring]

목록 보기
9/17

준비

과거에 이분의 글을 읽은 적이 있는데, 이럴때 도움이 될 줄은 몰랐다.
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

결과는 성공.

해결

해결 방법은 크게 아래와 같았다.

  1. synchronized 를 제거하고 @Transaction 사용
  2. @Transaction 제거하고 synchronized 사용
  3. @Transaction 이 포함된 메서드를 호출하기 전 synchronized 블럭으로 둘러쌓인

하지만 모두 적합하지 않았다.
이는 테스트 코드이기에 원코드를 바꾸기 힘들었다.

그래서 나는 통합테스트의 부모클래스에 붙은 @Transactional 어노테이션을 제거하고, 필요한 경우에만 사용하도록 하였다

profile
더 좋은 구조를 고민하는 개발자 입니다

0개의 댓글