비관적 락, 낙관적 락 그리고 동시성문제.

2023. 3. 3. 00:05개발

반응형

이번에 다시 공부를 시작하면서 항상 나오는 동시성 문제에 대해서 궁금했다.
동시성 문제를 잘 해결해야 한다는 걸 알고 있지만 어떻게 접근하고 어떤식으로 관리해야 하는지에 대해 정리 한 적은 없었다.
 
Spring Initializer 를 사용하여 간단한 프로젝트를 먼저 생성 하였다. 버전과 기타 상세 디펜던시의 경우 아래와 같다. 
Lombok
Devtool
Web
Spring Data JPA
간단하고 좋은 메모리 디비 사용을 위한 H2

스프링 프로젝트 디펜던시

간단한 applicaiton.yml 파일 설정 먼저 진행 하였다.

spring:
  profiles:
    active:
      - local
  datasource:
    url: jdbc:h2:mem:demo
    username: sa
    password:
    driver-class-name: org.h2.Driver
  h2:
    console:
      enabled: true

  jpa:
    hibernate:
      ddl-auto: create
    properties:
      hibernate:
        show_sql: true
        format_sql: true



logging:
  level:
    org.hibernate.SQL : debug
    #org.hibernate.type : trace

demo라는 메모리 DB를 사용하여 진행 하기로 했다.
동시성 문제를 확인하기 위하여 아래 Entity들을 생성하였다.
게시글을 등록할 유저 Entity

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Member {

    @Id
    @GeneratedValue
    @Column(name = "member_id")
    private Long id;

    @Column(name = "account_id", unique = true)
    private String accountId;

    @CreatedDate
    @Column(name = "created_date")
    private LocalDateTime createdDate;

    @Builder
    public Member(Long id, String accountId, LocalDateTime createdDate) {
        this.id = id;
        this.accountId = accountId;
        this.createdDate = createdDate;
    }
}

 게시글 및 좋아요 갯수를 표시 할 Entity. 

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "post_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id")
    private Member member;
    private String contents;
    private Long likeCount;
    @CreatedDate
    @Column(name = "created_date")
    private LocalDateTime createdDate;


    @Builder
    public Post(Long id, Member member, String contents, Long likeCount, LocalDateTime createdDate) {
        this.id = id;
        this.member = member;
        this.contents = Objects.requireNonNull(contents);
        this.likeCount = likeCount == null ? 0 : likeCount;
        this.createdDate = createdDate;
    }

    public void incrementLikeCount() {
        likeCount += 1;
    }
}

만약 특정 게시글에 작성 후 좋아요를 누른다고 했을때 incrementLikeCount 메서드를 호출하여 좋아요 갯수를 증가 시키고 더티체킹으로 해당 좋아요를 1씩 늘려주는 메서드이다.
그리고 기타 필요한 service, repository, controller를 모두 생성하였다.
MemberService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MemberService {
    private final MemberRepository memberRepository;


    @Transactional
    public Long save(Member member) {
        Member savedMember = memberRepository.save(member);
        return savedMember.getId();
    }
}

PostService

@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class PostService {
    private final PostRepository postRepository;
    private final MemberRepository memberRepository;

    @Transactional
    public Long save(Long memberId, Post post) {
        Member member = memberRepository.findById(memberId).orElseThrow();
        Post entity = Post.builder().contents(post.getContents()).member(member).build();
        Post savePost = postRepository.save(entity);
        return savePost.getId();
    }
    @Transactional
    public void incrementLikeCount(Long postId) {
        Post post = postRepository.findById(postId).orElseThrow();
        post.incrementLikeCount();
        // 이후 좋아요 갯수가 요청 된 숫자만큼 +1 된다.
    }
}

 
MemberRepository

public interface MemberRepository extends JpaRepository<Member, Long> {
}

PostRepository

public interface PostRepository extends JpaRepository<Post, Long> {
}

MemberController

@RestController
@RequiredArgsConstructor
public class MemberController {
    private final MemberService memberService;

    @GetMapping("/member/save")
    public Long saveMember() {
        var member = Member.builder().accountId("abc").build();
        Long memberId = memberService.save(member);
        return memberId;
    }


}

PostController

@RestController
@RequiredArgsConstructor
public class PostController {
    private final PostService postService;

    @GetMapping("/post/save")
    public Long savePost(@RequestParam Long memberId) {
        Post post = Post.builder().contents("임시게시글").build();
        return postService.save(memberId, post);
    }

    @GetMapping("/post/like")
    public void incrementLikeCount(@RequestParam Long postId) {
        postService.incrementLikeCount(postId);
    }
}

 
하나를 테스트하기위해 멀리 돌아왔지만 결국 상세한 곳에서 보자면
PostController에서 /post/like 주소로 특정 게시글의 id값을 가지고 요청을 하게되면 PostService 내에 있는 incrementLikeCount를 호출하게 된다.
그리고 아래 로직상에서는 해당 게시글을 가져오고 해당 게시글의 좋아요 갯수를 + 1 처리 해주고 Spring Data Jpa에서는 영속성 컨텍스트에 존재하는 값의 변화를 읽어 더티체킹되어 변경된 값이 반영되게 된다. 몇번 호출 하던지 +1이 되는듯 보여진다.

@Transactional
public void incrementLikeCount(Long postId) {
    Post post = postRepository.findById(postId).orElseThrow();
    post.incrementLikeCount();
    // 이후 좋아요 갯수가 요청 된 숫자만큼 +1 된다.
}

 
그리고 요청을 위해 postman이나 swagger로 요청을 진행해 보았다.
Gradle에서 사용하려면 아래 디펜던시를 추가해야한다.

implementation 'org.springdoc:springdoc-openapi-ui:1.6.8'

 
인텔리제이 디버깅모드에서 요청 할 때 마다 확인을 해 본다면 아래처럼 최초 조회시에 0이고 이후 요청시 마다 1씩 증가하는 형태로 적용되는게 보인다.

첫번째 요청
두번째 요청

 
그러나.. 인텔리제이에서 제공하는 동시요청에 대한 스레드 옵션을 설정한 뒤 2개를 요청하여 디버깅모드를 걸게 된다면...?( 0.00001초의 차이도 없이 동일하게 요청하는 동시성 이슈 발생한다면?!)

2개 요청

위 처럼 2개가 디버깅에 잡히게 되고 동일 시간에 동일한 likeCount 정보를 가져오게 되고...이 경우에는 아래처럼 된다.

들고온 좋아요의 갯수가 동일하게 6으로 조회된다.
이유는 명확하다. 동시에 요청된 시기에는 둘다 6으로 조회하는게 맞기 때문이다.
그러나 결과적으로는 우리가 원하지 않는 방향으로 진행되게 된다.
2개의 요청이 들어왔으나 1번의 요청이 사라져 버리게된다. --> 좋아요의 갯수는 요청 수 만큼이 아닌 1개만 올라버리는 것!

시퀀스다이어그램

 
그렇다면 어떻게 해야 할 것인가..?
방법은 2가지의 방향이 있었다.
1. 비관적 락
해당 정보를 조회 할 때 이러한 상황이 발생 할 것으로 비관적(비판적)으로 접근하여 해당 객체를 조회 할 때 부터 데이터에 Lock 을 거는 것이다.
해당 구현체에 Lock을 적용하기 위해 findPostById라는 형태의 메서드를 만들고 @Lock으로 비관적 락을 걸어주었다.

@Transactional
public void incrementLikeCount(Long postId) {
    Post post = postRepository.findPostById(postId).orElseThrow();
    post.incrementLikeCount();
    System.out.println("post.getLikeCount() = " + post.getLikeCount());
    // 이후 좋아요 갯수가 요청 된 숫자만큼 +1 된다.
}
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :postId")
Optional<Post> findPostById(Long postId);

하이버네이트에서 만들어주는 쿼리의 형태를 보면 아래와 같다.

select
        post0_.post_id as post_id1_1_,
        post0_.contents as contents2_1_,
        post0_.created_date as created_3_1_,
        post0_.like_count as like_cou4_1_,
        post0_.member_id as member_i5_1_ 
    from
        post post0_ 
    where
        post0_.post_id=? for update

만약 JPA가 아니라면 아래와 같은 형태가 된다.

SELECT *
FROM POST P
WHERE P.POST_ID = :POST_ID
FOR UPDATE

여기서 FOR UPDATE란 동시성 제어를 위하여 특정 데이터(ROW)에 대해 베타적 LOCK을 거는 기능이다.
해당 트랜잭션이 끝날때 까지 동일한 요청이 들어오게되면 대기상태에 빠지게되고 로직은 우리가 원하는 방향으로 진행된다. (요청수 만큼 좋아요 증가)

위 방법은 여러가지 문제가 있다.
그 중 하나는 아무래도 대기(Lock)가 생기게 된다는 것이다. 만약 하나의 테이블이라면 그나마 범위가 작고 그렇겠지만... 여러개의 테이블을 Join 하였다던가.. 그런 경우라면?? 관련된 테이블을 조회하는 모든 대상들에 Lock이 걸리는 상황이 발생 할 수 있다.

2. 낙관적 락
Post Entity에 해당 컬럼을 추가적용 하였다.

@Version
private Integer version;

그리고 JPA에서 제공하는 옵션으로 관리하기 위해 아래와 같이 변경하여 적용하였다.

@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT p FROM Post p WHERE p.id = :postId")
Optional<Post> findOptimisticPostById(Long postId);

@Version 의 사용..!

JPA는 @Version 어노테이션을 제공하는데, 이를 사용하여 엔티티의 버전을 관리할 수 있다. @Version 적용이 가능한 타입은 Long(long), Integer(int), Short(short), Timestamp 이다. @Version 은 아래와 같이 버전 관리용 필드를 만들어 적용한다.
버전관리에 필요한 컬럼을 넣고 요청 시 해당 버전또한 같이 관리하면서 중복요청이 되었으나 하나의 요청이 사라지게 되는 걸 방지하는 형태로 진행한다.
실제 하이버네이트의 쿼리의 경우 아래처럼 나간다.

    update
        post 
    set
        contents=?,
        created_date=?,
        like_count=?,
        member_id=?,
        version=? 
    where
        post_id=? 
        and version=?

동시요청이라는 가정하에 바라보게되면 두개 다 동일한 시기에 요청이 들어와 조회했기 때문에 동일한 정보를 버전과 동일한 likeCount 를 기준으로 요청하고자 하지만 둘 중 하나는 실패하게 된다! 하나라도 먼저 수행되게된다면 버전이 0이 아니게 되기 때문이다.

 
여기서는 좋아요를 기준으로 예시데이터를 판단하였지만 만약 이게 민감한 정보인 송금과 관련된 요청이였다면...??
위 상황들에 대해 충분한 고려가 되어 있지 않는 경우에는 문제가 발생하게 될 것이다.
나도 까먹지 말고 동시성에 대해서 잘 판단 할 수 있었으면 좋겠다!!!!

기타!!

위 컬럼 형태로 좋아요 갯수를 관리하는것은 장단점이 존재한다.

장점은 다른테이블에 관계성이 없기때문에 해당 테이블의 특정 컬럼 조회로만 처리가 가능하다는 것이다.

단점은 아무래도 쓰기시에 하나의 게시물에 대해서 여러명의 사용자가 좋아요를 누르게 되었을때 이슈가 발생하게 된다는 것이다.
( 하나의 자원을 두고 Lock 대기 발생)
그리고 다른 부가적인 이슈로는 누군가가 지속적으로 좋아요를 눌러 어뷰징 하는 형태를 막을 수 없게되고, 특정 사용자가 좋아요를 누른 대상에 대해 보고싶다거나 할 때 특정 할 수 없다는점도 존재한다.

 

반응형