티스토리 뷰

프로젝트 진행 와중 선착순으로 설명회를 신청할 때 동시성을 고려해야 하는 상황이 발생했고 이를 

각각 낙관적 락과 비관적 락으로 해결해보는 과정을 보이려고 합니다.

 

먼저 프로젝트의 도메인을 간략하게 살펴 보도록 하겠습니다.

 

학부모는 설명회를 신청하고, 신청인원이 마감되어 있다면 대기 신청을 진행하게 됩니다.

따라서 각각 설명회(Presentation), 신청(Participation), 대기(Waiting) 의 도메인에 대한 설명을 해보겠습니다.

 

설명회 도메인

@Entity
@NoArgsConstructor
@Getter
public class Presentation {

    @Id
    @GeneratedValue
    private Long id;
    private String name;
    private Integer maxPerson;
    private Integer participantCnt;
    private Integer waitingCnt;
    
}

 

신청 도메인

@Entity
@NoArgsConstructor
@Getter
public class Participation {

    @Id @GeneratedValue
    private Long id;
    private LocalTime time;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Presentation presentation;
    
}

 

대기 신청

@Entity
@NoArgsConstructor
@Getter
public class Waiting {

    @Id @GeneratedValue
    private Long id;
    private Integer orders;
    private LocalTime localTime;
    
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Presentation presentation;

}

 

또한 해당하는 내용에 맞게 PresentationService.register() 를 만들어 보겠습니다.

클라이언트 쪽에서 PresentationId 를 기반으로 요청이 들어오게 되면 Persistence Level 에서 설명회를 조회를 하고 신청 정보를 Participation 테이블이 insert 를 하고 설명회의 신청인원 현황을 + 1 하게 됩니다.

 

따라서 PresentationId 를 기반으로 설명회를 조회하는 과정과 Presentation 도메인의 ParticipantCnt 를 +1 하는 과정에서 갱신 분실 문제가 발생하고 이로 인해서 예정 인원보다 많은 인원이 설명회를 신청할 수 있게 된다는 이슈가 발생합니다.

 

즉, Presentation 정보를 가져오는 과정에서 동시성 문제가 발생하게 됩니다.

 

Lock 없이 Presentation 정보를 가져오고 update 하는 과정을 살펴보겠습니다.

 

PresentationService

@Service
@RequiredArgsConstructor
@Slf4j
public class PresentationService {

     private final ParticipationRepository participationRepository;
     private final PresentationRepository presentationRepository;
     private final WaitingRepository waitingRepository;

     @Transactional
     public void registerWithNoLock(Long presentationId) {

         Presentation presentation = presentationRepository.findById(presentationId).get();
         log.info("presentation 상태 : {}/{} (신청/최대) | {} (대기)",
                 presentation.getParticipantCnt(), presentation.getMaxPerson(), presentation.getWaitingCnt());

         if (canParticipate(presentation)){
             participate(presentation);
         } else {
             wait(presentation);
         }
     }
     
    private boolean canParticipate(Presentation presentation){
         // 설명회에서 수용가능한 인원
         Integer maxPerson = presentation.getMaxPerson();
         // 신청 인원
         Integer participantCnt = presentation.getParticipantCnt();
         return maxPerson > participantCnt;
     }

     private void participate(Presentation presentation){
         // 설명회 인원수 증가
         presentation.plusParticipantCnt();

         Participation participation = Participation.createParticipation(presentation);
         participationRepository.save(participation);
     }

     private void wait(Presentation presentation){
         // 대기 인원 수 증가
         presentation.plusWaitingCnt();
         // 대기 번호 부여
         Integer waitingCnt = presentation.getWaitingCnt();

         Waiting waiting = Waiting.createWaiting(waitingCnt, presentation);
         waitingRepository.save(waiting);
     }
}

 

registerWithNoLock 의 메서드를 살펴 보겠습니다.

가져온 설명회 정보에서 신청가능 최대 인원과 신청 인원 현황을 바탕으로 학부모를 신청상태로 만들 것인지 대기 상태로 만들 것인지를 판단하고 그에 맞는 Participation / Waiting 정보를 데이터베이스에 insert 하게 됩니다.

 

Test를 통해 해당 올바르게 신청이 되는지 확인해 보겠습니다.

 

 

결과를 확인해보도록 하겠습니다. 설명회를 신청할 수 있는 최대 인원은 10명 으로 설정하고 Thread 갯수를 200개로 실험을 진행했습니다.

 

 

 

오류가 없이 작업이 이루어 지지만 Presentation 의 ParticipantCnt 값이 dirtyUpdate로 갱신 분실이 발생하고 여러개의 커넥션에서 같은 버전(상태)의 Presentation 정보를 Read 하기 때문에 설명회 신청을 10명 이상 할 수 있게 되었습니다.

 

DataBase 의 저장상태를 확인해 보겠습니다.

설명회에 신청가능 인원이 10 명임에도 불구하고 88명이 신청을 한 것을 확인 할 수 있습니다.

 

다음으로 비관적 락을 도입하여 이 동시성 문제를 해결해보겠습니다.

기존 Presentation Service 에서 메서드를 추가로 작성합니다. (추가적으로 repo 계층에서의 비관적락을 추가합니다.)

 

h2 의 경우에 데이터베이스의 락을 선택할 시에 for update 를 통해 데이터베이스 락을 획득하게 됩니다.

추가적으로 QueryHint 를 통해 락을 받으려고 하는 대기시간에 대한 timeout 을 설정 할 수 있습니다.

 

@QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value = "1000")})

 

비관적락 사용하기

 

비관적 Lock을 통해 Presentation 획득

 

마찬 가지로 Test 를 진행 해보겠습니다.

 

이번에는 순차적으로 설명회를 잘 신청하는 것을 확인해 볼 수 있습니다.

 

데이터베이스에서 또한 10개의 Particiaption 이 등록되어 있는 것을 확인 할 수 있었습니다.

 

다음으로는 낙관적 락을 살펴보겠습니다.

 

낙관적 락의 경우 @Version 을 사용하여 Presentation 을 업데이트 하게 됩니다.

즉 update 쿼리문이 나갈 때 version 에 대한 update 와 where 절을 통한 presentation 을 검색하게 됩니다.

만약 update 이전에 다른 Thread 에서 해당 row 에 대한 변경이 일어났다면 update 가 진행 되지 않을 것이고 OptimisticLock Exception 을 터트리게 됩니다. 

 

비관적 락의 경우 데이터베이스 락을 획득하고 관리했지만 낙관적 락의 경우 Version 을 통해 application level 에서 동시성을 관리하게 됩니다.

 

마찬가지로 Test 진행시에 수많은 오류 메시지를 볼 수 있었습니다.

내용은 version 충돌에 의한 OptimisticLock Exception 입니다.

 

오류에 대한 처리를 따로 가져가지 않은 결과 데이터베이스에는 23 개의 신청내역 (대기 + 신청)만 저장되게 되었습니다.

즉 동시에 여러 요청을 처리하게 된다면 낙관적락은 오류를 피하기 힘들어 보입니다.

 

또한 어플리케이션 레벨에서 이 오류에 대한 처리를 정의할 수 있는 점은 장점이자 단점입니다.

 

 

그렇다면 비관적 락과 낙관적 락중에 어떠한 것을 선택해야 할까요?

 

트래픽이 몰리지 않는 상황이거나 계좌나 선착순 같은 중요한 로직들 같은 경우에는 비관적락을 사용하는 것이 좋다고 생각합니다.

 

데이터 베이스는 서버와는 달리 고비용의 자원이고 비관적락의 경우에는 데이터베이스의 자원을 사용하게 됩니다. 동시성이슈가 별로 없는 상황에서 또한 락을 잡게 됩니다.

 

현재 어플리케이션의 상황에 맞게 락의 종류를 고르는 것을 권장합니다.

 

저의 프로젝트의 경우에는 선착순에 대한 로직이지만, 트래픽이 몰리는 상황이 전혀 아니였고, 다른 서비스 어플리케이션 들이 하나의 데이터베이스를 사용하는 상황이였기 때문에 데이터베이스에 대한 자원의 가치가 더 높았습니다.

 

따라서 트래픽이 몰리는 상황 전 까지는 낙관적락을 사용하기로 결정했습니다.

 

물론 이러한 상황속에서 좋은 방법으로 분산락의 방법이 있기는 하나 redis 에 대한 인프라 비용과 기술 내용 미숙지로 차차 도입을 할 예정입니다.

 

++ 추가)

최근에 좋은 기회로 이 부분에 대해서 의구심이 생기는 주제가 주어졌습니다.

현재 포스팅 기준으로는 h2가 commited read 기준으로 포스팅을 작성했는데요.

만약 mysql 같은 repeatable read 의 격리 수준의 DB에 대해서 update 상황이 발생하면 어떤 자료에 대해 update 를 하게 될까요?

추가로 설명을 더 하자면

 

commited read 의 경우 다른 트랜잭션에서 update 된 내용을 읽게 되고 비관적락에 대해서 where version 을 통해 update 여부를 확인하여 구분을 할 수 있습니다. 하지만 repeatable read 라면 ? 다른 트랜잭션에서 update 된 내용을 알지 못하고 where version = ? 이 항상 true 이게 되므로 항상 update 를 진행 할 수 있겠네요? 뭔가 이상합니다.

 

다시한번 실험을 통해 알아 보겠습니다. datasource 를 h2 에서 mysql 로 먼저 바꾸어 봅시다.

 

여기서 재미있는 문제가 발생합니다.

 

알맞게 원하는 대로 구현이 나오긴 했지만...

 

오류가 낙관적락에 대한 exception 이 아닌 deadLock 과 관련된 exception 들이 무수히 터져나옵니다.

 

왜 다른 결과가 나오게 된 것일까? MySql 과 h2 데이터 베이스 사이에 락 관련하여 차이로 인해 발생되는 결과 였습니다.

기본적으로 h2 에서의 낙관적 락에서의 사이클은 흐름은 다음과 같습니다.

1. 설명회 내용 조회 (lock X)

2. 해당 설명회에 대한 대기/신청 을 위한 row 데이터 삽입

3. 설명회를 업데이트 하기 위해서 update 를 위한 설명회 row에 대한 lock 점유 (sql : update)

4. update 하면서 version 을 체킹 하게 되는데 read - commited 이므로 이전에 커밋된 내용이 있으면 update 반영이 0이므로 해당 내용을 통해 optimistic lock exception 발생

 

그렇다면 mysql 에서는 왜 deadLock 이 터질까?

흐름은 똑같습니다.

 

1. 설명회 내용 조회 (lock X)

2. 해당 설명회에 대한 대기/신청 을 위한 row 데이터 삽입 => mysql 에서는 참조되는 row 즉 설명회에 대한 shared lock 이 걸림

3. 설명회를 업데이트 하기 위해서 update 를 위한 설명회 row에 대한 x - lock 점유 (sql : update)

4. update 하면서 version 을 체킹 하게 되는데 repeated commit 입장에서 ... 

=> 이부분은 공식적인 문서로는 확인 할 수 없으나 추측하기로는 mysql 의 경우 inno DB(스토리지 엔진) 에서 undo 레코드 읽기를 통한 repeatable read 를 보장합니다. 즉 select 에 대해서는 undo 레코드를 update 에 대해서는 undo 레코드에 대해서가 아닌 실제 데이터에 대한 update 를 진행하려고해서 해당 설명회를 update 못하고 맞는 version 을 찾지못해 update 를 못하는 특성이 나오는 것 같습니다.

 

즉 inno DB 에서 sql update 문을 처리 할 때는 undo 영역이 아닌 스토리지 영역을 update 하기 때문에 version controll 이 올바르게 유지 되는 것 같습니다 (추측입니다.)

 

추가적으로 deadLock이 왜 발생하는 지 보겠습니다. 여기서 주의 깊게 봐야할 것은 같은 transaction 안에서는 s-lock 과 x-lock 의 충돌이 발생하지 않습니다. 추가적으로 insert 문을 진행 할 때 참조 되는 row 에 locking 이 걸린다는 것을 주의 깊게 봐야 합니다. 고로 여러 트랜잭션간에 s-lock을 얻는 과정과 update 에 대한 x - lock 얻기가 맞물려 dead lock 이 발생하고 예외가 발생하는 것입니다.

댓글
05-20 15:23
Total
Today
Yesterday
링크