본문 바로가기

프로젝트

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

이전 글에서 하나의 청원에 동시에 한 유저로부터 여러 동의 요청이 발생했을 때, DB의 유니크 조건을 활용함으로써 하나의 청원 동의만을 허용할 수 있었다. 이번 글에서는 청원에 관리자가 답변을 달 때, 발생할 수 있는 동시성 문제를 해결하는 과정에서 적용한 낙관전 잠금,  Entity Versioing에 대해 다루고자 한다.

 

문제 상황 정의

우리 서비스는 청원의 동의 수가 일정 수 이상을 넘게 되면, 관리자가 해당 청원에 답변을 남긴다. 이때, 청원의 답변은 최대 1개만 남길 수 있는 요구사항이 정의되어 있다. 이러한 요구사항을 위해 사용되는 청원과 답변 Entity의 Column들만 간단하게 작성해보면 아래와 같다. 비즈니스적인 요구사항으로 '답변-> 청원'의 방향의 연관관계는 필요 없다고 판단했기에 일대일 단방향 매핑을 사용했다. Answer의 영속성 관리를 위해 Cascade 설정을 진행했고, 매번 조회할 필요가 없는 Answer에 경우 Lazy로딩을 적용했다.
물론 답변을 등록하게 되었을 때, 답변 INSERT 쿼리가 한 번, 청원의 외래 키 UPDATE 쿼리가 한 번 더 발생하지만, 지금의 구조로 작성했을 때 쿼리 두 번 이상의 가치를 준다고 생각한다.

@Entity
public class Petition {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "answer_id")
    private Answer answer;
    
    public void answer(String content) {
        if (isAnswered()) {
            throw new AlreadyAnsweredPetition();
        }
        this.answer = new Answer(content);
    }
}
public class Answer {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "answer_id")
    private Long id;
    private String content;
 }

 

이때, 아래 동시성 테스트를 진행하게 되었을 때, 하나의 답변만 등록이 되어야 한다. 하지만, 당연하게도 10개의 답변이 모두 등록된다. @OneToOne관계라 해서 상대방 엔티티가 하나만 등록되는  것이 아니며, 외래 키의 값이 마지막 저장한 답변을 가리키게 된다. 

 

 @Test
void 동시_청원_답변() {
    Petition petition = petitionRepository.save(new Petition("content"));

    int numberOfThreads = 10;
    ExecutorService service = Executors.newFixedThreadPool(numberOfThreads);
    CountDownLatch latch = new CountDownLatch(numberOfThreads);

    for (int i = 0; i < numberOfThreads; i++) {
        service.execute(() -> {
                try {
                    petitionService.answerPetition(petition.getId(), "Answer");
                } catch (Exception e) {
                    System.out.println("중복됨!");
                } finally {
                    latch.countDown();
                }
            }
        );
    }
    assertThat(answerRepository.findAll()).hasSize(1);
}

 

 

그렇다면, 이 문제를 어떻게 해결할 수 있을까?

 

문제 해결 방법 1. DB 유니크 제약 조건

가장 먼저 떠오르는 방법은, DB 유니크 제약조건이다. 지금의 Entity 구조에서 적용해보면 다음과 같다.

@Entity
public class Petition {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "answer_id", unique = true)
    private Answer answer;
}

하지만, 지금의 방식에서는 unique 조건은 아무런 의미가 없는 제약 조건이 된다. 연관관계의 주인이 청원 테이블인데, 청원의 답변은 매번 등록될 때마다 새로운 id를 발급받게 되게 된다. 즉, 답변이 3개 달리더라도 unique 제약조건과 관계없이 모두 정상 생성된다.

유니크 방식으로 이 문제를 해결하기 위해서는 연관관계의 주인을 답변 테이블이 가지도록 하면 된다. petition_id에 유니크 조건을 걸게 되면, 처음 저장된 답변만 저장되고 그 이후에는 DataIntegrationViolationException을 뿜게 된다. 이 방식으로 구현하기 위해서는 양방향 연관관계를 뚫어야 하고, 연관관계의 주인이 답변이 되기 때문에 전체 구조에도 변화를 주어야 한다. 이 방법이 썩 맘에 들지 않는다. '답변이 존재하지 않는 청원'에 대한 조회 로직을 구현하게 되었을 때 상당히 까다로워졌다.

문제 해결 방법 2. 낙관적 잠금

이 문제는 키워드만 들어본 Versioning으로 너무나 쉽게 해결할 수 있었다. @Version 어노테이션을 추가한 Column 하나만을 추가함으로써 해결할 수 있었다.

@Entity
public class Petition {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String content;
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "answer_id")
    private Answer answer;
    @Version
    private Integer version;
}

위의 설정만을 진행하더라도 아래와 같이 version을 함께 db에 전달해주며 해당 version과 맞지 않을 시 ObjectOptimisticLockingFailureException이 발생하게 된다. 즉, 맨 처음 답변 이외에는 모두 실패하게 되고 위의 테스트를 통과하게 된다. 간단한 설정 하나로, 동시성 이슈에서 하나의 답변만을 허용하도록 설정을 진행할 수 있었다.

 

 

 

 

낙관적 잠금은 언제나 옳은가?

너무 편리한 방법이기 때문에 모든 부분에 대해서 낙관적 잠금을 적용해도 되지 않을까? 라는 생각이 들었고 우리 프로젝트에 바로 적용해보려고 했다. 하지만, 역시나 은탄환은 없었다.

 

계속 의문을 가지고 있는 부분이지만, 우리 프로젝트에서는 조회 성능과 쉬운 쿼리문의 작성을 위해 agreeCount라는 별도의 Column을 가지고 있었다. 이는 청원 동의가 이뤄질 때마다 agreeCount가 +1되는 구조를 가진다. 아래 구조에서 Versioning을 적용하게 되면 agreeCount가 바뀔 때마다 Version이 바뀌게 되고, 너무 많은 실패가 발생하게 된다. 동시에 서로 다른 10명의 동의 요청이 들어오게 된다면(실제로는 모두 성공해야 한다.), 첫 번째 요청만 성공하게 되고 이후에 요청에 대해서는 모두 실패하게 된다. 동일 사용자에 대한 동시 동의를 막기 위해서 더 많은 것을 잃어버리게 되는 상황이다. @OptimisticLock(excluded = true) 조건을 사용해서 낙관적 잠금에서 배제할 수 있지만, 동시성 이슈를 해결하지는 못한다.

@Entity
public class Petition{
    private String title;
    @Lob
    private String content;
    
    private Integer agreeCount = 0;
    @OneToMany(mappedBy = "petition", orphanRemoval = true)
    private final List<Agreement> agreements = new ArrayList<>();
    
    @OneToOne(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
    @JoinColumn(name = "answer_id")
    private Answer answer;
    @Version
    private Integer version;
}

 

우리 서비스에서 게시글을 수정하거나 답변을 다는 것과 같이 그 경합이 잘 발생하지 않는 경우에는 Versioning을 사용하는 것이 좋은 방법으로 보인다. 또한 청원 동의 테이블에 동의를 추가하는 한 번만 허용되는 요청에 대해서는 DB의 유니크 조건을 활용하는 것이 좋은 해결책이 될 수 있을 것으로 보인다.

 

그렇다면 AgreeCount는 어떤 방식으로 해결할 수 있을까? 그 방법은 다음 글에서 다루는 '비관적 잠금'을 통해 진행해보고자 한다.