본문 바로가기

프로젝트

우리 프로젝트에서 동시성 이슈를 해결하는 방법 - 1. DB Unique 조건을 활용하여

지스트 청원 프로젝트에서는 각 사용자마다 한 번의 청원 동의만을 허용합니다. 여러 요청이 동시에 들어왔을 때에도, 처음 들어온 하나의 요청만을 허용하도록 동시성과 관련하여 고민한 부분들을 작성하고자 합니다.

 

테스트 코드 작성

10개의 Thread를 생성하고 동시에 같은 유저의 id로 동의 요청을 보내는 테스트를 작성했고, 실행하게 되면 한 명의 유저가 10개의 청원 동의를 하는 결과를 가지게 됩니다. 10개의 동시 요청이 오더라도 하나의 동의만을 허용하도록 코드를 수정해야만 합니다.

@Test
public void applyAgreementWithConcurrency() throws InterruptedException {
    Long petitionId = petitionService.createPetition(DORM_PETITION_REQUEST, petitionOwner.getId());
    int numberOfThreads = 10;

    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);
    for (int i = 0; i < numberOfThreads; i++) {
        AgreementRequest agreementRequest = new AgreementRequest("동의합니다" + i);
        service.execute(() -> {
            try {
                petitionService.agree(agreementRequest, petitionId, user.getId());
            } catch (DuplicatedAgreementException e) {
                System.out.println("---동의 중복---" + agreementRequest.getDescription());
            }
            latch.countDown();
        });
    }
    latch.await();
    assertThat(petitionService.retrieveNumberOfAgreements(petitionId)).isEqualTo(1);
}
//PetitionService
@Transactional
public void agree(AgreementRequest request, Long petitionId, Long userId) {
    Petition petition = findPetitionById(petitionId);
    User user = findUserById(userId);
    petition.applyAgreement(user, request.getDescription());
}
//Petition
public void applyAgreement(User user, String description) {
    for (Agreement agreement : agreements) {
        if (agreement.isAgreedBy(user.getId())) {
            throw new DuplicatedAgreementException();
        }
    }
    this.agreements.add(new Agreement(user.getId(), description, this));
}

해결책 1. Service 코드에 synchronized 사용하기

이 문제를 가장 쉽게 해결할 수 있는 방법을 생각해 본 것은 아래의 agree메서드에 synchronized를 붙이는 방법입니다.

//PetitionService
@Transactional
public synchronized void agree(AgreementRequest request, Long petitionId, Long userId) {
    Petition petition = findPetitionById(petitionId);
    User user = findUserById(userId);
    petition.applyAgreement(user, request.getDescription());
}

이 방식을 사용하면, 효율성은 떨어질 수 있지만 쉽게 해결할 수 있을 줄 알았습니다. 하지만 역시나 기대를 저버리지 않고 실패했으며, 10 중 3~4개가 저장이 되는 현상이 발생했습니다. 

싱글톤으로 등록된 PetitionService의 메서드는 하나의 스레드만을 허용했음에도 의도치 않게 동작했습니다. 아예 되질 않았으면 몰라도 애매하게 3,4를 번갈아 나오니 유추할 수도 없었습니다. 이런 와중에 우테코의 '빛' 웨지님께서 이 링크를 하사해주셨습니다. 링크의 내용을 설명하면 다음과 같습니다.

청원 동의 동시성 발생 상황 모식도

자바의 synchronized는 Monitor 방식으로 구현이 되는데, 메소드가 실행하는 시점에서 모니터를 소유(own)하고, 실행이 끝난 후 풀어주는(release) 방식으로 진행됩니다. 위의 agree메서드에 synchronized를 설정했을 때에 위와 같은 상황이 발생할 가능성이 매우 높습니다. Thread1의 청원 동의 메서드가 완료된 후 커밋이 되기 직전에 Monitor를 대기 중이던 Thread 2가 바로 이어서 실행, 청원 동의가 이뤄지기 전의 정보를 접근하게 되어 에러 발생 없이 2개의 청원 동의가 이루어지게 됩니다. 이 문제를 해결하기 위해서는 트랜잭션 위의 단계에서 synchronized를 설정해 주어야 합니다.(물론 성능상의 문제로 절대 지양해야 합니다.) 또한 Service단계에서의 synchronized 또한 성능상의 문제로 지양해야 하기 때문에, 다른 방법을 선택해야만 합니다.

 

번외: 트랜잭션 잠금 레벨을 수정해서 위의 테스트를 통과 시키기

1. Isolation.READ_UNCOMMITTED

@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public synchronized void agree(AgreementRequest request, Long petitionId, Long userId) {
    Petition petition = findPetitionById(petitionId);
    User user = findUserById(userId);
    petition.applyAgreement(user, request.getDescription());
    em.flush();
}

위의 그림을 봤을 때, '트랜잭션이 커밋되기 전의 정보를 읽는다면 이 테스트를 통과시킬 수 있지 않을까?'라는 의문이 들었고 위의 방식을 통해 해결할 수 있었습니다. em.flush()를 통해 영속성 콘텍스트를 flush를 해줘야만 DB의 반영(commit X, flush O)되어 위의 테스트를 정상 통과하게 됩니다. READ_UNCOMMITED가 가지고 있는 위험성이 있기 때문에 이 방법은 시도만 해볼 뿐, 도입하지 않습니다.

2. Isolation.SERIALIZABLE

@Transactional(isolation = Isolation.SERIALIZABLE)
public void agree(AgreementRequest request, Long petitionId, Long userId) {
    Petition petition = findPetitionById(petitionId);
    User user = findUserById(userId);
    petition.applyAgreement(user, request.getDescription());
}

하나의 트랜잭션이 끝나고 나서 다음 트랜잭션이 진행된다고 생각했던 SERIALIZABLE 격리 레벨을 도입하게 되면, 당연히 이 문제를 해결할 줄 알았으나, 그렇지 않았습니다. H2 공식문서에서는 SERIALIZABLE 방식에 아래의 설명으로 작성되어 있었고, 쓰기 옵션에 대한 동시성과 직렬화된 실행을 보장하지는 않는다고 적혀있기 때문에 위의 테스트를 통과하지 못한 것으로 예상됩니다.

Serializable
Dirty reads, non-repeatable reads, and phantom reads aren't possible. Note that this isolation level in H2 currently doesn't ensure equivalence of concurrent and serializable execution of transactions that perform write operations. This isolation level is very expensive in databases with many tables. To enable, execute the SQL statement SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL SERIALIZABLE

 

해결책 2.  Agreement 테이블에 UniqueConstraint 설정하기

동시성 문제는 데이터베이스의 정보를 메모리에 가져와서 작업을 진행하기 때문에, 데이터베이스와의 sync를 매 순간 보장하지 못하기 때문에 발생한다 생각합니다. 청원 동의의 경우도 DB를 읽어왔을 때의 데이터가 쓸 때의 상태와 달라지기 때문에 의도치 않게 동작합니다. 이를 Agreement테이블에 UniqueConstraint 제약 조건을 설정함으로써 쉽게 해결할 수 있었습니다. (필자의 경우, Unique조건을 특정 칼럼 하나에 지정하는 줄 알았으나, 동시에 여러 칼럼을 묶어서 작성할 수 있다고 합니다.)

@Getter
@Entity
@Table(uniqueConstraints = @UniqueConstraint(columnNames = {"user_id", "petition_id"}))
public class Agreement extends UnmodifiableEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    @Lob
    private String description;
    @Column(name = "user_id")
    private Long userId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "petition_id")
    private Petition petition;
    // 메소드 생략
}

이 설정을 추가하게 되면, 커밋되는 시점에 유니크 조건을 만족하지 않는다면  DataIntegrityViolationException이 발생하게 되어 위의 테스트를 통과하게 됩니다.

이 방식으로 수정을 하게 되면 중복 조건을 Domain 레이어가 아닌 Persistent 레이어(@Transactional을 이용하는 Service 레이어가 더 올바른 표현 같음)에서 관리를 하게 되기 때문에, 도메인에서의 검증 로직의 필요성이 없어지게 되고 아래의 코드만으로도 충분하게 됩니다. 

// Petition
public void applyAgreement(User user, String description) {
    this.agreements.add(new Agreement(user.getId(), description, this));
}

 

고민되는 부분

1. 이 방법으로 수정하게 되었을 때 남게 되는 한 가지 고민은, 트랜잭션이 커밋되는 시점(Service 레이어가 종료되는 시점)에 생성되는 Exception이기 때문에 이를 ApplicationException으로 예외 전환을 해주기 위해서는 Controller 레이어에서 진행해야 하는 부분입니다. 예외 처리의 분기를 위해서는 필요한 부분이지만, Service계층에서 진행해야 한다고 생각되지만 우선은 이 정도로 타협하고자 합니다.

-> 이 부분에 경우에는 flush하는 시점에 Exception이 발생하기 때문에, 코드의 수정으로 Service레이어에서 해결이 가능하도록 처리할 수 있었습니다.

 

2. 기존에는 청원 동의들을 순회해가며 unique함을 검증했지만, 지금의 구현에서는 해당 기능들을 DB의 특징에 숨겨버린 구현이 되는 구조가 되어버립니다. 도메인 로직에 대한 검증을 모두 DB에 의존하게 되는 구조가 조금은 조심스럽네요. 도메인 로직은 유지한 채, 동시성을 위해 지금의 Unique Constraint를 사용할지에 대해 고민할 필요가 있을 것 같습니다.

-> 이는 효율적일 수 있으나, 도메인 로직을 DB의 제약조건에 의존한다 판단하여 기존의 방식으로 직접 순회하는 것으로 결정했습니다.

 

정리

`우리 서비스는 청원 동의 요청이 동시다발적으로 오게 된다면 어떻게 될까? `라는 의문점에 만든 테스트를 통과시키기 위해 다양한 방법으로 시도해보았고 해결할 수 있었습니다. Jpa의 낙관적 잠금과 같은 새로운 개념들을 공부해야만 이 문제를 해결할 수 있을 것이라 생각했지만, 유니크 조건만으로도 손쉽게 동시성을 제어할 수 있는 부분이 인상적이고 흥미로웠습니다. (성능적인 측면에서도 지금의 방법이 더 효과적일 것이라 생각합니다) 다시 한번 킹. 갓. 빛. 웨지에게 감사의 말씀 올립니다 ㅎㅎ