JPA는 다양한 방법으로 동시성 처리를 지원한다. Lock을 걸수도 있고, Transaction의 격리 수준을 조절할 수도 있다. 프로젝트를 진행하며 동시성을 고려해본 경험이 없기 때문에 동시성 처리를 위한 공부를 하며 새롭게 알게 된 내용들을 정리하고자 하기 위해 작성했다.
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){
...
}
}
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을 사용할 때도 주의해야할 사항이다.
@Async
, Future
와 AsyncResult
를 사용해서 [트랜잭션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){
...
}
}
멀티 스레드 환경에서 흔히 발생할 수 있는 문제가 바로 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====");
}
changeName
과 changeName2
를 만들어서 락을 얻는 순서를 changeName
-> changeName2
로 만들고 Transaction
이 종료되는 순서는 changeName2
->changeName2
로 설계했다.LockModeType.PESSIMISTIC_WRITE
를 사용했기 때문에 동시성에 문제가 발생하지 않았다.다만 고의적으로 LockTimeOutException
을 발생시키기 위한 목적으로 @QueryHint
를 사용해서 Lock Timeout을 0 초로 설정했지만, LockTimeOutException
이 발생하진 않았다. 찾아보니 MySQL은 해당 옵션을 지원하지 않는다는 얘기가 스택오버플로우에 있었다.
x
표시가 없길래 당연히 지원하는 줄 알았는데 Only Oracle has full jpa support
라는 문구를 보고 x
가 OK 표시임을 뒤늦게 알아차렸다 :(
비관적 락
은 DeadLock이 발생할 수 있기 때문에 락을 얻기 위해 기다리는 Timeout을 설정하는 설정이 필요하다. 이 부분은 다른 방법을 찾는대로 포스팅을 해야겠다.
@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());
}
MySQL은 기본적으로 MVCC 기반의 InnoDB를 사용하기 때문에 Lock 이 걸려 있는 row 에 동시 접근을 해도 에러가 발생하지 않는다. 아마 MVCC를 지원하지 않는 DB 라면 READ 시 에러가 발생할 것이라고 생각한다.
비관적 락
은 락의 범위를 지정할 수 있다. 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);