m1ndy5's coding blog

Ditto 프로젝트 동시성 문제 해결하기 3편 - 낙관적 락 Version, Select for Update 본문

Toy Projects/Ditto - Discuss Today's Topic

Ditto 프로젝트 동시성 문제 해결하기 3편 - 낙관적 락 Version, Select for Update

정민됴 2024. 3. 13. 20:59

모의면접을 봤는데 생각하지 못했던 부분에서 질문이 들어왔다.

이전 포스팅에서 낙관적 락을 사용해 동시성 문제를 해결할 때 Version을 사용해 엔티티의 수정을 막았다고 했는데 이 Version이 데이터베이스에 저장되서 관리되는 것이냐는 질문에 대답을 하지 못했다.

또한 Select for Update로도 충분히 동시성 이슈를 해결할 수 있는데 왜 굳이 Version을 사용해서 진행하였냐라는 질문에도 제대로 대답하지 못했다.

그래서 정리해보려고 한다!

 

Post Entity

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post extends BaseEntity {

    @Column(name="member_id", nullable = false)
    private Long memberId;

    @Column(name = "member_name", nullable = false)
    private String memberName;

    @Column(name = "stock_id", nullable = false)
    private Long stockId;

    @Column(name = "title", nullable = false)
    private String title;

    @Column(name = "content", nullable = false)
    private String content;

    @Column(name = "likes", nullable = false)
    @ColumnDefault("0")
    private long likes;

    @Column(name = "views", nullable = false)
    @ColumnDefault("0")
    private long views;

    @Version
    private Long version;

    @Builder
    public Post(Long memberId, String memberName, Long stockId, String title, String content){
        this.memberId = memberId;
        this.memberName = memberName;
        this.stockId = stockId;
        this.title = title;
        this.content = content;
        this.status = UsageStatus.ACTIVE;
    }

    public void updatePost(String title, String content){
        this.title = title;
        this.content = content;
    }

    public void deletePost(){
        this.status = UsageStatus.DELETED;
    }

    public void addView(){ this.views += 1;}

    public void addLike() {this.likes += 1;}
    public void subLike() {this.likes -= 1;}

}

기존 나의 Post Entity고 @Version 어노테이션을 걸어서 해당 엔티티의 버전을 관리하고 있다.

이건 전에 테스트로 만들어놓은 레코드긴 한데 맨 오른쪽에서 두번째를 보면 version이 있는 것을 볼 수 있다.

1로 올라가 있는 이유는 테스트 해보려고 좋아요를 1번 눌러봤기 때문!

이제 프로젝트를 실행해서 알아보자

이렇게 요청을 하게 되면

이렇게 version과 좋아요수가 0개인 게시글로 만들어지는 것을 확인할 수 있다.

이렇게 2번 게시글에 좋아요를 누르고 확인해보면

version과 likes가 한개씩 올라가 있는 것을 확인할 수 있다.

 

따라서 거의 동시에 요청이 온다면 낙관적락은 다음과 같은 로직으로 동작하게 된다.

1. 낙관적락은 다른 트랜잭션이 값을 읽을 수는 있게 해주기 때문에 트랜잭션 1, 2 모두 version 0 값(좋아요 0개)을 읽어온다.

2. 트랜잭션 1이 먼저 커밋이되고 version과 좋아요을 1씩 업해준다.

3. 트랜잭션 2가 커밋을 시도하려고 할 때 이미 version이 1이 올라있기 때문에 값의 변경이 이루어지지 않고 롤백된다.

4. 이때 개발자가 다시 변경을 시도하게 해준다면 끝!

 

궁금한 점

- 만약 한 엔티티에 2개의 필드가 업데이트될 때 낙관적 락을 사용해 업데이트된다고 가정하자. @Version을 두개 사용할 수 있을까? 안된다면 각 필드를 업데이할 때 서로가 충돌이 날까? (서로가 Version을 다르게 하니까)

- @Version은 엔티티 당 하나만 가져야한다. 그렇기 때문에 다른 필드가 업데이트되는 상황이더라도 둘 다 낙관적 락을 통해 업데이트된다면 충돌이 일어날 수 있다.

 

- 낙관적 락을 사용할 때 컨텍스트 전환 오버헤드를 줄이려면 어떻게 해야할까?

- 트랜잭션 범위를 최소화하여 트랜잭션이 실행되는 시간을 줄이거나 낙관적 락 사용의 이유를 잘 고려해 사용을 최소화한다.

 

- 트랜잭션이 충돌했을 때 재시도하는 횟수와 재시도하는 간격을 조정해도 컨텍스트 전환 오버헤드가 줄어들 수 있을까?

- 일부 상황에서는 그렇다. 지나치게 많은 재시도 횟수나 짧은 간격은 성능에 부정적인 영향을 미칠 수 있다.(Backoff 알고리즘 사용하면 좋음) 또한 어떤 상황에서는 재시도 전략을 적용하는 것이 적절하지 않을 수 있다. 충돌이 지속적이라면 비즈니스 로직이나 데이터 모델을 변경하여 충돌을 최소화하는 것이 더 나을 수 있다.

 

 

SELECT FOR UPDATE

비관적 락의 한 형태로 특정 행을 선택하고 해당 행에 대한 업데이트 권한을 얻기 위해 사용된다.

특정 행을 선택하면 다른 트랜잭션이 해당 행에 대한 업데이트 권한을 얻을 때까지 읽기도 못하는 x-lock을 사용한다.

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

@Repository
public class YourRepository {

    @Autowired
    private JdbcTemplate jdbcTemplate;

    @Transactional
    public void selectAndUpdateInTransaction() {
        // "SELECT FOR UPDATE"를 사용하여 특정 행을 선택하고 잠금을 획득
        String selectForUpdateQuery = "SELECT * FROM your_table WHERE your_condition FOR UPDATE";
        YourEntity result = jdbcTemplate.queryForObject(selectForUpdateQuery, YourEntity.class);

        // 선택한 행에 대한 업데이트 작업
        if (result != null) {
            String updateQuery = "UPDATE your_table SET column1 = value1 WHERE your_condition";
            jdbcTemplate.update(updateQuery);
        }
    }
}

 

SELECT FOR UPDATE와 낙관적 락의 비교

- SELECT FOR UPDATE

장점 :

- 특정 행에 대한 잠금을 획득한 트랜잭션이 해당 잠금을 놓을 때까지 다른 트랜잭션들이 해당 행을 읽거나 수정할 수 없기 때문에 데이터 무결성을 유지하면서 다른 트랜잭션이 기다리는 상황을 방지하여 대기를 줄일 수 있다.

- 특정 행에 대한 잠금을 획득한 후에 트랜잭션이 롤백되면 해당 잠금도 해제된다. 이는 예외 발생 시에도 데이터베이스의 일관성을 보장하는 데 도움이 된다.

 

단점 :

- 한 트랜잭션이 행을 사용하고 있다면 조회조차 되지 않으므로 성능 이슈가 있을 수 있다.

- 잘못된 사용 시 데드락이 발생할 수 있다.

 

데드락 예시

트랜잭션 A가 id 1번 행을 선택했다.

BEGIN;
SELECT * FROM your_table WHERE id = 1 FOR UPDATE;
-- 작업 수행

트랜잭션 B가 id 2번 행을 선택했다.

BEGIN;
SELECT * FROM your_table WHERE id = 2 FOR UPDATE;
-- 작업 수행

트랜잭션 A가 id 2번 행에 update를 시도한다.

-- 여전히 실행 중
UPDATE your_table SET column1 = value1 WHERE id = 2;
-- 트랜잭션 A는 트랜잭션 B가 가지고 있는 행에 대한 업데이트를 시도

트랜잭션 B가 id 1번 행에 update를 시도한다.

-- 여전히 실행 중
UPDATE your_table SET column1 = value1 WHERE id = 1;
-- 트랜잭션 B는 트랜잭션 A가 가지고 있는 행에 대한 업데이트를 시도

서로의 트랜잭션이 끝나지 못하고 무한히 대기하게 되고 데드락이 발생할 수 있다.

 

- 낙관적 락

장점 :

- 잠금을 사용하지 않기 때문에 경합이 줄어든다.

- 락 없이 여러 트랜잭션이 동시에 실행될 수 있기 때문에 데이터베이스의 확장성이 향상될 수 있다.

 

단점 :

- 여러 트랜잭션이 동시에 같은 데이터를 변경하면 충돌이 발생할 수 있다.

- 충돌이 발생하면 롤백 및 재시도를 통해 충돌을 해결해야 할 수 있어 컨텍스트 전환 오버헤드가 발생할 수 있다.

컨텍스트 전환 오버헤드 : 프로그램이나 시스템이 한 작업에서 다른 작업으로 전환될 때 발생하는 추가 비용

 

결론

@Version을 사용하면 데이터베이스에 컬럼이 하나 생기고 이를 비교해 업데이트를 방지한다.

SELECT FOR UPDATE 는 데이터 일관성을 우선시하는 상황에서는 사용될 수 있지만, 성능 이슈 및 데드락 가능성이 있다.

낙관적 락은 성능 측면에서는 더 유리할 수 있지만, 충돌을 관리하는 로직이 필요하며 이로 인해 코드가 더 복잡해질 수 있다.

성능 향상을 위해서는 낙관적 락이 필요한지 고려해 사용을 최소화하는 것이 좋다.