과정을 즐기자

RDB에서 동시성 문제는 왜 발생하며 어떻게 해결해야 하나? 본문

Database

RDB에서 동시성 문제는 왜 발생하며 어떻게 해결해야 하나?

320Hwany 2023. 9. 30. 16:18

백엔드 애플리케이션을 개발하면서 동시성 문제를 처리해야 하는 경우가 있습니다.

DB의 Isolation Level이 있어서 적절한 레벨로 설정해주면 DB에서 동시성 문제는 발생하지 않을까요?

이번 글에서는 동시성 문제를 처리하는 방법에 대해 알아보겠습니다.

트랜잭션의 Isolation Level

트랜잭션의 특징인 ACID에서 I를 나타내는 Isolation Level은 여러 트랜잭션이 동시에 실행될 때 트랜잭션끼리의

격리 수준을 말합니다. 4가지 레벨이 있는데 격리성이 높은 순서대로 Serializable, Repeatable Read,

Read Commited, Read Uncommited가 있습니다.

MySQL, InnoDB에서는 기본적으로 Repeatable Read 레벨을 사용합니다.

이 레벨에서는 MVCC, Lock을 이용해서 데이터의 무결성을 유지할 수 있습니다.

동시성 문제

위에서 이야기한 것 처럼 Repeatable Read 레벨을 사용하면 MVCC, Lock을 이용하여 데이터 무결성을 유지하는데

왜 우리는 동시성 문제를 주의해서 처리해야 한다고 하는 것일까요? DB 단에서 보장하는 것이 아닐까요?

 

DB에서 보장하는 수준의 무결성은 다음과 같습니다.

특정 컬럼의 값을 100증가시키는 트랜잭션 A, 특정 컬럼의 값을 200증가 시키는 B가 동시에 어떤 컬럼에 적용한다고 가정해보면

Repeatable Read 레벨에서 조금이라도 먼저 실행된 트랜잭션이 Exclusive Lock을 획득하고 데이터를 변경한 후

Lock을 해제할 때까지 다른 트랜잭션은 Exclusive Lock을 획득할 수 없습니다.

즉 A, B를 동시에 요청하더라도 총 증가한 컬럼의 값은 최종적으로 300이 됨을 보장할 수 있다는 것입니다.

그러면 DB가 모든 동시성 문제를 보장한다는 것일까요?

 

안타깝게도 그런 것은 아닙니다. 하나의 예시를 들어 설명해보겠습니다.

어떤 댓글이 있다고 가정해봅시다. 이 댓글에 1000명의 회원이 동시에 좋아요를 누른다면 좋아요 수는 1000 증가해야 합니다.

그러면 위에서 말한 것처럼 Repeatable Read 레벨에서 각 트랜잭션 Lock을 획득하고 처리하면 문제 없이 

처리 되지 않을까요?

@Service
public class CommentUpdater {

    private final CommentRepository commentRepository;

    public CommentUpdater(final CommentRepository commentRepository) {
        this.commentRepository = commentRepository;
    }

    @Transactional
    public void pressLike(final long commentId) {
        Comment comment = commentRepository.getById(commentId);
        comment.pressLike();
    }
}

위와 같이 좋아요를 누르는 pressLike 메소드를 만들었고 아래는 10개의 쓰레드로 1000개의 요청을 동시에 했을 때

누락되지 않고 처리할 수 있는지를 테스트하는 코드입니다.

@Test
@DisplayName("여러 명의 사용자가 동시에 같은 댓글에 좋아요를 눌러도 좋아요 수가 누락되지 않고 동시성 문제를 처리")
void pressLikeConcurrencyTest() throws ExecutionException, InterruptedException {
    // given
    Comment comment = Comment.builder()
            .likesCount(0)
            .build();

    commentRepository.save(comment);

    // given2 - thread
    int numThreads = 10;
    int incrementsPerThread = 100;

    ExecutorService executorService = Executors.newFixedThreadPool(numThreads);
    List<CompletableFuture<Void>> futures = new ArrayList<>();

    // when
    for (int i = 0; i < numThreads; i++) {
        CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
            for (int j = 0; j < incrementsPerThread; j++) {
                commentUpdater.pressLike(comment.getId());
            }
        }, executorService);

        futures.add(future);
    }

    CompletableFuture<Void> allOf = CompletableFuture.allOf(
    				futures
    				.toArray(new CompletableFuture[0])
               	 );
    allOf.get();

    executorService.shutdown();

    // then
    Comment psComment = commentRepository.getById(comment.getId());
    int expectedCount = incrementsPerThread * numThreads;
    assertThat(psComment.getLikesCount()).isEqualTo(expectedCount);
}

테스트 결과는 좋아요 수가 1000이 아니라 156이라는 결과가 나왔습니다.

pressLike 메소드에서 실제로 좋아요 수를 증가시키는 로직은 DB가 Exclusive Lock의 획득과 해제로 동시성을 보장합니다.

하지만 문제는 getById로 댓글 entity를 가져올 때 발생합니다.

즉 트랜잭션 A, B가 동시에 좋아요 수가 0인 댓글을 가져옵니다. 이때 Exclusive Lock이 아니라 Shared Lock을 획득합니다.

(사실 MySQL의 격리 레벨에 따라 Consistent Non-Locking Read로 lock을 걸지 않고 조회를 할 수 있습니다)

A는 1을 증가시키고 커밋하고 B도 1을 증가시키고 커밋을 했습니다.

하지만 둘다 좋아요 수가 0일 때 가져왔기 때문에 두 트랜잭션 모두 실행되었음에도 좋아요 수의 결과는 2가 아니라 1이됩니다.

비관적 락

이 문제를 해결하기 위해 비관적 락을 사용해보겠습니다.

비관적 락은 트랜잭션이 시작될 때 미리 Shared Lock 또는 Exclusive Lock을 걸고 시작하는 방법입니다.

Shared Lock은 다른 트랜잭션에서 읽기만 가능하고 다른 트랜잭션의 Shared Lock과 자원을 공유합니다.

하지만 Exclusive Lock은 접근할 수 없으며 Exclusive Lock은 읽기과 쓰기 둘 다 공유하지 않습니다.

 

MySQL, InnoDB를 사용한다면 기본적으로 Repeatable Read 격리 수준을 사용하는데 이 경우에는 

getById로 댓글 entity를 가져오는 시점에 Lock을 걸지 않고 Consistent Non-Locking Read로 읽습니다.

하지만 위와 같은 문제를 해결하기 위해서는 getById로 댓글 entity를 가져오는 시점에 Exclusive Lock을 거는

Locking Read를 해야합니다.  

이 부분은 DB에서 자동적으로 진행하는 것이 아니라 개발자가 따로 챙겨줘야 하는 부분입니다.

 

Lock과 Consistent Non-Locking Read 에 대한 더 자세한 내용은 아래 글을 참고해주세요

 

Lock을 사용한 격리와 MVCC가 나온 이유

여러 트랜잭션이 동시에 실행된다면 데이터의 정합성에 문제가 생길 수 있으며 여러 트랜잭션을 순차적으로 실행한다면 성능이 좋지 않을 수 있습니다. 트랜잭션의 특징인 ACID 중에서 I인 Isolati

320hwany.tistory.com

@Service
public class CommentUpdater {

    private final CommentRepository commentRepository;

    public CommentUpdater(final CommentRepository commentRepository) {
        this.commentRepository = commentRepository;
    }

    @Transactional
    public void pressLike(final long commentId) {
        Comment comment = commentRepository.getByIdWithPessimisticLock(commentId);
        comment.pressLike();
    }
}

(getByIdWithPessimisticLock 메소드는 setLockMode를 PESSIMISTIC_WRITE로 설정하였습니다)

 

위에서 실패한 테스트 코드를 다시 실행하면 좋아요 수가 누락되지 않고 1000이 됨을 확인할 수 있습니다.

비관적 락은 미리 락을 거는 방식이기 때문에 충돌이 많이 발생하는 경우에 사용하며 데이터의 무결성을 보장하는

수준이 높습니다. 다만 충돌이 많이 발생하지 않을 경우에 사용하면 성능이 좋지 않을 수 있습니다.

낙관적 락

낙관적 락은 미리 자원에 락을 걸지 않고 충돌이 발생했을 때 롤백을 하여 처리하는 방법입니다.

이 경우에는 롤백 처리 구현을 따로 해줘야 합니다.

@Service
public class CommentUpdater {

    private final CommentRepository commentRepository;

    public CommentUpdater(final CommentRepository commentRepository) {
        this.commentRepository = commentRepository;
    }

    @Transactional
    public void pressLike(final long commentId) {
        Comment comment = commentRepository.getByIdWithOptimisticLock(commentId);
        comment.pressLike();
    }
}
@Service
public class CommentFacade {

    private final CommentUpdater commentUpdater;

    public CommentFacade(final CommentUpdater commentUpdater) {
        this.CommentUpdater = commentUpdater;
    }

    public void increaseLikesCountWithOptimisticLock(final long commentId) 
    						throws InterruptedException {
        while (true) {
            try {
                commentUpdater.pressLike(commentId);

                break;
            } catch (Exception e) {
                Thread.sleep(50);
            }
        }
    }
}

(getByIdWithOptimisticLock 메소드는 setLockMode를 OPTIMISTIC으로 설정하였습니다)

 

낙관적 락은 미리 자원에 Lock을 걸지 않기 때문에 충돌이 많이 발생하지 않을 경우에는 성능상 이점이 있습니다.

하지만 충돌이 많이 발생할 경우에 롤백 처리에 대한 비용이 많이 들어가기 때문에 성능상 불리할 수 있습니다.

정리

기본적으로 DB의 Isolation 레벨에 따라 동시성 문제는 어느정도 해결되지만 격리 레벨에 따라 Lock을 사용하지 않고

MVCC를 사용하는 경우 추가적으로 개발자가 따로 비관적 락, 낙관적 락을 사용해야 처리할 수 있는 경우도 있습니다.

비관적 락의 경우 충돌이 많이 발생하고 데이터 무결성이 중요한 로직에 사용하면 좋고

낙관적 락의 경우 충돌이 많이 발생하지 않는 경우에 사용하면 성능상 이점이 있을 수 있습니다.