멘토 신청 기능 동시성 이슈 문제 해결기 #4 - DB 의 기능을 활용하기 2 (낙관적 동시성 제어)

kms·2023년 12월 24일
0

낙관적 동시성 제어(Optimistic Concurrency Control)

다른 곳에서는 낙관적 락(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:00BEGIN;
10:01SELECT * FROM MENTORE_INFO WHERE user_id = 1 where VERSION = 1 and user_id = 1;BEGIN;
10:01SELECT * FROM MENTORE_INFO WHERE user_id = 1 where VERSION = 1 and user_id = 1;
10:01UPDATE MENROT_INFO SET CURRENT_MENTEES = CURRENT_MENTEES + 1, VERSION = 2 WHERE VERSION = 1
10:01COMIT;
10:02UPDATE MENTOR_INFO SET CURRENT_MENTEES = CURRENT_MENTEES + 1, VERSION = 2 WHERE VERSION = 1 AND USER_ID = 1; => X 다른 사용자에 의해 데이터 변경이 일어났음.
10:03COMMIT;

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 으로 처리하는 것을 볼 수 있습니다.

참조

0개의 댓글