➡️ 두 스레드는 개념적으로 동일하지만 물리적으로 서로 다른 애그리거트 객체를 사용함. 이 경우 일관성이 깨지는 문제가 발생할 수 있다.
먼저 애그리거트를 구한 스레드가 애그리거트 사용이 끝날 때까지 다른 스레드가 해당 애그리거트를 수정하지 못하게 막는 방식
선점 잠금의 동작 방식
- 스레드1이 선점 잠금 방식으로 애그리거트를 구한 뒤 스레드2가 같은 애그리거트를 구함
- 이때 스레드2는 스레드1이 애그리거트에 대한 잠금을 해제할 때까지 블로킹됨
- 스레드1이 트랜잭션을 커밋하면 잠금이 해제된다
선점 잠금은 보통 DBMS가 제공하는 행단위 잠금을 사용해서 구현
JPA EntityManger의 find()
메서드에 LockModeType.PESSIMISTIC_WRITE
를 값으로 전달하면 해당 엔티티와 매핑된 테이블을 이용해서 선점 잠금 방식을 적용할 수 있음
Order order = entityManager.find(Order.class, orderNo, LockModeType.PERSSIMISTIC_WRITE);
JPA 프로바이더와 DBMS에 따라 잠금 모드 구현이 다름
하이버네이트: PESSIMISTIC_WRITE
를 잠금 모드로 사용하면 for update
쿼리를 이용해서 선점 잠금을 구현
스프링 데이터 JPA:@Lock
애너테이션을 사용해서 잠금 모드를 지정함
public interface MemberRepository extends Repository<Member, MemberId> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select m from Member m where m.id = :id")
Optional<Member> findByIdForUpdate(@Param("id") MemberId memberId);
8.2.1 선점 잠금과 교착 상태
Map<String, Object> hints = new HashMap<>();
hints.put("javax.persistence.lock.timeout", 2000);
Order order = entityManager.find(Order.class, orderNo, LockModeType.PERSSIMISTIC_WRITE);
@QueryHints
애너테이션을 사용해서 쿼리 힌트를 지정할 수 있음public interface MemberRepository extends Repository<Member, 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);
선점 잠금으로 모든 트랜잭션 충돌 문제가 해결되는 것은 아님.
한 스레드에서 데이터를 조회한 후 변경하기 전에 다른 스레드에서 데이터를 변경하면 문제가 발생할 수 있음
➡️ 이때 비선점 잠금이 필요함
비선점 잠금: 동시에 접근하는 것을 막는 대신, 변경한 데이터를 실제 DBMS에 반영하는 시점에 변경 가능 여부를 확인하는 방식
특징
비선점 잠금에선 버전을 이용해 데이터를 관리함
UPDATE aggtable SET version = version + 1, colx = ?, coly = ?
WHERE aggid = ? and version = 현재버전
JPA는 버전을 이용한 비선점 잠금 기능을 지원함
@Version
애너테이션을 붙이고 매핑되는 테이블에 버전을 저장할 칼럼을 추가하면 됨@Entity
@Table(name = "purchase_order")
@Access(AccessType.FIELD)
public class Order {
@EmbeddedId
private OrderNo number;
@Version
private long version;
...
UPDATE purchase_order SET ...생략, version = version + 1
WHERE number = ? and version = 10
응용 서비스는 버전에 대해 알 필요가 없음
비선점 잠금을 위한 쿼리를 실행할 때 쿼리 실행 결과로 수정된 행의 개수가 0이면 이미 누군가가 앞서 데이터를 수정한 것
➡️ 이는 트랜잭션이 충돌한 것이므로 트랜잭션 종료 시점에 익셉션(OptimisticLockingFailureException
)이 발생함
➡️ 응용 서비스 또는 표현 영역에서 해당 예외를 적절하게 처리
@Service
public class ChangeShippingService {
@Transactional
public void changeShipping(ChangeShippingRequest changeReq) {
Optional<Order> orderOpt = orderRepository.findById(new OrderNo(changeReq.getNumber()));
Order order = orderOpt.orElseThrow(() -> new NoOrderException());
order.changeShippingInfo(changeReq.getShippingInfo());
}
...
}
matchVersion
메서드는 현재 애그리거트의 버전과 인자로 전달받은 버전이 일치하면 true
를, 아니라면 false
를 리턴하도록 구현VersionConflictException
은 이미 누군가가 애그리거트를 수정했다는 것을 의미하고, OptimisticLockingFailureException
은 이미 누군가가 거의 동시에 애그리거트를 수정했다는 것을 의미함8.3.1 강제 버전 증가
LockModeType.OPTIMISTIC_FORCE_INCREMENT
를 사용하면 해당 엔티티의 상태가 변경되었는지에 상관없이 트랜잭션 종료 시점에 버전 값 증가 처리@Lock
애너테이션을 이용해서 지정하면 됨8.4.1 오프라인 선점 잠금을 위한 LockManager 인터페이스와 관련 클래스
LockManager
인터페이스public interface LockManager {
// type과 id를 파라미터로 갖고, 각각 잠글 대상 타입과 식별자를 값으로 전달
// 잠금을 식별할 때 사용할 LockId를 return
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;
}
LockId
클래스public class LockId {
private String value;
public LockId(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
LockManager#tryLock()
을 이용해 잠금을 시도tryLock()
은 LockId
를 리턴// 서비스: 서비스는 잠금 ID를 리턴한다
public DataAndLockId getDataWithLock(Long id) {
// 1. 오프라인 선점 시도
LockId lockId = lockManager.tryLock("data", id);
// 2. 기능 실행
Data data = someDao.select(id);
return new DataAndLockId(data, lockId);
}
// 서비스: 잠금을 해제한다.
public void edit(EditRequest editReq, LockId lockId) {
// 1. 잠금 선점 확인
lockManager.checkLock(lockId);
// 2. 기능 실행
...
// 3. 잠금 해제
lockManager.releaseLock(lockId);
}
LockId
는 뷰와 컨트롤러를 거쳐 잠금을 해제하는 서비스 코드에 전달됨8.4.2 DB를 이용한 LockManager 구현
DB를 이용해 LockManager
를 구현할 수도 있음
잠금 정보를 저장할 테이블과 인덱스를 생성하는 쿼리
expiration_time
칼럼을 사용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);
Order
타입의 1번 식별자를 갖는 애그리거트에 대한 잠금을 구하고 싶다면 다음의 insert 쿼리를 이용해서 locks
테이블에 데이터를 삽입함insert into locks values ('Order', '1', '생성한lockid', '2016-03-28 09:10:00');
locks
테이블의 데이터를 담을 LockData
클래스를 다음과 같이 작성
isExpend()
메서드는 유효 시간이 지났는지를 판단할 때 사용
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 테이블을 이용해서 LockManager를 구현하기
@Component
public class SpringLockManager implements LockManager {
private int lockTimeout = 5 * 60 * 1000;
private JdbcTemplate jdbcTemplate;
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);
LockId lockId = new LockId(UUID.randomUUID().toString());
locking(type, id, lockId);
return 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);
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 테이블에 데이터를 삽입한다
* 데이터 삽입 결과가 없으면 익셉션을 발생시킨다
* DuplicateKeyException이 발생하면 LockingFailException을 발생시킨다
*/
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;
}
/**
* 잠금이 유효한지 검사한다
*/
@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;
}
}
checkAlreadyLocked()
메서드를 이용해서 이미 잠금이 선점됐는지 확인하고, locking()
메서드로 잠금을 선점