Toy Projects/Ditto - Discuss Today's Topic

Ditto 프로젝트 동시성 문제 해결하기 1편 - 낙관적 락 VS 비관적 락

정민됴 2024. 2. 24. 09:52

프로젝트를 진행하면서 아주 중요한 문제 중에 하나인 동시성 문제가 발생하는 것을 깨달았다.

일단 내 프로젝트에서는 좋아요를 누르는 부분이나 조회수에서 동시성 문제가 발생하는데 사실은 동시성 이슈가 생겨도 크게 상관없는 부분들이긴 했으나 만약 개발하는 기능이 잔고 차감과 같은 동시성에 아주 민감한 기능일 수도 있기 때문에 동시성 문제를 해결해보려고 한다!

 

트랜잭션이란?

@Transactional
    public PostLikeResponse pushPostLike(Long memberId, Long postId){
    
        Post post = postRepository.findById(postId).orElseThrow(() -> {
            throw new NoSuchPostException();
        });
        
        if (isPostDeleted(post)){
            throw new NoSuchPostException();
        }
        
        PostLike postLike = postLikeRepository.findByMemberIdAndPostId(memberId, postId).orElse(null);

        if (postLike != null){
            if (postLike.getStatus().equals(UsageStatus.ACTIVE)){
                postLike.deletePostLike();
            } else {
                postLike.pushPostLike();
            }
        } else {
            postLike = PostLike.builder()
                    .memberId(memberId)
                    .post(post).build();
            postLikeRepository.save(postLike);
        }

        return new PostLikeResponse(postLike);
    }

위는 게시글에 좋아요를 눌렀을 때 동작하는 메서드이다.

기존에 좋아요가 눌린 상태면 좋아요 수를 차감하고 좋아요가 눌리지 않은 상태면 좋아요 수를 증가시키는 로직이다.

또한 이 로직은 온전한 트랜잭션 안에서 동작하게 되는데 이는 @Transactional 덕분이다.

트랜잭션은 DB의 상태를 변경시키기 위해 수행하는 작업 단위이다.

 

그렇다면 왜 트랜잭션을 보장해야할까?

A가 B에게 백만원 송금을 한다고 가정해보자.

A 계좌에서는 백만원이 인출 됐지만 중간에 문제가 생겨 B에게는 100만원이 제대로 들어오지 못했다고 가정하자!

트랜잭션이 보장받지 못한다면 A만 돈을 잃고 B는 돈을 받지 못할 것이다.

 

그렇다면 트랜잭션을 어떤 방식으로 보장이 될까?

트랜잭션은 commit과 rollback을 가지고 있는데

모든 작업이 정상적으로 성공하여 데이터베이스에 정상 반영되는 것을 commit,

중간에 오류가 발생해 비정상적으로 종료됐을 시 다시 처음으로 돌아가는 것을 rollback이라고 한다.

예를 들어 A->B 송금과정에서 문제가 발생했을 시 A에게 돈이 차감되기 전으로 rollback 되는 것이다.

 

다시 동시성 문제로 돌아가보자!

트랜잭션이 보장된다고 해서 동시성 이슈가 생기지 않는 것은 아니다.

예를 들어서 위 그림과 같이 A와 B가 동시에 좋아요 0개인 게시물에 좋아요를 눌렀다고 가정해보자.

A가 좋아요 수를 1개 올리기도 전에 B가 좋아요 0개를 읽어온 후 1개로 변경했기 때문에 좋아요 수는 2가 아닌 1이 될 것이다.

이게 좋아요가 아니라 잔고에 대한 문제라면 A와 B가 C에게 거의 동시에 입금을 한다했을 때  A와 B가 보낸 돈이 둘다 들어오지 않고 조금이라도 늦게 입금한 사람의 돈만 들어오게 될 것이다!ㅜㅜ

동시성 문제 해결하기 - 낙관적 락(Optimistic Lock)

낙관적 락은 한 트랜잭션이 이 값 변경할 거야 라고 찜하면 다른 트랜잭션이 그 값에 동일한 조건으로는 값을 수정할 수 없게 막는 것이다.

낙관적 락은 JPA의 Version 속성을 이용하여 구현할 수 있는데 이 version으로 값을 변경할 수 있는지 없는지를 확인할 수 있다.

Alice와 Bob이 version1의 amount에 접근을 했을 때 Alice가 amount를 변경하면 version이 2가 되게 되고 Bob이 version2로 변경 요청을 했을 때 똑같은 version이기 때문에 값을 변경할 수 없게 된다.

version 뿐만아니라 hashcode 또는 timestamp를 사용하기도 한다.

 

낙관적 락은 데이터를 읽을 때 락을 걸지 않고, 데이터를 업데이트할 때 충돌이 발생하지 않았는지 확인하는 방식이다.

데드락 가능성이 적고 성능이 좋다는 장점이 있지만 충돌이 난다면 개발자가 수동으로 롤백처리를 한땀한땀 해줘야한다는 단점이 있다.

때문에 읽기가 쓰기보다 빈번하게 일어나는 곳에 사용하면 좋다.

동시성 문제 해결하기 - 비관적 락(Pessimistic Lock)

비관적 락은 Repeatable Read 또는 Serializable 정도의 격리성 수준을 제공한다.

Repeatable Read : 특정 행을 조회시 항상 같은 데이터를 응답하는 것을 보장

Serializable : 특정 트랜잭션이 사용중이 테이블의 모든 행을 다른 트랜잭션이 접근할 수 없도록 잠금

 

비관적 락은 트랜잭션이 시작될 때 Shared Lock 또는 Exclusive Lock을 걸고 시작하는데 수정을 하기 위해서는 수정하고 싶은 행을 들고 있는 트랜잭션이 종료되기까지를 기다려야한다.

Shared Lock : 다른 사용자가 동시에 읽을 수는 있지만 Update, Delete 작업 방지

Exclusive Lock : 다른 사용자가 읽기, 수정, 삭제 모두 불가능

비관적 락은 데이터를 읽을 때부터 락을 걸기 때문에 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없다.

조회 시점부터 락을 걸기 때문에 데이터 무결성을 보장하기 좋지만 데드락의 위험성이 존재한다.

Dead Lock : 프로세스가 자원을 얻지 못해 다음을 처리하지 못하는 상태, 한정된 자원을 여러 곳에서 사용하려고 할 때 발생

결론

비관적 Lock의 성능은 낙관적 Lock보다 떨어지지만 읽기보다 수정의 비율이 높아 문제가 자주 일어날 것같다면 비관적 락을 사용하는 것이 낫고 수정보다 읽기의 비율이 높다면 성능이 더 좋은 낙관적 Lock을 사용하는 것이 좋다.

일반적으로 웹 어플리케이션은 읽기 수행작업이 많기 때문에 낙관적 락을 주로 사용한다.

 

다음 포스팅에서는 낙관적 락을 사용해 동시성 이슈를 해결해보도록 하겠다!