본문 바로가기

프로젝트

우리 프로젝트에서 동시성 이슈를 해결하는 방법 - 3. 비관적 잠금

지금까지 알아본 우리 프로젝트에서 동시성 이슈를 해결하는 방법은 다음과 같다.

1. 유니크 제약 조건

2. 낙관적 잠금

마지막으로 비관적 잠금에 대해서 알아보도록 하자.

 

문제 상황 정의

우리 서비스는 청원의 동의 수를 실시간으로 반영하고 보여줘야 했고, 거의 모든 조회 요청에는 동의 수가 필요했다. 조회수를 가지고 Sorting 하는 비즈니스 적인 요구사항 또한 있었기 때문에, 매번 해당 청원의 동의들의 개수를 세는 것은 불가능하다 판단했다. 그렇기에 별도의 agreeCount라는 칼럼을 추가하여 관리하고자 했다.

@Entity
public class Petition {
    @OptimisticLock(excluded = true)
    private Integer agreeCount;
    
    // 1. DB 유니크 조건
    @OneToMany(cascade = CascadeType.ALL, fetch = FetchType.EAGER, mappedBy = "petition")
    private List<Agreement> agreements = new ArrayList<>();
    
    // 2. 낙관적 잠금
    @OneToOne(cascade = CascadeType.PERSIST, mappedBy = "petition")
    private Answer answer;
    @Version
    private Long version;
    
    public void agree(String content, Long userId) {
        this.agreements.add(new Agreement(content, userId, this));
        this.agreeCount.increment();
    }
}

1, 2번 글에서 다뤘던 부분과 이번에 다룰 agreeCount만을 남겨두면 위와 같다. (@OptimisticLock(excluded = true) 조건을 활용하여 낙관적 잠금의 관리 대상에서 배재했다.)

동시에 10명의 다른 사용자가 요청을 보낸다면 agreeCount=0인 상태를 읽게 되고, 모두 update agreeCount=1 의 쿼리가 실행되게 된다. 실제 DB에는 Agreement에는 10개의 동의가 저장이 되지만, agreeCount에 경우에는 1로 저장이 되는 문제가 발생한다.

이러한 문제를 해결해야 했고, 그 방법으로 비관적 잠금을 도입하게 되었다.

 

비관적 잠금

비관적 잠금(이 글에서는 select for update를 한정해서 설명)은 DB에 조회를 할 때, "내가 이거 바꿀 거니까 아무한테도 보여주지 마!"라는 메시지를 함께 던진다는 개념으로 볼 수 있다. 실제로 select ~~ for update의 쿼리는 트랜잭션이 커밋 또는 롤백되는 시점까지 해당 row에 읽기, 쓰기 요청에 모두 잠금을 걸어 다른 트랜잭션에서는 접근하지 못하게 된다. 다른 트랜잭션이 접근하지 못하게 되면 안정성을 얻지만 그 만큼 성능의 저하가 예상된다.

 

Petition 테이블 비관적 잠금

지금의 구조를 유지한 채 가장 쉽게 생각하고 적용했던 방법은 동의 요청 트랜잭션에서 해당 Petition row에 비관적 잠금을 거는 방식이다. '@Lock(LockModeType.PESSIMISTIC_WRITE)'을 활용하여 간단하게 구현할 수 있다.

//PetitionService
@Transactional
public void agree(Long petitionId, String content, Long userId) {
    Petition petition = petitionRepository.findByIdWithLock(petitionId).orElseThrow();
    petition.agree(content, userId);
}

//PetitionRepository
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select p from Petition p where p.id = ?1")
Optional<Petition> findByIdWithLock(Long id);

지금의 이 방법은 구조의 변화 없이 편하게 진행할 수 있었지만, 해당 트랜잭션 동안에는 해당 row에 조회조차 허용하지 않게 된다. 당연히 서비스에 큰 성능 저하가 예상된다. (실제 테스트 속도만 보더라도 상당히 느려짐이 체감된다.)

 

이 부분을 고민하다 도달한 결론은, 해당 agreeCount에 대해서만 락을 걸게 된다면 성능적으로 이점을 가져갈 수 있지 않을까? 그렇게 해서 AgreeCount라는 별도의 테이블로 분리하여 이를 해결해보고자 했다.

 

AgreeCount 테이블 비관적 잠금

기존 Petition에서 AgreeCount만을 별도 분리를 진행했다.

@Entity
public class AgreeCount {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private Integer count;

    private Long petitionId;
 }

AgreeCount의 테이블을 분리하게 되면, 서비스의 코드는 아래와 같이 구현할 수 있다. Peition의 row가 아닌 AgreeCount를 증가하는 작업을 할 때에만 비관적 잠금을 걸게 되었기 때문에 성능적으로도 이득을 볼 수 있을 것이라 예상했다.

@Transactional
public void agree(Long petitionId, String content, Long userId) {
    Petition petition = findPetitionById(petitionId);
    petition.agree(content, userId);

    AgreeCount agreeCount = agreeCountRepository.findByPetitionIdWithLock(petitionId).orElseThrow();
    agreeCount.increment();
}

성능 측정

아래 테스트 코드를 통해 간단한 성능 측정을 진행해 볼 수 있었다.

@Test
public void applyAgreementByManyWithConcurrency() throws InterruptedException {
    Long petitionId = petitionCommandService.createPetition(DORM_PETITION_REQUEST, petitionOwner.getId());
    int numberOfThreads = 1000;

    List<User> users = saveUsersNumberOf(numberOfThreads);

    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    for (int i = 0; i < numberOfThreads; i++) {
        AgreementRequest agreementRequest = new AgreementRequest("description" + i);
        User user = users.get(i);
        service.execute(() -> {
            try {
                petitionCommandService.agree(agreementRequest, petitionId, user.getId());
            } finally {
                latch.countDown();
            }
        });
    }
    latch.await();

    AgreeCount agreeCount = agreeCountRepository.findByPetitionId(petitionId).orElseThrow();
    assertThat(agreeCount.getCount()).isEqualTo(numberOfThreads);
}

1000명의 사용자에게 동시에 해당 청원에 동의 요청을 보내는 경합 상황을 만들었을 때, 걸리는 시간을 분석해 봤다.

왼쪽(AgreeCount 비관적 잠금), 오른쪽(Petition 비관적 잠금)

1000개의 요청에 대해 AgreeCount 비관적 잠금의 경우 23초가 걸렸고, Petition 비관적 잠금에 경우에는 36초가 걸렸다. Petition잠금에서 실패하는 경우도 발생했는데, 이는 HikariPool Connection의 요청 타임아웃 시간인 30초로 설정되었기 때문에 생긴 문제다. 테이블을 분리하고 별도로 잠금을 진행했을 때, 테스트 실행 시간을 기준으로 35%의 성능 개선을 이뤄낼 수 있었다.

 

정리

동시성 이슈를 해결하기 위해 고민하다보니, 다양한 방법을 활용하여 서비스의 치명적인 위험으로부터 보호할 수 있었다. 지난 서비스에서 Read-Write DB를 단순히 경험을 해보자라는 취지로 구현했었는데, 비관적 잠금을 도입한 지금에 와서야 그 이유를 알게 된 것 같다. 얼른 도입하러 가야겠습니다. 

 

JPA를 벗어나면 보이는 또 다른 방법

여러 면접을 진행해 가며, 나름의 확신을 비관적 잠금을 가지고 해결한 문제를 설명했다. 하지만, 이는 다소 JPA의 읽고 영속성 컨텍스트의 업데이트를 반영하는 로직에 한정된 생각이었다. JPA로 DB를 본격적으로 다루기 시작했던 나에게 부족한 인사이트 였다. 이 방식은 아래의 간단한 Native SQL을 적용함으로써 해결할 수 있다. 이 update구문이 가지고 있는 Atomicity를 보장하기 때문이다. 기존의 방식은 읽고 1을 더하고 쓰기의 3가지의 과정을 거치기 때문에 Atomicity를 보장하기 위해서는 비관적 잠금이 필요했던 것이다.

update agree_count set count=count+1 where petition_id = 1

이러한 방식으로 구현을 수정을 하였을 때, 코드 또한 더 깔끔해졌고 성능적으로도 이득을 볼 것으로 예상된다.

해당 작업 풀리퀘스트 에서 수정 작업을 확인할 수 있다.