[Spring] 좋아요 기능 동시성 제어

김민범·2024년 11월 8일

Spring

목록 보기
15/29


지난 게시글로 게시판 좋아요 기능을 구현하는 과정에서 동시성 문제가 발생할 수 있음을 알아내고 테스트 코드로 실제 문제가 발생하는 것을 확인했다.

(링크) Spring 동시성 테스트

그래서 오늘은 이를 해결하는 간단한 방법들을 소개한다.

기존 Service

	@Transactional
    @Override
    public PostResDto addPostLike(Long id) {
        PostResDto post = postRepository.findPostByIdOrElseThrow(id);

        int updatedRow = postRepository.addPostLike(id, post.getLikes() + 1);

        if (updatedRow > 0) {
            return postRepository.findPostByIdOrElseThrow(id);
        } else {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "something went wrong");
        }
    }

1. Pessimistic Locking

쿼리로 직접 레코드에 락을 걸어 하나의 스레드가 업데이트를 마칠 때까지 다른 스레드가 접근하지 못하도록 하는 방식

Service 코드에 손 대지 않고 repository Select 문을 변경해주면 끝난다.

Repository

	public PostResDto findPostByIdOrElseThrow(Long id) {
        return jdbcTemplate.query("SELECT * FROM posts WHERE id = ? FOR UPDATE", postRowMapper(), id).stream().findAny()
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "no post having id " + id));
    }

쿼리문에 UPDATE FOR 를 추가했다.

실행결과

테스트 코드로 총 3번에 걸쳐 1000개의 쓰레드를 실행했고 3회의 테스트 모두
1sec 486ms / 1sec 541ms / 1sec 604ms
의 실행 속도로 통과하였다.

그러나 이런 방법은 요청 실패 등의 이유로 락 헤제 실패 시 테이블 또는 데이터 자체에 락이 걸릴 수 있어 리스크가 매우 크기 때문에 추천하지 않는 방식이다.

2. synchronized

메서드나 특정 코드 블록에 synchronized 키워드를 사용해 임계 영역을 설정하는 방식이다.
동시에 한 스레드만 접근할 수 있어 addPostLike 메서드가 한 번에 하나의 스레드에서만 호출된다.

@Transactional
@Override
public synchronized PostResDto addPostLike(Long id) {
    PostResDto post = postRepository.findPostByIdOrElseThrow(id);

    int updatedRow = postRepository.addPostLike(id, post.getLikes() + 1);

    if (updatedRow > 0) {
        return postRepository.findPostByIdOrElseThrow(id);
    } else {
        throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "something went wrong");
    }
}

그러나 이렇게 하더라도 동기화 문제를 해결할 수 없었다.
@Transactional은 데이터베이스의 동일한 엔티티가 동시에 수정되지 않도록 잠근다.

  • 트랜잭션은 Begin - 로직 실행(count + 1) - Commit의 순서로 진행되는데, 하나의 트랜잭션이 커밋 되기 전 다른 트랜잭션이 실행될 수 있고, 이로 인해 동시성 이슈가 발생
  • synchronized 키워드는 메서드가 한 번에 하나의 스레드에서만 실행할 수 있도록 Locking

이를 해결하기 위해
1. 트랜잭션이 적용되기 전 synchronized 적용하기
2. @Transactional 제거

의 방법을 사용할 수 있지만,

  • 방법1 : 성능이 매우 비효율적
  • 방법2 : synchronized로 인해 메서드의 모든 동작에 대해 Lock을 걸어 하나의 스레드만 접근이 가능하기 때문에 많은 오버헤드가 발생, synchronized는 동일한 프로세스 내의 스레드 단위에서만 동시성을 보장.
    즉, 단일 서버라면 동시성 이슈가 발생하지 않겠지만 실질적으로 웹 환경과 같이 여러 대의 서버를 활용하면 동시성을 보장할 수 없다.

이러한 문제점들이 있다.

3. BlockingQueue

Queue 를 이용하여 요청을 순차적으로 처리하는 방법으로 동시성 문제를 해결할 수 있다.
들어오는 요청을 한 번에 하나씩 처리할 수 있기 때문에, 동시성 문제나 데이터 충돌을 장지할 수 있다.
이때 유의할 점으로 일반 Queue를 사용하면 안된다. 일반 Queue는 동시성을 지원하지 않는 경우도 있기 때문에 일반 Queue 를 사용하여 진행하게 되면 Queue 자체에서 동시성 문제가 발생할 수 있다.

실제로 모르고 일반 Queue 를 사용하다 두시간이 증발한...

Service

public class PostServiceImpl implements PostService {

	public PostServiceImpl(PostRepo postRepository, PagePostRepo pagePostRepo, CommentRepo commentRepo) {
        this.postRepository = postRepository;
        this.pagePostRepo = pagePostRepo;
        this.commentRepo = commentRepo;

        thread.start();
    }
    
	private Deque<Long> q = new ConcurrentLinkedDeque<>();

    private Thread thread = new Thread(() -> {
        while (true) {
            if (!q.isEmpty()) {

                addPostLike(q.poll());
            }
        }
    });

    public void getPostResDto(Long id) {
        q.add(id);
    }
    
    @Transactional
    @Override
    public PostResDto addPostLike(Long id) {
        PostResDto post = postRepository.findPostByIdOrElseThrow(id);

        int updatedRow = postRepository.addPostLike(id, post.getLikes() + 1);


        if (updatedRow > 0) {
            return postRepository.findPostByIdOrElseThrow(id);
        } else {
            throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "something went wrong");
        }
    }
}

실행 순서는 다음과 같다.

  1. 컨트롤러에서 getPostResDto 호출
  2. getPostResDto 는 요청이 들어올 때 마다 필드에 선언된 Queue 에 id 를 add
  3. 필드에 쓰레드를 선언하고 무한루프로 Queue 에 들어있는 id 로 좋아요 메서드 실행

여기서 필드에 선언된 쓰레드가 하나이기 때문에 싱글 쓰레드로 addPostLike 에서의 동시성 문제를 걱정할 필요가 없다.

하지만 이를 테스트 코드로 확인하게 되면

성공할 때도 있고 실패할 때도 있다.

요청을 받게 되면 큐에만 쌓아두고 완료된 후 쓰레드로 순차적 업데이트가 진행되기 때문에 테스트 코드에서 findById 를 불러오기 전에 업데이트가 완료되지 않을 경우가 생길 수 있다.

이를 해결하기 위해서

  1. 테스트 코드 findById 를 호출하기 전 Thread.sleep() 을 사용하여 충분한 시간을 제공
  2. 터미널을 사용해 요청을 보내고 DB 를 확인(mac)

위의 두 가지 방법을 사용할 수 있다.

방법 1의 경우 위 사진 성공과 같은 방법으로 확인이 가능하다

방법 2의 경우

seq 1000 | xargs -I{} -P 1000 curl -X POST http://localhost:8080/posts/2609/likes

다음과 같은 명령어를 터미널에 입력해 DB 를 확인하면

요청이 잘 들어갔음을 알 수 있다.

Queue 방식을 사용하면 모든 요청이 순차적으로 처리되기 때문에 동시성 문제 없이 안전하게 데이터가 업데이트 될 수 있고, 시스템의 과부하를 방지하면서 안정적으로 처리가 가능하다는 장점이 있다.
또한 동시 요청이 많을 때 데이터 충돌 문제를 방지하며 시스템 성능을 유지할 수 있다.

마무리

이렇게 Spring API 를 만들며 발생할 수 있는 동시성 문제와 그에 대한 해결방법을 알아보았다.
추가적으로 redis, 카프카 등 다양한 방법이 있지만 이는 더 많은 공부를 하고 시도해 볼 예정이다.

0개의 댓글