다른 곳에서는 낙관적 락(optimistic lock) 이라고 표현하기도 합니다. 이름에는 락이 들어가서 왠지 Lock이 사용될 것 같지만 전혀 사용되지 않기 때문에 유의바랍니다.
낙관적 동시성 제어의 경우, 따로 Lock 을 설정하지 않습니다. 수정 시점에 다른 사용자에 의해 값이 변경되었는 지 확인하는 검사를 거치기 위해 따로 변경일시나 버전 컬럼을 따로 두어 구현한다. 다시말해 수정 시점에 다른 사용자에 의해 값이 변경되었는 지를 확인하기 위해 변경일시나 버전을 사용합니다.
tx.begin();
select current_mentees from MENTOR_INFO where version=1 and user_id = 1;
update MENTOR_INFO set current_mentees + 1, version=2 from where version=1 and user_id = 1;
tx.commit();
TX1 이 select 한 이후 TX2가 이미 update 까지 끝낸 상황이라면 해당 로우의 version은 2로 값이 바뀌었기 때문에
TX1 이 update 쿼리를 날리는 시점에는 version 1에 대한 정보를 찾을 수 없기때문에 예외를 반환하게 됩니다. 즉, 이런식으로 수정 시점에 수정하려고 하는 데이터가 무엇이였는 지를 알 수 있도록 하여, 데이터의 일관성을 유지할 수 있도록 합니다.
위와 같은 상황을 간단하게 표로 나타내면 아래와 같습니다.
| 시간 | 세션-1, TX1 | 세션-2, TX2 |
|---|---|---|
| 10:00 | BEGIN; | |
| 10:01 | SELECT * FROM MENTORE_INFO WHERE user_id = 1 where VERSION = 1 and user_id = 1; | BEGIN; |
| 10:01 | SELECT * FROM MENTORE_INFO WHERE user_id = 1 where VERSION = 1 and user_id = 1; | |
| 10:01 | UPDATE MENROT_INFO SET CURRENT_MENTEES = CURRENT_MENTEES + 1, VERSION = 2 WHERE VERSION = 1 | |
| 10:01 | COMIT; | |
| 10:02 | UPDATE MENTOR_INFO SET CURRENT_MENTEES = CURRENT_MENTEES + 1, VERSION = 2 WHERE VERSION = 1 AND USER_ID = 1; => X 다른 사용자에 의해 데이터 변경이 일어났음. | |
| 10:03 | COMMIT; |
TX2 에 의해 이미 TX1 이 조회 후 수정할 시점에는 이미 데이터에 대한 일관성이 무너진 상태입니다. 따라서 이런 경우에는 따로 애플리케이션에서 예외가 일어날 경우 다시 조회 후 업데이트를 할 수 있도록 해야하거나, 실패했을 경우 필요한 조치를 취해야 합니다.
JPA 에서는 낙관적 제어와 관련하여 LockModeType.OPTIMISTIC 옵션을 제공하고 있습니다.
public interface MentorInfoRepository extends JpaRepository<MentorInfo, Long> {
@Lock(LockModeType.OPTIMISTIC)
@Query("select m from MentorInfo m where m.userId = :userId")
MentorInfo findMentorInfoByUserId(long userId);
}
관련 엔티티 필드에@Version 어노테이션을 붙혀서 사용합니다. @Version 애너테이션이 붙은 필드는 자동으로 낙관적 락이 적용됩니다. 당연히 @Column 애너테이션을 사용해서 따로 테이블 컬럼을 지정할 수 있습니다.
적용되는 필드 타입으로는 int, Integer, short, Short, long, Long, java.sql.Timestamp 를 사용할 수 있습니다. 현재 프로젝트의 경우 Long 으로 하였습니다.
@Table(name = "MENTOR_INFO")
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class MentorInfo extends BaseEntity {
// .. 생략
@Column(name = "max_mentees")
private long maxMentees;
@Column(name = "current_mentees")
private long currentMentees;
@Version
@Column(name = "version")
private Long version;
}
비관적 동시성 제어와 달리, 락을 사용하지 않기 때문에 version 이 달라져서 업데이트에 실패했을 경우(즉, 트랜잭션 충돌로 인해 다른 사용자가 이미 값을 변경했을 경우)에 대한 코드를 직접 개발자가 작성해야합니다.
아래의 경우는 MentorRequestService 를 감싸는 MentoreRequestServcieFacade 를 만들었습니다. 그리고 업데이트가 실패했을 경우 다시 요청할 수 있도록 while 반복문 안에서 mentoreRequestService.request(...); 를 호출하되, 만약 업데이트가 실패하여 예외가 발생했을 경우 바로 다시 멘토요청을 하는 것이 아닌 잠시 멈춘 후 요청하기 위해 Thread.sleep(50) 을 걸었습니다.
public class MentoreRequestServicer{
// ... 생략
@Transactional
public void request(AuthenticatedUser authUser, MentorRequestDto mentorRequestDto) {
MentorInfo mentorInfo = findMentorInfo(mentorRequestDto);
MentorRequest mentorRequest = MentorRequest.create(authUser, mentorRequestDto);
saveMentorRequest(mentorRequest);
mentorInfo.increaseMentee();
}
}
@Slf4j
@Component
public class MentorRequestServiceFacade {
private final MentorRequestService mentorRequestService;
public MentorRequestServiceFacade(MentorRequestService mentorRequestService) {
this.mentorRequestService = mentorRequestService;
}
public void request(AuthenticatedUser authUser, MentorRequestDto mentorRequestDto) throws InterruptedException {
while (true) {
try {
mentorRequestService.request(authUser, mentorRequestDto);
break;
} catch (InterruptedException e) {
Thread.sleep(50);
}
}
}
위의 코드는 예외가 발생할 경우 예외가 발생하지 않을 때 까지 계속해서 재시도를 하고 있기 때문에, 경우에 따라서 아래와 같이 재시도 횟수 같은 것들을 두어도 괜찮을 것입니다.
Facade 계층을 따로 두는 것 보다는 @Retry 와 같은 어노테이션을 만들고 예외에 따라 재시도 하는 로직을 따로 분리하는 것도 좋은 방법일 수 있습니다.
테스트를 돌려보면, 테스트를 통과하는 것을 알 수 있습니다.

번외로 다른 트랜잭션이 해당 레코드를 업데이트 했을 때는 어떤 예외가 나올까요?
아래와 같이 JPA 구현체로 등록되어 있는 hibernate 의 StateObjectStateException
예외가 발생하고 이를 spring 에선 ObjectOptimisiticLockingFailureException 으로 처리하는 것을 볼 수 있습니다.
