Hyun's Wonderwall

[EFUB 4기 BE Lead] 도메인 주도 개발 시작하기 - 8. 애그리거트 트랜잭션 관리 본문

Study/Java, Spring

[EFUB 4기 BE Lead] 도메인 주도 개발 시작하기 - 8. 애그리거트 트랜잭션 관리

Hyun_! 2024. 4. 1. 19:02

EFUB 4기 BackEnd Lead_ 도메인 주도 개발 스터디

  • 스터디 커리큘럼: 최범균, "도메인 주도 개발 시작하기: DDD 핵심 개념 정리부터 구현까지"
  • 3주차 과제: Chapter 7. 도메인 서비스, Chapter 8. 애그리거트 트랜잭션 관리

Chapter 8. 애그리거트 트랜잭션 관리

  • Keywords: 애그리거트 트랜잭션, 애그리거트 잠금 기법

8.1 애그리거트와 트랜잭션

애그리거트의 일관성이 깨지는 문제가 발생하지 않도록 하려면 다음 두 가지 중 하나를 해야 한다.

  • 운영자가 배송지 정보를 조회하고 상태를 변경하는 동안 고객이 애그리거트를 수정하지 못하게 막는다
  • 운영자가 배송지 정보를 조회한 이후에 고객이 정보를 변경하면, 운영자가 애그리거트를 다시 조회한 뒤 수정하도록 한다,

이 두 가지는 애그리거트 자체의 트랜잭션과 관련이 있다. DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다.

애그리거트에 대해 사용할 수 있는 대표적인 트랜잭션 처리 방법에는 선점(Pessimistic, 비관적) 잠금과 비선점(Optimistic, 낙관적) 잠금 두 가지 방식이 있다.

8.2 선점 잠금

선점 잠금: 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 떄까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식이다.

1) 스레드1에서 애그리거트를 구해 접근 잠금
2) 스레드2에서 애그리거트 구함 시도, 잠금으로 대기 - 애그리거트의 잠금이 해제될 때까지 블로킹됨
3) 스레드1이 애그리거트를 수정
4) 스레드1이 트랜잭션 커밋, 잠금 해제 -> 이 순간 블로킹되어있던 스레드2가 애그리거트를 구하고 접근 잠금.
스레드2는 스레드1이 수정한 애그리거트의 내용을 보게 된다.
5-1) 스레드2에서 애그리거트 변경 성공하는 경우 트랜잭션 커밋, 잠금 해제
5-2) 스레드2에서 애그리거트 변경 실패하는 경우 트랜잭션 실패, 잠금 해제

 

선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현한다. 오라클을 비롯한 다수의 DBMS가 for update와 같은 쿼리를 사용해 특정 레코드에 한 커넥션만 접근할 수 있는 잠금장치를 제공한다.

  • JPA EntityManager은 LockModeType을 인자로 받는 find() 메서드를 제공한다.
    - LockModeType.PESSIMISTIC_WRITE를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있다. 
  • JPA 프로바이더와 DBMS에 따라 잠금 모드 구현이 다르다. 하이버네이트의 경우 PESSIMISTIC_WRITE를 잠금 모드로 사용하면 'for update' 쿼리를 이용해 선점 잠금을 구현한다.
  • 스프링 데이터 JPA는 @Lock 애너테이션을 사용해서 잠금 모드를 지정한다.
@Lock(LockModeType.PESSIMISTIC_WRITE)

8.2.1 선점 잠금과 교착 상태

선점 잠금 기능을 사용할 때는 잠금 순서에 따른 교착 상태(deadlock)이 발생하지 않도록 주의해야 한다.

- 구해야 하는 애그리거트를 구할 수 없어 더 이상 다음 단계를 진행하지 못함

- 선점 잠금에 따른 교착 상태는 상대적으로 사용자 수가 많을 때 발생할 가능성이 높고, 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.

교착 상태를 방지하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.

JPA에서 선점 잠금을 시도할 때 최대 대기 시간을 지정하려면 힌트(hints)를 사용한다.

- JPA의 'javax.persistence.lock.timeout' 힌트 - 잠금을 구하는 대기 시간을 밀리초 단위로 지정, 지정한 시간 이내에 잠금을 구하지 못하면 익셉션을 발생시킨다.

DBMS에 따라 힌트가 적용되지 않을 수도 있음을 주의.

스프링 데이터 JPA는 @QueryHints 애너테이션을 사용해서 쿼리 힌트를 지정할 수 있다.

DBMS에 따라 교착 상태에 빠진 커넥션을 처리하는 방식이 다르다. 쿼리별로 대기 시간 지정, 커넥션 단위로만 대기 시간 지정 가능한 등... 사용하는 DBMS에 대해 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.

8.3 비선점 잠금

선점 잠금으로 해결되지 않는 상황도 있다.

비선점 잠금은 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식이다.

비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.

애그리거트를 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가하도록 한다.

- 다른 트랜잭션이 먼저 데이터를 수정해서 버전 값이 바뀌면 데이터 수정 실패

 

JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.

@Version

JPA는 엔티티가 변경되어 UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해서 비선점 잠금 쿼리를 실행한다. 버전이 일치하는 경우에만 데이터를 수정한다.

 

트랜잭션 충돌 시 트랜잭션 종료 시점에 익셉션이 발생한다.

예시 코드에서는 메서드 위에 @Transaction 애너테이션으로 트랜잭션 범위를 정했으므로 메서드가 리턴될 때 트랜잭션이 종료되고, 이 시점에 트랜잭션 충돌이 발생하면 OptimisticLockingFailureException이 발생한다.

표현 영역의 코드는 이 익셉션이 발생했는지에 따라 트랜잭션 충돌이 일어났는지 확인할 수 있다.

- 폼을 사용자에게 제공할 때 애그리거트 버전을 함꼐 제공하고, 폼을 서버에 전송할 때 버전을 함께 전송. 사용자가 전송한 버전과 애그리거트 버전이 동일한 경우에만 애그리거트 수정 기능을 수행하도록 한다.

 

비선점 잠금 방식을 여러 트랜잭션으로 확장하려면 애그리거트 정보를 뷰로 보여줄 때 버전 정보도 함께 사용자 화면에 전달해야 한다.

응용 서비스는 버전 값을 요청 데이터에서 전달받고 이를 이용해 애그리거트 버전과 일치하는지 확인하고, 일치하는 경우에만 기능을 수행한다.

8.3.1 강제 버전 증가

JPA EntityManager#find() 메서드로 엔티티를 구할 떄 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.

LockModeType.OPTIMISTIC_FORCE_INCREMENT: 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다. => 애그리거트 루트 엔티티가 아닌 다른 엔티티나 밸류가 변경되더라도 버전 값을 증가시킬 수 있으므로 비선점 잠금 기능을 안전하게 사용할 수 있다.

스프링 JPA를 사용하면 @Lock 이용해서 지정.

8.4 오프라인 선점 잠금

오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.

첫 번째 트랜잭션을 시작할 때 오프라인 잠금을 선점하고, 마지막 트랜잭션에서 잠금을 해제한다. 잠금을 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다. ex. 수정 기능

8.4.1 오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스

LockManager 인터페이스 작성

tryLock() - 잠금을 식별할 때 사용할 LockId를 리턴. 일단 잠금을 구하면 잠금을 해제하거나 잠금이 유효한지 검사하거나 잠금 유효 시간을 늘릴 때 LockId를 사용한다.

잠금을 선점하는 데 실패하면 LockException이 발생한다.

잠금을 선점한 이후에 실행하는 기능은 반드시 주어진 LockId()를 갖는 잠금이 유효한지 확인해야 한다.

8.4.2 DB를 이용한 LockManager 구현

코드로 구현