[JPA] @Transactional 과 동시성

신명철·2022년 10월 24일
5

JPA

목록 보기
12/14

들어가며

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
내 머릿속 지우개

0개의 댓글