열공스토리

[Spring] 게시판 좋아요 기능 동시성 문제 트러블 슈팅 본문

Spring

[Spring] 게시판 좋아요 기능 동시성 문제 트러블 슈팅

열쩡열쩡열쩡_ 2023. 7. 28. 14:35

이번 포스팅은 최근 게시판 프로젝트를 진행하면서 발생한 좋아요 기능 동시성 문제 해결과정에 대한 회고입니다.

 

트러블 슈팅 과정을 보기에 앞서, 동시성 문제란 무엇인지 간단하게 짚고 넘어가겠습니다.

동시성 문제란?

동시성 문제란 여러 스레드가 한 공유 자원에 대해 동시에 읽기(Read)와 쓰기(Write) 작업을 처리할 때 발생하는 문제입니다.

 

예를 들어, Thread A와 Thread B가 우리가 흔히 알고있는 게시글의 좋아요 기능을 동시에 서버에 요청한다고 생각해 봅시다. 이런 상황에서 우리는 게시글의 좋아요 수가 기존의 좋아요 수에서 2가 증가된 값을 기대할 것입니다. 하지만 동시성 문제가 발생하면 예상과는 달리 좋아요 수가 1밖에 증가하지 않게 됩니다.

 

이러한 현상이 발생하는 이유를 다음 그림에서 확인해 보겠습니다.

그림에서 각각의 Thread는 id가 1인 게시글에 대해 좋아요 수를 1씩 증가시켜 commit 하고 있습니다. 하지만 Thread A가 좋아요 수를 증가시키고 commit 하는 시점 이전에 Thread B가 아직 업데이트가 반영되지 않은 엔티티를 조회하여 좋아요 수를 증가시키는 작업을 수행하고 있기 때문에 동시성 문제가 발생하는 것입니다.

 

그리고 이러한 상황을 경쟁 조건(race condition) 이라고 합니다.

 

경쟁 조건(race condition)

"두 개의 스레드가 하나의 자원을 놓고 서로 사용하려고 경쟁하는 상황을 말한다."

참고 - https://iredays.tistory.com/125

 

이제 프로젝트에서 발생한 동시성 문제에 대해 해결하는 과정을 살펴보겠습니다.

문제 발견

저는 이번 동시성 문제 테스트를 위해 JMeter라는 부하 테스트 도구를 사용하였습니다.

테스트 환경은 다음과 같이 설정하였습니다.

Number of Thread (스레드 수) : 1000

Ramp up Period (X초 동안 요청) : 1
=> (1초 동안 요청)

Loop Count (요청 횟수) : 1

스레드 1000개로 요청을 보낸 결과,

GOOD_COUNT 칼럼에 기댓값 1000이 아닌 227이 저장되어 동시성 문제가 발생한 것을 볼 수 있습니다.

 

요청 시간은 최대 160(ms), 최소 1(ms)가 소요되었습니다.

해결

1 ) synchronized

synchronized 키워드가 붙어있는 하나의 메소드에 여러 스레드가 동시에 접근하게 되면 먼저 접근한 스레드부터 순차적으로 메소드가 실행됩니다. 따라서 synchronized는 멀티 스레드 환경에서 thread-safe를 보장합니다.

 

하지만 스프링의 @Transactionl과 함께 사용한다면 sychronized는 의미가 없어집니다. 그 이유는 Spring AOP를 기반하고있는 @Transactional은 적용된 메소드가 호출될 시, 해당 메소드가 proxy 객체에 감싸져 begin - method - commit 순서으로 로직이 수행되는데 이 때, 이 proxy 객체는 스레드가 새로 만드는 객체이기 때문에 다른 스레드에서는 이 객체의 로직을 수행하지 않고 각자가 만든 proxy 객체의 로직을 수행하게 되어 sychronized의 역할이 무의미해지는 것입니다.

 

따라서 @Transactional 실행 시점 이전에 synchronized를 적용시켜 해결할 수 있습니다.

@PostMapping("like/{boardId}")
public synchronized ResponseEntity<CommonGoodResponseDto> like(
       @PathVariable("boardId") Long boardId
) {
    CommonGoodResponseDto responseDto = goodService.like(boardId);

    return ResponseEntity.status(HttpStatus.OK).body(responseDto);
}

위와 같이 Service 계층 진입 전 Controller 계층 메소드에 synchronized를 적용하였습니다.

 

테스트 결과는 좋아요 수가 알맞게 1000개가 저장되어 성공입니다.

 

하지만 이러한 스레드가 순차적으로 실행되는 방법은 성능적인 저하를 초래할 수 있습니다. 그 이유는 synchronized가 모니터(monitor) 기법을 사용하기 때문인데요.

synchronized의 모니터(monitor)

한 스레드가 메소드에 진입할 때 lock을 획득하여 해당 블럭을 잠그고 실행이 끝나면 lock을 다시 반환하게 되는데 블럭이 잠겨있는 동안에는 다른 메소드가 해당 메소드에 접근하지 못하고 무한히 대기하는 방식

그림과 같이 최대 요청 시간이 890(ms), 최소 요청 시간이 6(ms)가 걸린 것으로 보아 동시성 문제가 발생하는 테스트에서 보다 비교적 더 많은 시간이 소요된 것을 알 수 있습니다.

 

사실 스프링 프로젝트에서 sychronized를 이용하여 동시성 문제 발생을 막는 것이 일반적인 경우는 아닙니다. 예를 들어 다중 서버로 서비스하는 경우에는 synchronized는 하나의 프로세스 내에 존재하는 메소드의 thread-safe를 보장하는 것이기 때문에 다른 프로세스에서 공유 자원에 대한 동기화가 이뤄지지 않을 수 있습니다. 때문에 이 방법은 패스하고 JPA에서 지원하는 동시성 관리 기능을 다음에서 한 번 사용해보려고 합니다.

2) 낙관적 락(Optimistic Lock)

낙관적 락이란 race condition이 발생하지 않을 것이라고 생각하고 이를 방지하는 방법입니다. 낙관적 락은 version 시스템을 이용해서 데이터가 update 하려 할 때마다 version을 확인하여 일치하면 update가 반영되고 일치하지 않으면 update가 반영되지 않는 매커니즘입니다. 또, 데이터가 update 내역이 반영되면 DB 내에서 자체적으로 version을 증가시켜 동시에 쓰기 작업을 하는 트랜잭션과의 충돌을 막습니다.

 

따라서 낙관적 락은 DB에 직접적으로 lock을 건다기 보다는 충돌을 방지한다는 것으로 볼 수 있습니다.

 

낙관적 락 사용방법은 엔티티에 @Version이라는 어노테이션이 적용된 필드하나만 추가하면 됩니다.

@Version
private Long version;

위와 같이 엔티티에 버전 기능 필드를 추가하였습니다.

 

테스트 결과는 좋아요 수가 기댓값 1000이 아닌 206으로 실패입니다.

 

낙관적 락은 동시성 문제 해결을 위한 하나의 방법인데 왜 실패한 것일까요?

 

동시성 문제가 발생하는 환경과 낙관적 락을 이용하여 동시성 문제를 방지할 때, 이 둘의 각각의 요청에 대한 응답에는 사실 차이점이 존재합니다. 전자의 경우에는 (실제로 동시성 문제가 발생하였는데도 불구하고) 모든 작업이 성공하여 성공 응답을 리턴하는 반면, 후자의 경우에는 동시성 문제가 발생하면 Spring에서 ObjectOptimisticLockingFailureException이라는 exception을 터뜨려 실패 응답을 리턴해 줍니다. 따라서 전자는 클라이언트가 동시성 문제가 발생하였다는 사실을 알 수 없지만 후자는 클라이언트가 동시성 문제가 발생하였다는 것을 알 수 있다는 점에서 차이가 있습니다.

 

이러한 낙관적 락의 동시성 제어 방식은 실패 시 재요청/재시도의 수동적인 작업이 추가로 필요하지만 이는 DB에 많은 부하를 줄 수 있고 이러한 방식은 프로젝트의 좋아요 기능 의도와는 거리가 있다고 생각하여 적용하지 않았습니다.

3) 비관적 락(Pssimistic Lock)

비관적 락이란 race condition이 발생할 것이라 예상하고 미리 lock을 걸어 이를 방지하는 방법입니다. 비관적 락은 DBMS Lock 기능을 이용하여 테이블 레코드 자체에 배타 락(Exclucive Lock)을 걸어 해당 레코드에 접근하는 다른 트랜잭션의 읽기와 쓰기 작업을 둘 다 막습니다.

배타 락(Exclucive Lock)

"즉, 배타 락이 걸려있다면 다른 트랜잭션은 공유 락, 배타 락 둘다 획득할 수 없습니다.
배타 락을 획득한 트랜잭션은 해당 데이터에 대해 독점권을 가지게 되는 것입니다."

참고 - https://velog.io/@msung99/JPA-%EC%9D%98-%EB%B9%84%EA%B4%80%EC%A0%81-%EB%9D%BD%EC%97%90-%EB%8C%80%ED%95%9C-LockModeType-%EA%B3%B5%EC%9C%A0-%EB%9D%BD%EA%B3%BC-%EB%B0%B0%ED%83%80-%EB%9D%BD

 

그럼 한 번 테스트 해보겠습니다.

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select b from Board b where b.id = :id")
Optional<Board> findByIdForUpdate(Long id);

해당 구문은 JpaRepository의 메소드입니다.

 

@Lock 어노테이션의 LockModeType을 PESSIMISTIC_WRITE로 하여 배타 락을 걸도록 설정하였습니다.

 

이 때, 메소드 이름을 findByIdForUpdate로 명명한 이유는 기본적으로 제공하는 fibdById와 차별화하기 위해서이고 native query를 작성하지 않으면 Spring Data JPA 메소드 명명 규칙에 의해 빌드 오류가 발생하기 때문에 native query를 작성하여 이를 방지해야 합니다.

 

테스트 결과는 좋아요 수가 1000개로 알맞게 들어와서 성공입니다.

 

동시성 문제가 해결되었지만 비관적 락에도 단점이 존재하는데요.

 

위에서 말했듯이 비관적 락은 레코드 자체에 미리 lock을 걸어버립니다. 때문에 선수 트랜잭션의 작업이 다 끝날 때까지 대기를 해야하고 그렇게 되면 지나치게 고립성이 높아지는 현상으로 인해 성능적으로 저하를 초래할 수 있습니다.

 

그림과 같이 최대 요청 시간이 1047(ms), 최소 요청 시간이 1(ms)이 걸린 것으로 보아 마찬가지로 동시성 문제가 발생하는 테스트에서 보다 비교적 더 많은 시간이 소요된 것을 알 수 있습니다.

결론

저는 이번 동시성 문제에 대해 위에서 언급한 synchronized와 낙관적 락의 특성을 고려하여 최선의 방법이라고 생각했던 비관적 락을 해결 방법으로 채택하였습니다.

 

해결!

마무리

이번에는 프로젝트에서 발생한 동시성 문제를 해결하면서 관련한 내용들을 공부해봤는데요. 글에는 담지 않았지만 트랜잭션 ACID 속성, 트랜잭션 격리 수준, 모니터 lock, DB lock 등등 새로운 것들을 배우니 이해하는데는 다소 어려웠지만 흥미있게 공부할 수 있었던 시간이었습니다. 뿌듯하네요:)

 

다음엔 시간이 된다면 방금 언급한 글에 담지 않은 것들을 더 깊이 공부하여 포스팅 해봐야겠습니다.

 

끝!

 

 

참고

https://jaehoney.tistory.com/159

https://sunrise-min.tistory.com/entry/%EC%9E%90%EB%B0%94-%EB%8F%99%EA%B8%B0%ED%99%94-SynchronizedMonitor-Atomic-Type

https://kdhyo98.tistory.com/59

https://zzang9ha.tistory.com/443

https://velog.io/@lsb156/JPA-Optimistic-Lock-Pessimistic-Lock

https://golf-dev.tistory.com/73