Cotato 네트워킹 회고 3 (동시성 제어)

minchan·2025년 1월 8일
1

Cotato 동아리

목록 보기
3/7

조회수 필드에 대한 문제 확인하기

// 단일 게시글 조회
@Transactional
public PostDTO getPostById(final long id) {

	// 게시글이 존재하지 않을 경우 예외 처리
	Post post = postRepository.findById(id).orElseThrow(() -> PostException.from(POST_NOT_FOUND));

	// 조회수 증가
	post.increaseViews();

	// DTO로 변환하여 반환
	return PostDTO.toPostDTO(post);
}
// 조회수 증가
public void increaseViews() {
	this.views++;
}

위 메서드에 대하여 같은 게시글에 대하여 요청이 들어올 경우, 해당 조회수에 대한 증가는 순차적으로 이뤄져서 동기화가 이루어져야 한다. 하지만 적절한 동시성 제어를 하지 않고 해당 메소드를 실행하게 되면 동일 게시글에 대하여 대량의 요청이 실행 될 경우 제대로 된 조회수 증가가 이루어지지 않는다.

JMeter로 부하 테스트 해보기

동시성 문제를 확인하기 위해서 JMeter로 스레드 요청을 500개로 늘려서 부하 테스트를 해보았다.

스레드 500개를 다음과 같은 게시물 상세 조회 api에 요청을 보내도록 한다. 동시성 문제가 없다고 가정을 하게 될 경우에는 조회수는 기존의 1에서 500이 더해진 수와 다시 조회함으로써 1이 더해진 502가 되어야 한다.

하지만 결과를 보게 되면, 조회수는 502가 아닌 452 밖에 되지 않았음을 확인할 수 있다. 해당 원인은 아마 JPA의 트랜잭션 흐름 내에서 읽기 → 쓰기 → 변경 감지의 순서로 동작을 하기 때문에 여러 트랜잭션이 동시에 동일한 값을 읽음으로써 발생하는 문제일 것이다. 즉 Race Condition 이 발생한 것이다.

이 문제를 해결하기 위해 동시성 제어가 필요함을 확인할 수 있다.

스프링의 동시성 제어 방식

1. Java의 Synchronized

Synchronized 키워드를 통해 데이터에 하나의 스레드만 접근이 가능하도록 만들어 줄 수 있다.

@Transactional
public synchronized PostDTO getPostById(final long id) {

	// 게시글이 존재하지 않을 경우 예외 처리
	Post post = postRepository.findById(id).orElseThrow(() -> PostException.from(POST_NOT_FOUND));

	// 조회수 증가
	post.increaseViews();

	// DTO로 변환하여 반환
	return PostDTO.toPostDTO(post);
}

기존의 메소드 선언부에 synchronized 키워드를 적어준다. 하지만 해당 키워드를 넣고 다시 부하테스트를 하게 되더라도 여전히 조회수는 정확히 증가하지 않는다.

synchronized 키워드를 사용했음에도 불구하고, 조회수가 정확히 증가하지 않는 이유는 @Transactional의 동작 원리에 있다.

@Transactional이 붙은 메소드는 Proxy 객체를 생성하여 트랜잭션 관련 처리를 해준다. 프록시 객체가 생성이 되고 프록시 객체의 getPostById 메소드가 호출이 되어서 트랜잭션 처리와 함께 조회수 증가를 실시한다. 증가된 조회수가 DB에 반영되는 시점은 트랜잭션이 커밋되고 종료되는 시점이다.

트랜잭션이 종료되는 시점에 DB에 반영되기 때문에 트랜잭션이 종료되기 전까지는 조회수 증가가 DB에 반영되지 않는다. synchronized 키워드가 붙은 메소드는 해당 메소드가 종료되면 다른 스레드에서 해당 메소드를 실행할 수 있기 때문에 메소드가 다 실행이 되고 트랜잭션이 종료되기 전까지의 시점에서 다른 스레드가 해당 메소드를 실행하면 동시성 문제가 발생한다.

// 단일 게시글 조회
// @Transactional
public synchronized PostDTO getPostById(final long id) {

	// 게시글이 존재하지 않을 경우 예외 처리
	Post post = postRepository.findById(id).orElseThrow(() -> PostException.from(POST_NOT_FOUND));

	// 조회수 증가
	post.increaseViews();
	postRepository.save(post); // DB 반영을 위해 추가

	// DTO로 변환하여 반환
	return PostDTO.toPostDTO(post);
}

@Transactional을 제거하면, 트랜잭션이 관리되지 않고 메소드가 실행될 때 바로 DB에 반영된다. 즉, 조회수 증가가 즉시 DB에 반영되고, 다른 스레드가 이 변경 사항을 조회할 때 반영된 상태의 조회수를 볼 수 있게 된다.

하지만 synchronized 키워드를 통해서 데이터에 동시에 하나의 스레드만 접근이 가능하다는 조건은 하나의 프로세스에서만 보장된다는 문제를 가진다. 프로세스가 여러 개일 경우, 서버가 여러 개일 경우 동시성이 보장되지 않는다는 단점을 가진다.

2. MySQL의 락 방식

비관적 락(Pessimistic Lock)

  • DB 단의 X-Lock : 트랜잭션 1에서 데이터에 X-Lock을 설정하면, 해당 트랜잭션이 종료되기 전까지는 다른 트랜잭션이 해당 데이터를 수정할 수 없음
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdForUpdate(long id);
Post post = postRepository.findByIdForUpdate(id).orElseThrow(() -> 
														PostException.from(POST_NOT_FOUND));
  • LockModeType.PESSIMISTIC_WRITE : X-LOCK 쿼리 수행
  • LockModeType.PESSIMISTIC_READ : S-LOCK 쿼리 수행

조회수가 정확히 증가한다.

낙관적 락 (Optimistic Lock)

  • DB 단에 실제 Lock을 설정하지 않고, Version을 관리하는 컬럼을 테이블에 추가해서 데이터 수정 시마다 맞는 버전의 데이터를 수정하는지를 판단하는 방식

낙관적 락 작동 방식(참고)

  1. 2개의 스레드에서 동시에 DB에 접근하여 조회수 1, Version이 1인 게시물을 조회
  2. 스레드 1에서 먼저 조회한 게시물에 대한 업데이트 (views + 1, version + 1)
  3. 스레드 2에서 조회한 게시물에 대해 업데이트 하려고 할 때 id가 1이고 version이 1인 게시물은 존재하지 않으므로(이미 스레드 1에서 version 2로 업데이트) 예외 발생
  4. 예외를 잡아서 다시 DB에서 게시물을 재조회하여 version 2인 게시물을 업데이트 (views + 1, version + 1)

여기서 1~3번 과정은 스프링에서 어노테이션을 선언하면 자동으로 동작

4번 과정은 애플리케이션에서 예외를 잡아서 다시 로직을 수행하도록 수동으로 코드를 구현해야 함

@Lock(LockModeType.OPTIMISTIC)
@Query("SELECT p FROM Post p WHERE p.id = :id")
Optional<Post> findByIdWithOptimisticLock(long id);
@Version
private Long version;

위와 같이 락 설정 후 부하 테스트를 하게 되면 버전이 맞지 않는 데이터가 존재해서 예외가 발생한다.

예외가 발생하게 될 경우에는 낙관적 락에서는 적절한 예외 처리를 해주어야 한다.

3. 비관적 락과 낙관적 락 빅교

비관적 락 (Pessimistic Lock)

  • 장점
    • Race Condition이 빈번하게 일어난다면 낙관적 락보다 성능이 좋음
    • 확실하게 데이터 정합성이 보장
  • 단점
    • DB 단의 Lock을 설정하기 때문에 한 트랜잭션 작업이 정상적으로 끝나지 않으면 다른 트랜잭션 작업들이 대기해야 하므로 성능이 감소할 수 있음

낙관적 락 (Optimistic Lock)

  • 장점
    • 별도의 Lock을 설정하지 않기 때문에 하나의 트랜잭션 작업이 길어질 때 다른 작업이 영향받지 않아서 성능이 좋을 수 있음.
  • 단점
    • 버전이 맞지 않아서 예외가 발생할 때 예외 처리 로직을 구현해야 함
    • 버전이 맞지 않는 일이 여러번 발생한다면 예외 처리를 여러번 거칠 것이기 때문에 성능이 좋지 않음.

참고

[Spring] 스프링 동시성 처리 방법(feat. 비관적 락, 낙관적 락, 네임드 락)

profile
chanmin

1개의 댓글

comment-user-thumbnail
2025년 1월 8일

안녕하세요! 개발자 준비하시는 분이나 현업에 종사하고 계신 분들만 할 수 있는 시급 25달러~51달러 LLM 평가 부업 공유합니다~ 제 블로그에 자세하게 써놓았으니 관심있으시면 한 번 읽어봐주세요 :)

답글 달기

관련 채용 정보