[JPA] @Transactional 과 동시성

신명철·2022년 10월 24일
6

JPA

목록 보기
12/14
post-custom-banner

들어가며

JPA는 다양한 방법으로 동시성 처리를 지원한다. Lock을 걸수도 있고, Transaction의 격리 수준을 조절할 수도 있다. 프로젝트를 진행하며 동시성을 고려해본 경험이 없기 때문에 동시성 처리를 위한 공부를 하며 새롭게 알게 된 내용들을 정리하고자 하기 위해 작성했다.


1. @Transactional = 단일 스레드

DB Transaction 을 관리하기 위해 사용하는 @Transactional 은 AOP를 기반으로 동작한다는 건 흔히 아는 사실이지만, 단일 스레드에서만 동작한다는 사실은 모르고 있었다.

위의 DataSourceTransactionManager 클래스의 문서에서 확인할 수 있듯 DataSource로 부터 현재 스레드에 JDBC Connection을 바인딩해서 잠재적으로 DataSource당 하나의 스레드를 연결하게 된다. 여기서 현재 스레드 라는 것은 실행 중인 스레드를 의미한다. 일반적인 경우 아마도 main-thread 일 것이고, 그게 아니라면 비동기 처리 메서드를 호출한 thread 가 될 것이다.

public abstract class TransactionSynchronizationManager {
    private static final Log logger = LogFactory.getLog(TransactionSynchronizationManager.class);
    private static final ThreadLocal<Map<Object, Object>> resources = new NamedThreadLocal("Transactional resources");
    private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations = new NamedThreadLocal("Transaction synchronizations");
    private static final ThreadLocal<String> currentTransactionName = new NamedThreadLocal("Current transaction name");
    private static final ThreadLocal<Boolean> currentTransactionReadOnly = new NamedThreadLocal("Current transaction read-only status");
    private static final ThreadLocal<Integer> currentTransactionIsolationLevel = new NamedThreadLocal("Current transaction isolation level");
    private static final ThreadLocal<Boolean> actualTransactionActive = new NamedThreadLocal("Actual transaction active");
}

트랜잭션 매니저는 언제나 현재 스레드와 연결된다. 그 이유는 Connection을 ThreadLocal 로 관리하기 때문이다. 이렇게 ThreadLocal 에서 Connection이 관리된다는 것은 Transaction 은 하나의 쓰레드에서 시작해서 종료되어야 한다 라는 것을 의미하게 된다.

따라서 다음과 같은 멀티 스레딩 코드는 의도된대로 실행되지 않는다.

@Test
@Transactional // *트랜잭션 A*
public void test() throws InterruptedException {

	Member member = new Member("001-01","홍길동",new ArrayList<>());
	memberService.save(member);
	
	ExecutorService executor = Executors.newFixedThreadPool(2);
	CountDownLatch latch = new CountDownLatch(2);
    
	executor.submit(() -> {
		memberService.changeName("홍길동", "김민수");
        latch.countDown();
	});
	
	executor.submit(() -> {
		Member findOne = memberService.findById("001-01");
		System.out.println(findOne.getName());
        latch.countDown();
	});
    
    latch.await();
}

...

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
	
    @Transactional // *트랜잭션 B*
    public void changeName(String from, String to){
    	// 예외 발생 !!
    }
    
    @Transactional // *트랜잭션 C*
    public void findById(String id){
    	...
    }
}
  • 위 테스트 코드를 처음 작성했을 때는 다음과 같이 동작하길 의도했었다.
    • [트랜잭션A] 에서 changeName()findById() 이 하나의 트랜잭션에서 동작한다.
    • 따라서 changeName() 에서 Exception이 발생하면, [트랜잭션A]가 rollback 되면서 test()findById() 가 다 같이 rollback 된다.
  • 하지만 위 의도대로 동작하지 않는다.
    • changeName()findById() 는 서로 다른 스레드에서 동작한다. 즉 [트랜잭션A] 는 main-thread, findById()pool-thread-1, changeName()pool-thread-2 에서 동작하고 Connection 을 ThreadLocal로 관리하기 때문에 이 셋은 서로 분리된 트랜잭션에서 동작하게 된다.

이것은 또 다시 Batch등에서 성능을 위해 MultiThread 화 했을때 트랜잭션은 각 쓰레드 별로 실행되게 설계 해야 한다는 제약으로 이어진다.

@Async 같은 비동기 처리를 위한 Annotation을 사용할 때도 주의해야할 사항이다.

멀티스레드 환경에서 Transaction을 연장할 수 있는 방법 ?

  • @Async, FutureAsyncResult를 사용해서 [트랜잭션A] 메서드에서 비동기 처리 결과를 꺼내고, 결과에 따라 [트랜잭션A]를 rollback 시키는 방법을 생각해볼 수 있다.
@Test
@Transactional // *트랜잭션 A*
public void test() throws InterruptedException {

	Member member = new Member("001-01","홍길동",new ArrayList<>());
	memberService.save(member);
    
    Future future = memberService.changeName("홍길동", "김민수");
	Member findOne = memberService.findById("001-01");
    
    try {
    	future.get();
    } catch(ExecutionException e) {
		e.printStackTrace();
	} catch(InterruptedException e) {
		e.printStackTrace();
	}
    
    ...
}

...

@Slf4j
@Service
@RequiredArgsConstructor
public class MemberService {
	
    @Async
    @Transactional // *트랜잭션 B*
    public Future<Void> changeName(String from, String to){
    	// ...
    	return new AsyncResult<>(null);
    }
    
    @Transactional // *트랜잭션 C*
    public void findById(String id){
    	...
    }
}

2. 동시 접근 시 발생하는 상황들

멀티 스레드 환경에서 흔히 발생할 수 있는 문제가 바로 DB 동시성 문제다. JPA 는 @Transactional의 격리 수준으로 해당 메서드의 접근을 관리할 수 있고, @Lock, LockModeType을 통해서 DB에 Lock을 걸어서 동시성을 해결할 수 있다.

@Test
void test() throws InterruptedException, ExecutionException {
		
	ExecutorService executorService = Executors.newFixedThreadPool(2);
	CountDownLatch latch = new CountDownLatch(2);
	
	executorService.submit(() ->  {
		memberService.changeName("001-01", "GOOD");
		latch.countDown();
	});
	
	Thread.sleep(500);
	
	executorService.submit(() ->  {
		memberService.changeName2("001-01", "B");
		latch.countDown();
	});
	
	latch.await();
	
	Member afterChange = memberService.findById("001-01");
	log.info("after Name = {}", afterChange.getName());		
}
public interface MemberRepository extends JpaRepository<Member, String>{
	@QueryHints({
		@QueryHint(name = "javax.persistence.lock.timeout", value = "0")
	})
	@Lock(LockModeType.PESSIMISTIC_WRITE)
	@Query("SELECT m FROM Member m WHERE m.id = :id")
	Optional<Member> findByIdforUpdate(@Param("id") String id);
}

@Transactional
public void changeName2(String id, String to){
	
	log.info("====CHANGE START====");
	
	Member findOne = memberRepository.findByIdforUpdate(id).get();
	log.info("change from {} to {}", findOne.getName(), to);
	findOne.changeName(to);
	log.info("====CHANGE END====");
}

@Transactional(isolation = Isolation.SERIALIZABLE)
public void changeName(String id, String to){
	
	log.info("====CHANGE START====");
	
	Member findOne = memberRepository.findByIdforUpdate(id).get();
	log.info("change from {} to {}", findOne.getName(), to);
	findOne.changeName(to);
	
	try {
		Thread.sleep(10000);
	} catch (InterruptedException e) {
		e.printStackTrace();
	}
	log.info("====CHANGE END====");
}
  • changeNamechangeName2 를 만들어서 락을 얻는 순서를 changeName-> changeName2로 만들고 Transaction이 종료되는 순서는 changeName2->changeName2로 설계했다.
  • LockModeType.PESSIMISTIC_WRITE 를 사용했기 때문에 동시성에 문제가 발생하지 않았다.

다만 고의적으로 LockTimeOutException을 발생시키기 위한 목적으로 @QueryHint를 사용해서 Lock Timeout을 0 초로 설정했지만, LockTimeOutException이 발생하진 않았다. 찾아보니 MySQL은 해당 옵션을 지원하지 않는다는 얘기가 스택오버플로우에 있었다.

  • 표를 보면 MySQL 에 x 표시가 없길래 당연히 지원하는 줄 알았는데 Only Oracle has full jpa support 라는 문구를 보고 x 가 OK 표시임을 뒤늦게 알아차렸다 :(

비관적 락은 DeadLock이 발생할 수 있기 때문에 락을 얻기 위해 기다리는 Timeout을 설정하는 설정이 필요하다. 이 부분은 다른 방법을 찾는대로 포스팅을 해야겠다.

동시 접근에 대해서 error가 나지 않는 경우

@Test
void test() throws InterruptedException, ExecutionException {	
	ExecutorService executorService = Executors.newFixedThreadPool(2);
	CountDownLatch latch = new CountDownLatch(2);
	
	executorService.submit(() ->  {
		memberService.changeName("001-01", "홍길동"); - (1)
		latch.countDown();
	});
	
	Thread.sleep(500);
	
	executorService.submit(() ->  {
		Member afterChange = memberService.findById("001-01"); - (2) // Lock걸린 데이터에 동시 접근시도
		log.info("after Name = {}", afterChange.getName());
		latch.countDown();
	});
	
	latch.await();
	
	Member afterChange = memberService.findById("001-01");
	log.info("after Name = {}", afterChange.getName());	
}
  • (1) 에서 생성된 Lock 으로 인해 (2) 에서 해당 row에 접근할 때 에러가 발생할 것이라고 생각했지만 정상적으로 처리됐다.

MySQL은 기본적으로 MVCC 기반의 InnoDB를 사용하기 때문에 Lock 이 걸려 있는 row 에 동시 접근을 해도 에러가 발생하지 않는다. 아마 MVCC를 지원하지 않는 DB 라면 READ 시 에러가 발생할 것이라고 생각한다.

Lock Scope

비관적 락은 락의 범위를 지정할 수 있다. Join시 참여하는 모든 테이블에 Lock을 걸 것인지, 메인이 되는 테이블에만 Lock을 걸 것인지, 1 개의 row에만 Lock을 걸 것인지를 지정하는 것이다.timeout 옵션처럼 @QueryHint를 사용해서 지정할 수 있다.

@QueryHints({@QueryHint(name = "javax.persistence.lock.scope", value = "EXTENDED")})
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT m FROM Member m WHERE m.id = :id")
Optional<Member> findByIdforUpdate(@Param("id") String id);
  • 이 기능은 모든 DBMS에서 지원하지 않는다고 한다.

참고 자료들

profile
내 머릿속 지우개
post-custom-banner

0개의 댓글