본문 바로가기
한화시스템 Beyond SW Camp/백엔드

[Spring Boot] 동시성 제어

by taeh00n 2025. 2. 25.

동시성 제어

애플리케이션을 사용하다보면 수많은 사용자가 동시에 데이터를 읽고 쓰고 한다.

예를 들어 콘서트 티켓팅을 하다가 마지막 남은 티켓을 동시에 여러 사용자가 결제한다고 가정해보자. 이때 동시성 문제가 발생할 것이다. 사용자가 마지막 티켓을 거의 동시에 구매 버튼을 눌렀을 때 시스템이 동시성 제어를 하고 있지 않다면 하나 남은 티켓이 두 번 결제되거나 한 사람의 결제는 오류가 날 것이다. 이러한 문제를 방지하기 위해서 동시성 제어가 필요하다.

 

동시성 제어 : 동시에 접근하는 여러 사용자의 요청이 데이터 무결성을 해치지 않도록 관리하는 것을 의미

동시성 제어를 테스트하기 위해 Postman과 같은 역할을 하는 Talend API Tester를 사용하였다.

멀티 스레드 환경에서 동시성 제어를 테스트 하기 위해 Breakpoint의 오른쪽 마우스를 클릭 해 Thread로 설정을 변경해주었다.

동시성 문제 발생

초기 테이블 상태

동시에 1번 게시물에 좋아요를 눌렀다고 가정해보겠다.

스레드가 두 개가 생성되었고 두 스레드를 하나씩 실행해보겠다.

좋아요 테이블에는 두 개의 좋아요가 들어갔지만 게시물에는 좋아요가 1개만 들어간 것을 확인할 수 있다. 동시성 문제가 발생한 것이다.

동시성 문제 발생 과정

첫 번째 사용자가 좋아요를 눌렀을 때

게시물의 좋아요가 0 -> 1로 증가

두 번째 사용자가 거의 동시에 좋아요를 눌렀을 때

두 번째 사용자는 업데이트되기 전의 데이터(0개)를 읽어온 상태

다시 0 → 1로 업데이트함.

 

=> 두 번의 업데이트가 같은 값을 반영하면서 실제로 좋아요는 2번 눌렀지만 게시물에는 하나의 좋아요만 등록됐다.


동시성 제어 방법 1 : synchronized

@Transactional
    public synchronized CreateLikesRes create(Member member, Long idx) throws StaleObjectStateException {
        Post post = postRepository.findById(idx).get();	// 1차 캐시 저장

        Optional<Likes> result = likesRepository.findByMemberAndPost(member, post);
        Likes likes = null;
        if (result.isPresent()) {
            post.subLikesCount();
            postRepository.save(post);

            likes = result.get();
            likesRepository.deleteById(likes.getIdx());


            return CreateLikesRes.builder().idx(likes.getIdx()).result("삭제 성공").build();
        } else {
            post.addLikesCount();
            postRepository.save(post);
            likes = Likes.builder()
                    .member(member)
                    .post(post)
                    .build();
            likes = likesRepository.save(likes);

            return CreateLikesRes.builder().idx(likes.getIdx()).result("저장 성공").build();
        }
    }

메서드 명 앞에 synchronized를 붙이면 동시성 제어가 되는 줄 알았다.

하지만 @Transactional이 붙은 메서드가 실행될 때 영속성 컨텍스트가 생성된다. 동시에 좋아요를 누르게 된다면 첫 번째로 좋아요를 눌렀을 때 findById()로 조회한 엔티티는 1차 캐시에 저장이 될 것이고 두 번째 사람이 실행될 때 같은 트랜잭션내에서 DB를 다시 조회하지 않고 1차 캐시내에 있는 데이터를 가져오게 될 것이다. 그러면 아까와 같이 좋아요가 반영되지 않은 상태에서 또 좋아요를 눌러 1개만 반영되는 동시성 문제가 또 발생할 수 있다.

 

// Controller
synchronized (this){
    likesService.create(member, idx);
}

synchronized는 동기화를 통해 하나의 스레드만 접근만 가능하다.

 

스레드가 하나만 보인다.

 

 

그래서 동시에 좋아요 요청을 보내도 하나의 스레드만 보이고 다른 스레드가 동시에 접근하려면 대기하는 방식이다.

이렇게 실행하게 되면은 위와 같은 동시성 문제는 발생하지 않는다.

 

하지만 synchronized는 성능면에서 좋지 않은 방식이다.

 

synchronized가 걸린 코드 블록에 한 번에 하나의 스레드만 접근할 수 있기에 여러 스레드가 동시에 접근하려고 하면 대기 상태에 들어간다. 대기 중인 스레드는 실행을 시작할 수 없고 다른 스레드가 해당 코드 블록을 실행하는 동안 기다려야 하기 때문에 성능면에서 좋지 않다.

여러 스레드가 대기하면서 CPU는 스레드 간에 컨텍스트 전환을 해야 하기 때문에 성능면에서 좋지 않다.


동시성 제어 방법 2 : 낙관적 락 (Optimistic Locking)

낙관적 락 : 다른 사람이 데이터를 수정하지 않을 것이다라고 믿고 데이터를 읽은 후 수정할 때 충돌을 예상하지 않고 작업을 진행하는 방식

=> 데이터를 수정하는 동안 다른 스레드가 데이터를 수정하지 않을거다라고 낙관적으로 가정

public class Post {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long idx;
    private String contents;
    @ManyToOne
    @JoinColumn(name = "member_idx")
    private Member member;
    @Builder.Default
    @OneToMany(mappedBy = "post")
    List<Likes> likes = new ArrayList<>();
    private int likesCount;

    @Version    // 낙관적 락 : 프로그램 단에서 사용하는 LOCK
    @ColumnDefault(value = "0")
    private int version;

    public void addLikesCount() {
        this.likesCount = this.likesCount + 1;
    }

    public void subLikesCount() {
        this.likesCount = this.likesCount - 1;
    }
}

낙관적 락버전 관리를 사용해 데이터의 변경 여부를 추적한다. 테이블에 버전 정보를 추가해 엔티티가 수정될 때 마다 증가시켜야하고 버전 충돌이 일어날 시에 처리를 한다. @Version 애노테이션을 사용해 버전 정보를 관리할 수 있다.

 

@Version : JPA가 해당 필드를 자동으로 관리하고 엔티티가 수정될 때 마다 버전 번호를 자동으로 증가

낙관적 락을 적용하고 두 명이 동시에 좋아요를 눌러 동시성 문제를 발생시켜 보니 version 관리가 자동으로 되면서 한 명만 좋아요가 되고 한 명은 버전이 일치하지 않아 오류가 발생하는 것을 볼 수 있다. 이렇게 버전이 일치하지 않아 예외가 발생한 경우 예외 처리를 통해 충돌을 감지하고 재시도 요청을 하는 방식으로 동시성 문제를 해결할 수 있다.


동시성 제어 방법 3 : 비관적 락(Pessimistic Lock)

비관적 락 : 데이터에 대한 잠금을 사용해 다른 트랜잭션이 해당 데이터를 수정하지 못하게 막는 방식

=> 낙관적 락 충돌이 없을거라 믿고 진행하는 방식, 비관적 락 충돌이 있을거라 믿고 잠금을 미리 하는 방식

 

잠금(Lock) : 데이터가 수정되는 동안 다른 트랜잭션이 해당 데이터를 수정할 수 없게 잠금

 

비관적 락 종류

PESSIMISTIC_READ : 다른 트랜잭션이 읽기o, 쓰기x
PESSIMISTIC_WRITE : 다른 트랜잭션이 읽기x, 쓰기x

public interface PostRepository extends JpaRepository<Post, Long> {
    // 비관적 락 : DB 단에서 LOCK
    // @Lock(LockModeType.PESSIMISTIC_READ)
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Post> findById(Long idx);

비관적 락을 적용하고 두 명이 동시에 좋아요를 눌러보니 데드락이 발생했다는 메세지를 볼 수 있다.

이런 데드락을 방지하기 위해선 트랜잭션 간 자원 순서나 락 전략을 잘 설계해야한다.

 

데이터 잠금으로 인해 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없어서 대기 시간이 길어져 전체 시스템의 성능이 떨어질 수 있다.

여러 트랜잭션이 서로 자원을 잠그고 대기하면은 데드락이 발생할 수 있다.

 

낙관적 락 충돌이 적고 트랜잭션이 자주 겹치지 않는 환경에서 성능이 좋다..

비관적 락 충돌이 잦고, 충돌을 미리 방지해야 하는 환경에서는 안전하지만 성능에 미치는 영향은 상대적으로 크다.

=> 일반적으로 성능을 고려하면은 낙관적 락이 더 좋다.