dev/solution

OptimisticLockException - JPA에서 동시성 문제 해결

오이호박참외 2024. 7. 6. 15:00

OptimisticLockException

JPA(Java Persistence API)에서 낙관적 락 관련 동시성 제어 문제를 처리할 때 사용되는 예외

 

OptimisticLockException은 동시에 여러 트랜잭션이 동일한 데이터를 수정하려고 할 때 발생하는 예외로, 낙관적 락(Optimistic Locking) 메커니즘을 사용할 때 주로 나타난다. 이 예외는 데이터 일관성을 유지하고 충돌을 방지하는 역할을 한다.

 

구체적으로, 낙관적 락은 트랜잭션이 데이터를 읽고 수정하는 동안 다른 트랜잭션이 해당 데이터를 변경하지 않았는지 확인하기 위해 버전 번호나 타임스탬프와 같은 메타데이터를 사용한다. 트랜잭션이 커밋될 때, 데이터의 버전 번호나 타임스탬프가 예상했던 것과 다르다면 OptimisticLockException이 발생하며, 이는 다른 트랜잭션이 해당 데이터를 수정했음을 의미한다.

 

OptimisticLockException 발생 시나리오

1. 처음 데이터 버전이 1이라고 가정하고, A와 B 두 명이 동시에 데이터를 수정하려고 한다.

2. A가 먼저 데이터를 수정하면 버전은 2로 업데이트된다.

3. 이후 B가 데이터를 수정하려고 할 때, B가 바라보던 버전은 1이지만 실제 현재 버전은 2이다.

 

이와 같이 B가 수정하려는 시점의 버전과 현재 버전이 다를 때, OptimisticLockException이 발생한다.

 

낙관적 락(Optimistic Locking)

 

데이터베이스에서 동시에 여러 트랜잭션이 동일한 데이터를 수정할 때 발생할 수 있는 충돌을 방지하기 위한 기법이다.

낙관적 락은 데이터베이스 레벨에서 락을 걸지 않고, 트랜잭션이 커밋할 때 충돌을 감지하고 해결하는 방식이다.

  • 트랜잭션이 데이터를 수정하고 커밋할 때, 현재 데이터의 버전 정보가 트랜잭션이 처음 읽었을 때의 버전 정보와 일치하는지 확인
  • 버전 정보가 일치하지 않으면 트랜잭션이 데이터를 수정한 것이므로, 충돌이 발생했다고 판단하고 OptimisticLockException 발생

구체적인 예시

데이터베이스에 Product라는 테이블이 있고, id, name, price, version 컬럼을 가지고 있는 상황 설정

1. 트랜잭션 A
트랜잭션 A가 Product 테이블에서 특정 제품 조회 (SELECT * FROM Product WHERE id = 1;)
조회된 제품 version 값은 1이고, 제품 가격을 100에서 120으로 변경

2. 트랜잭션 B
트랜잭션 B도 동일 제품 조회 (SELECT * FROM Product WHERE id = 1;)
조회된 제품 version 값은 1이고, 제품 가격을 100에서 150으로 변경

3. 트랜잭션 A 커밋
UPDATE Product SET price = 120, version = 2 WHERE id = 1 AND version = 1;
version 값이 2로 변경

4. 트랜잭션 B 커밋 시도
UPDATE Product SET price = 150, version = 2 WHERE id = 1 AND version = 1;
version 값이 이미 2로 변경되어 조건이 맞지 않는 오류 발생
OptimisticLockException 발생

 

전자결재 시스템에서의 동시성 문제와 OptimisticLockException 활용

 

회사에서 휴가 신청을 할 때, 전자결재 모듈을 한 번 정도는 이용해봤을 것이다.

결재라인을 지정해서 결재문서를 상신하는데, 결재라인을 변경하는 작업과 결재 승인 작업이 동시에 이루어져 문제가 발생할 수 있다.

이 경우에 OptimisticLockException을 활용할 수 있을 것 같다는 생각이 들었다.

 

예를 들어, A 사용자가 B 사용자 - C 사용자 순서로 결재라인을 지정하여 문서를 상신한 상황에서,

A 사용자가 C 사용자 - B 사용자 순서로 결재라인을 변경하고, 동시에 B 사용자가 해당 문서를 결재하려고 한다고 가정하자.

B 사용자는 자신의 결재 차례라고 생각하고 문서를 오픈하여 결재를 시도하지만, 이미 A 사용자가 결재라인을 변경하여 B 사용자가 결재할 차례가 아니라면 문제가 발생할 수 있다.

 

이 때, OptimisticLockException을 활용할 수 있다. Optimistic Locking 메커니즘을 통해 결재라인을 변경하는 작업과 결재 승인 작업이 동시에 이루어질 때 데이터의 일관성을 유지할 수 있다. 구체적으로, 데이터베이스 테이블에 버전 필드를 추가하여 각 트랜잭션이 데이터를 수정할 때 이 버전 번호를 확인하면 된다. 만약 두 작업이 동시에 같은 데이터를 수정하려고 시도하면, 버전 번호가 일치하지 않음을 감지하여 예외를 발생시키고, 해당 트랜잭션을 롤백하면 된다.

 

나의 경우는, 테이블에 필드를 추가하지는 않았다. 하지만, 동시성 문제를 처리한다는 점에서 OptimisticLockException을 사용하는 것이 의미상 일치한다고 생각했다. B 사용자가 문서를 조회하는 시점(B 사용자가 결재 차례)과 결재 승인 시점(A 사용자가 결재라인을 변경한 후)에 결재라인이 변경된 것은 각 시점의 버전이 달라서 발생하는 문제다. 따라서 결재가 승인되면 안 되기 때문에 롤백을 해야 하며, 이 경우 예외를 던지는 방법으로 로직을 작성했다.

 

OptimisticLockException 예외 처리 방법

service 단에서 throw new OptimisticLockException("line is moved !"); 으로 예외를 던진다.

import javax.persistence.OptimicticLockException;

// 결재라인변경 여부 확인 - 문서 조회 시 결재라인과 현재 시점의 결재라인 비교
boolean isApprovalLineChanged = checkApprovalLineChange(params);
if (!isApprovalLineChanged) {
	throw new OptimisticLockException("line is moved !");
}

 

controller 단에서 예외처리를 진행한다.

import javax.persistence.OptimisticLockException;

try{
	// 서비스 호출 등 비즈니스 로직 실행
} catch(Exception e) {
	LOG.error("Exception: ", e);

	if (e instanceof OptimisticLockException) {
		OptimisticLockException oe = (OptimisticLockException) e;
		LOG.error("OptimisticLockException: " + oe.getMessage());
		// 추가적인 예외 처리 로직 작성
	}
}

 

 

결론

JPA 예외가 너무 많아서 예외 처리에 대한 부분을 소홀히 하여, 모든 예외를 단순히 Exception 클래스로 처리하는 경우가 많았다. 코드 작성할 때 편리성을 추구했고, 예외 처리에 대한 이해가 부족해서 그랬던 것 같다. 그러나 이렇게 처리하면 예외의 구체적인 내용을 놓쳐서 디버깅이나 문제 해결에 어려움을 초래할 수 있다.

 

이번에 OptimisticLockException을 처음 사용해보면서 각 예외에 대한 이해가 필요하다는 생각이 들었다. 구체적인 예외를 사용하면 코드의 가독성도 높아지고, 유지 보수할 때에도 훨씬 편리하다는 장점이 있는 것 같다. 더 안정적이고 효율적인 개발을 하도록 노력해야겠다.