-
8장 - 애그리거트 트랜잭션 관리📕 book/도메인 주도 개발 시작하기 2023. 1. 12. 22:32
📌 애그리거트와 트랜잭션
한 애그리거트를 두 사용자가 동시에 변경할 경우 애그리거트의 일관성이 깨질 수 있다.
일관성이 깨지는 문제가 발생하지 않도록 하려면 아래 두 가지 중 하나를 해야 한다.
- 사용자 1이 정보를 조회하고 상태를 변경하는 동안 사용자2가 애그리거트를 수정하지 못하게 막는다
- 사용자1이 정보를 조회한 후 사용자2가 정보를 변경하면, 사용자1이 애그리거트를 다시 조회한 뒤 수정하도록 한다.
DBMS가 지원하는 트랜잭션과 함께 애그리거트를 위한 추가적인 트랜잭션 처리 기법이 필요하다!!
대표적인 트랜잭션 처리 방식에는 선점, 비선점 잠금의 두 가지 방식이 있다.
🙅🏻 선점 잠금
💡 선점 잠금(Pessimistic Lock)
- 먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식한 스레드가 애그리거트를 구하고 수정하는 동안 다른 스레드가 수정할 수 없다.
→ 애그리거트 수정 시 발생하는 데이터 충돌 문제를 해소할 수 있다.
선점 잠금은 보통 DBMS가 제공하는행단 위 잠금을 사용해 구현한다.
- 특정 레코드에 한 커넥션만 접근할 수 있다.
JPA EntityManager는 LockModeType을 인자로 받는 find() 메서드를 제공한다.
- LockModeType.PESSIMISTIC_WRITE 를 값으로 전달 시 해당 엔티티와 매핑된 테이블을 이용해 선점 잠금 방식을 적용할 수 있다.
Order order = entityManager.find( Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE);
JPA 프로바이더, DBMS에 따라 잠금 모드 구현이 다르다!
- 하이버네이트 - PESSIMISTIC_WRITE 를 잠금모드로 사용하면 ‘for update’ 쿼리를 이용해 구현
스프링 데이터 JPA는 @Lock 애너테이션을 사용해 잠금 모드를 지정한다.
public interface MemberRepository extends JpaRepository<Member, MemberId> { @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT m FROM Memeber m where m.id = :id") Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId); }
선점 잠금과 교착 상태
🔥 선점 잠금 기능을 사용 시 잠금 순서에 따른 교착상태(deadlock)가 발생하지 않도록 주의해야 한다.
선점 잠금에 따른 교착 상태는 사용자 수가 많을 때 발생할 가능성이 높고 사용자 수가 많아지면 교착 상태에 빠지는 스레드는 더 빠르게 증가한다.
이런 문제가 발생하지 않도록 하려면 잠금을 구할 때 최대 대기 시간을 지정해야 한다.
⇒ JPA에선 최대 대기 시간을 지정하기 위해 힌트를 사용한다.
Map<String, Object> hints = new HashMap<>(); hints.put("javax.persistence.lock.timeout", 2000); Order order = entityManager.find( Order.class, orderNo, LockModeType.PESSIMISTIC_WRITE, hints);
- “javax.persistence.lock.timeout” 힌트 - 잠금을 구하는 대기 시간을 밀리초 단위로 지정
- 지정 시간이내에 잠금을 구하지 못하면 exception 발생
⚠️ DBMS에 따라 힌트가 적용되지 않을 수도 있다.
- 사용하는 DBMS에 따라 JPA가 어떤 식으로 대기 시간을 처리하는지 반드시 확인해야 한다.
스프링 데이터 JPA는 @QueryHints 애너테이션을 사용해 쿼리 힌트를 지정할 수 있다.
public interface MemberRepository extends Repository<Member, MemberId> { Optional<Member> findById(MemberId memberId); @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({ @QueryHint(name = "javax.persistence.lock.timeout", value = "3000") }) @Query("select m from Member m where m.id = :id") Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId); void save(Member member); }
🙆🏻♂️ 비선점 잠금
선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아니다!!
📖 비선점 잠금
- 동시에 접근하는 것을 막는 대신 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식
비선점 잠금을 구현하려면 애그리거트에 버전으로 사용할 숫자 타입 프로퍼티를 추가해야 한다.
→ 애그리거트 수정할 때마다 버전으로 사용할 프로퍼티 값이 1씩 증가
버전 프로퍼티 값 증가 쿼리
UPDATE aggtable SET version = version + 1, colx = ?, coly = ? WHERE aggid = ? and version = 현재버전
수정할 애그리거트와 매핑되는 테이블의 버전 값이 현재 애그리거트의 버전과 동일한 경우에만 데이터를 수정
수정에 성공하면 버전 값을 1 증가
- 다른 트랜잭션이 먼저 데이터를 수정해 버전 값이 바뀌면 데이터 수정에 실패 ❌
JPA는 버전을 이용한 비선점 잠금 기능을 지원한다.
- 버전으로 사용할 필드에 @Version 애너테이션을 붙이고 매핑되는 테이블에 버전을 저장할 컬럼을 추가
@Entity @Table(name = "purchase_order") @Access(AccessType.FIELD) public class Order { @EmbeddedId private OrderNo number; @Version private long version; }
- UPDATE 쿼리를 실행할 때 @Version에 명시한 필드를 이용해 비선점 잠금 쿼리를 실행
UPDATE purchase_order SET ..., version = version + 1 WHERE number = ? and version = {현재 버전}
응용 서비스는 버전에 대해 알 필요가 없다.
- 기능 실행 과정에서 애그리거트 데이터가 변경되면 JPA는 트랜잭션 종료 시점에 비선점 잠금을 위한 쿼리를 실행
@Service public class ChangeShippingService { ... @Transactional public void changeShipping(ChangeShippingRequest changeReq) { Optional<Order> orderOpt = orderRepository.findById(new OrderNo(changeReq.getNumber())); checkNoOrder(order); order.changeShippingInfo(changeReq.getShippingInfo()); } }
비선점 잠금을 위한 쿼리 실행 시 쿼리 실행 결과로 수정된 행의 개수가 0이라면 이미 누군가 데이터를 수정한 것.
⇒ 트랜잭션이 충돌한 경우, 트랜잭션 종료 시점에 exception 발생
- OptimisticLockingFailureException
강제 버전 증가
❗애그리거트에서 루트 엔티티가 아닌 다른 엔티티의 값만 변경되는 경우
- JPA는 루트 엔티티의 버전 값을 증가시키지 않는다.
- 루트 엔티티 자체의 값은 바뀌는 것이 없으므로 버전 값을 갱신하지 않는 것이다.
⇒ 애그리거트의 구성요소 중 일부 값이 바뀌었으므로 논리적으로 그 애그리거트는 바뀐 것이다.
- 따라서, 루트 애그리거트의 버전 값이 증가해야 비선점 잠금이 올바르게 동작한다.
이런 문제를 해결하기 위해 JPA는 강제로 버전 값을 증가시키는 잠금 모드를 지원한다.
@Repository public class JpaOrderRepository implements OrderRepository { @PersistenceContext private EntityManager entityManager; @Override public Order findByIdOptimisticLockMode(OrderNo id) { return entityManager.find( Order.class, id, **LockModeType.OPTIMISTIC_FORCE_INCREMENT**); } }
LockModeType.OPTIMISTIC_FORCE_INCREMENT 를 사용하면 해당 엔티티의 상태가 변경되었는지 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리를 한다
스프링 데이터 JPA를 사용 시 @Lock 애너테이션을 이용해 지정하면 된다.
📌 오프라인 선점 잠금
💡 오프라인 선점 잠금(Offline Perssimistic Lock)
- 단일 트랜잭션에서 동시 변경을 막는 선점 잠금 방식과 달리 오프라인 선점 잠금은 여러 트랜잭션에 걸쳐 동시 변경을 막는다.- 첫 번째 트랜잭션 시작 시 오프라인 잠금을 선점
- 마지막 트랜잭션에서 잠금을 해제
잠금 해제하기 전까지 다른 사용자는 잠금을 구할 수 없다.
- 잠금을 해제하지 않으면 다른 사용자는 영원히 잠금을 구할 수 없는 상황이 발생한다.
이런 사태를 방지하기 위해 오프라인 선점 방식은 잠금 유효 시간을 가져야 한다.
- 유효 시간이 지나면 자동으로 잠금 해제
- 일정 주기로 유효 시간을 증가시키는 방식도 필요
오프라인 선점 잠금을 위한 LockManager 인터페이스, 그와 관련된 클래스
오프라인 선점 잠금은 크게 4가지 기능이 필요
- 잠금 선점 시도
- 잠금 확인
- 잠금 해제
- 잠금 유효시간 연장
LockManager 인터페이스
public interface LockManager { LockId tryLock(String type, String id) throws LockException; void checkLock(LockId lockId) throws LockException; void releaseLock(LockId lockId) throws LockException; void extendLockExpiration(LockId lockId, long inc) throws LockException; }
tryLock()
- type과 id를 파라미터로 갖는다.
- 잠글 대상 타입과 식별자
- 잠금을 식별할 때 사용할 LockId 리턴 (잠금 성공 시)
- 잠금을 선점하는 데 실패하면 LockException 발생
LockId 클래스
public class LockId { private String value; public LockId(String value) { this.value = value; } public String getValue() { return value; } }
- LockId를 어딘가 보관해야 한다(이게 없다면 잠금을 해제할 수 없다)
잠금 해제 코드
// 컨트롤러 - 서비스를 호출 시 잠금 ID 함께 전달 @RequestMapping(value = "/some/edit/{id}", method = RequestMethod.POST) public String edit(@PathVariable("id") Long id, @ModelAttribute("editReq") EditRequest editReq, @RequestParam("lid") String lockIdValue) { editReq.setId(id); someEditService.edit(editReq, new LockId(lockIdValue); model.addAttribute("data", data); return "editSuccess"; } // 서비스 - 잠금 선점 확인 후 해제 public void edit(EditRequest editReq, LockId lockId) { // 잠금 선점 확인 lockManager.checkLock(lockId); // 기능 실행 ... // 잠금 해제 lockManager.releaseLock(lockId); }
checkLock()
- 잠금 선점한 이후에 실행하는 기능들은 반드시 주어진 lockId를 갖는 잠금이 유효한지 확인해야 한다.
다음과 같은 상황이 발생할 수 있다.
- 잠금 유효 시간이 지났다면 이미 다른 사용자가 잠금을 선점
- 잠금을 선점하지 않은 사용자가 기능을 실행했다면 기능 실행을 막아야 한다.
DB를 이용한 LockManager 구현
잠금 정보를 저장할 테이블과 인덱스를 생성
// MySQL 용 create table locks ( `type` varchar(255), id varchar(255), lockid varchar(255), expiration_time datetime, primary key (`type`, id) ) character set utf8; create unique index locks_idx ON locks (lockid);
- type, id 컬럼을 primary key로 지정해 동시에 두 사용자가 특정 타입 데이터에 대한 잠금을 구하는 것을 방지
- 잠금마다 새로운 LockId 사용 → lockid 필드를 유니크 인덱스로 설정
- 잠금 유효 시간 - expiration_time 컬럼 사용
l
ocks 테이블의 데이터를 담을 LockData 클래스
public class LockData { private String type; private String id; private String lockId; private long timestamp; public LockData(String type, String id, String lockId, long timestamp) { this.type = type; this.id = id; this.lockId = lockId; this.timestamp = timestamp; } public String getType() { return type; } public String getId() { return id; } public String getLockId() { return lockId; } public long getTimestamp() { return timestamp; } // 유효시간 지났는지 판단할 때 사용 public boolean isExpired() { return timestamp < System.currentTimeMillis(); } }
Locks 테이블을 이용한 LockManger 구현 코드
- JdbcTemplate를 이용한 SpringLockManager의 tryLock()
@Component public class SpringLockManager implements LockManager { private int lockTimeout = 5 * 60 * 1000; private JdbcTemplate jdbcTemplate; // locks 테이블에서 조회한 데이터를 LockData로 매핑하기 위한 RowMapper private RowMapper<LockData> lockDataRowMapper = (rs, rowNum) -> new LockData(rs.getString(1), rs.getString(2), rs.getString(3), rs.getTimestamp(4).getTime()); public SpringLockManager(JdbcTemplate jdbcTemplate) { this.jdbcTemplate = jdbcTemplate; } // type과 id에 대한 잠금을 시도 @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public LockId tryLock(String type, String id) throws LockException { checkAlreadyLocked(type, id); // 해당 type, id에 잠금 존재하는지 검사 LockId lockId = new LockId(UUID.randomUUID().toString()); // 새로운 LockId 생성 locking(type, id, lockId); // 잠금 생성 return lockId; // lockId 리턴 } // 이미 잠금이 존재하는지 검사 private void checkAlreadyLocked(String type, String id) { List<LockData> locks = jdbcTemplate.query( "select * from locks where type = ? and id = ?", lockDataRowMapper, type, id); Optional<LockData> lockData = handleExpiration(locks); // 유효기간이 지나지 않은 잠금이 있다면 exception 발생 if (lockData.isPresent()) throw new AlreadyLockedException(); } // 잠금 유효 시간이 지나면 해당 데이터 삭제 후 값이 없는 Optional 리턴 // 지나지 않았다면 해당 LockData를 가진 Optional 리턴 private Optional<LockData> handleExpiration(List<LockData> locks) { if (locks.isEmpty()) return Optional.empty(); LockData lockData = locks.get(0); if (lockData.isExpired()) { jdbcTemplate.update( "delete from locks where type = ? and id = ?", lockData.getType(), lockData.getId()); return Optional.empty(); } else { return Optional.of(lockData); } } // 잠금을 위해 locks 테이블에 데이터를 삽입 private void locking(String type, String id, LockId lockId) { try { int updatedCount = jdbcTemplate.update( "insert into locks values (?, ?, ?, ?)", type, id, lockId.getValue(), new Timestamp(getExpirationTime())); if (updatedCount == 0) throw new LockingFailException(); } catch (DuplicateKeyException e) { throw new LockingFailException(e); } } // 현재 시간 기준으로 lockTimeout 이후 시간을 유효 시간으로 생성 private long getExpirationTime() { return System.currentTimeMillis() + lockTimeout; } ... }
SpringLockManager 의 나머지 구현코드
// 잠금이 유효한지 검사 @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void checkLock(LockId lockId) throws LockException { Optional<LockData> lockData = getLockData(lockId); if (!lockData.isPresent()) throw new NoLockException(); } // lockId에 해당하는 LockData를 구한다. private Optional<LockData> getLockData(LockId lockId) { List<LockData> locks = jdbcTemplate.query( "select * from locks where lockid = ?", lockDataRowMapper, lockId.getValue()); return handleExpiration(locks); } // lockId에 해당하는 잠금 유효 시간을 Inc 만큼 늘린다. @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void extendLockExpiration(LockId lockId, long inc) throws LockException { Optional<LockData> lockDataOpt = getLockData(lockId); LockData lockData = lockDataOpt.orElseThrow(() -> new NoLockException()); jdbcTemplate.update( "update locks set expiration_time = ? where type = ? AND id = ?", new Timestamp(lockData.getTimestamp() + inc), lockData.getType(), lockData.getId()); } // lockId에 해당하는 잠금 데이터를 locks 테이블에서 삭제 @Transactional(propagation = Propagation.REQUIRES_NEW) @Override public void releaseLock(LockId lockId) throws LockException { jdbcTemplate.update("delete from locks where lockid = ?", lockId.getValue()); } public void setLockTimeout(int lockTimeout) { this.lockTimeout = lockTimeout; }
참고자료
'📕 book > 도메인 주도 개발 시작하기' 카테고리의 다른 글
10장 - 이벤트 (1) 2023.01.16 9장 - 도메인 모델과 바운디드 컨텍스트 (0) 2023.01.13 7장 - 도메인 서비스 (0) 2023.01.12 6장 - 응용 서비스와 표현 영역 (0) 2023.01.05 5장 - 스프링 데이터 JPA를 이용한 조회 기능 (0) 2023.01.05