좋아요 동시성 제어 과정

2024. 8. 20. 13:45Article

문제 상황

이 글은 프로젝트에서 진행했던 좋아요 동시성 제어 과정에 대한 글이다.

 

앞서 게시글 페이징 조회 성능 개선을 위해 좋아요 수(likeCount)를 게시글 엔티티의 필드로 추가하면서, 동시성 제어가 필요해졌다. (https://olsohee.tistory.com/169)

 

동시성 제어가 필요한 상황은 다음 두 경우가 있다.

  1. 사용자는 같은 게시글에 좋아요를 2개 이상 누를 수 없다.
  2. 여러 명의 사용자가 동시에 같은 게시글에 좋아요를 누를 경우 해당 게시글의 좋아요 수가 덮어 씌워지면 안 된다.

기존 코드에서 두 경우를 테스트한 결과, 두 경우 모두 데드락이 발생했다. 데드락의 원인은 MySQL이 조회하는 레코드에 외래 키가 있을 때, 외래 키에 해당하는 테이블 레코드에 S락을 걸기 때문이다. 즉, 게시글 테이블(post)과 좋아요 테이블(likes)을 조인하는 과정에서, 각 트랜잭션은 likes와 연관된 post 테이블의 레코드에 S락을 건다. 그리고 post의 likeCount 필드 값을 UPDATE하기 위해 배타 락을 걸어야 하는데, 상대 트랜잭션이 S락을 걸고 있기 때문에 대기하게 되면서 데드락 발생한다.

해결 과정

1. 낙관적 락 + Retry

낙관적 락을 통해 커밋 시점에 버전을 비교하고, 버전 충돌 시 재시도를 하는 방법이다.

 

그러나 좋아요를 누른다는 기능 특성 상 좋아요는 자주 발생하는 요청으로 충돌이 잦다. 따라서 재시도가 빈번히 일어나기 때문에 비효율적이고, 기존과 마찬가지로 데드락이 발생한다.

2. 비관적 락

비관적 락을 통해 SELECT FOR UPDATE 쿼리로 X락을 거는 방법이다. 쓰기 락을 걸기 때문에 데드락이 발생하지 않으며 동시성 제어에 성공한다.

 

그러나 락은 성능이 떨어진다. 따라서 트랜잭션 범위를 최소화하거나 트랜잭션 범위 내에서 성능을 개선시키는 것이 중요하다. 

성능 개선

기존 코드를 보면 post와 연관된 모든 likes를 조인하여 조회해온다. 그리고 애플리케이션 단에서 현재 사용자가 좋아요를 눌렀는지 순차 탐색한다. 즉, 게시글에 대한 좋아요가 10,000개라면, 조인 결과로 10,000개의 데이터를 메모리로 읽어와서 순차 탐색을 한다. 즉, 순차 탐색으로 인한 시간과 메모리 부담의 단점이 있다. 

 

따라서 이 부분을 개선하여 코드를 다음과 같이 변경했다. post 테이블에서 post와 연관된 모든 likes를 조인하여 조회하는 것이 아니라, likes 테이블에서 특정 사용자가 특정 게시글에 좋아요를 눌렀는지 한 건만 조회하도록 변경했다. 그리고 이때 FK 인덱스를 통해 조회하기 때문에 빠른 조회가 가능하다.

3. 그 외 락(synchronized, 분산 락)

DB 단의 락 말고 자바의 synchronized와 Redis를 활용한 분산 락이 있다.

 

우선 두 방법 모두 애플리케이션 단에서 사용할 때 @Transactional AOP와 함께 사용되며 동시성 제어에 실패할 수 있다. 또한 자바의 synchronized는 분산 환경에서는 동시성 제어가 불가하다.

 

그리고 두 방법 모두 비관적 락과 마찬가지로 락을 거는 방식이기 때문에 락으로 인한 성능 저하는 마찬가지이다.

4. 일정 주기로 싱크를 맞추는 방법

게시글의 likeCount 필드는 동시성 문제로 정합성이 보장되지 않지만, 일정 주기로 post 테이블과 likes 테이블 조인을 통해 정확한 좋아요 수를 구해 likeCount 값에 반영해주는 방법이다. 즉, 일정 주기로 싱크를 맞춰주는 것이다.

 

장점

  • 좋아요 수는 실시간성이 완벽하게 보장되어야 하는 속성은 아니다. (만약 실시간으로 정확한 좋아요 수가 요구된다면 해당 방법은 적합하지 않음)
  • 비관적 락 없이 좋아요 기능이 이뤄지므로 성능이 빠르다.

단점

  • 게시글 수가 많아질 경우, 일정 주기마다 그 많은 게시글에 대한 싱크를 맞춰야 하므로 부담스럽다. 만약 100만개의 게시글이 있다면, 일정주기마다 100만개의 게시글의 좋아요 수를 건들여야 한다. 

5. Redis 활용

좋아요 수를 Redis에서 관리하여, 좋아요를 누를 때마다 Redis에 반영하는 방법이다. Redis는 싱글 스레드이므로 동시성 문제가 발생하지 않는다. 그리고 좋아요 수가 필요할 때 Redis에서 조회해온다.

 

단점

  • Redis는 메모리에 데이터를 저장하므로 Redis 장애 시 문제가 발생한다.
  • 게시글이 많아질수록 Redis에 쌓이는 데이터가 늘어난다.
  • 게시글의 "좋아요"라는 속성이 Redis에서 따로 관리되면서 관리 포인트가 늘어난다.

6. Queue + 비동기 처리

좋아요 요청(likeCount 값 증가 요청)을 애플리케이션 내 큐에 담는다. 그리고 큐를 구독한 스레드가 해당 요청을 순차적으로 처리한다. 그리고 요청 처리 부분을 비동기적으로 구현한다.

 

물론 메시지 브로커를 사용할 수 있지만, 그럴 경우 별도의 메시지 브로커 + 메시지 큐의 컨슈머 역할을 하는 애플리케이션을 구축해야 하는 인프라 비용이 있다. 따라서 하나의 애플리케이션 내에서 처리하는 방법을 생각했다.

 

장점

  • 동시성 제어 성공
  • 좋아요 처리를 비동기로 처리하여 빠른 응답 가능

단점

  • 비동기로 구현하기 때문에 좋아요 요청 처리가 정상적으로 수행되지 않았음에도 사용자에겐 정상 응답이 나갈 수 있다. 따라서 좋아요 요청 손실 문제가 발생할 수 있다. 그러나 좋아요 수가 중요한 속성은 아니므로 괜찮다고 생각한다.
  • 스케일아웃 상황에서는 동시성 발생
    • 애플리케이션 1, 2에서 각각 큐를 통해 좋아요 요청이 처리되고, 두 트랜잭션이 동시에 DB에 접근하면서 동시성 문제가 발생할 수 있다. 따라서 결국 비관적 락을 통해 DB 단의 제어는 필수적이다.
    • 그러면 큐를 사용하지 않고 DB 락만 사용하면 되지 않을까? 큐를 사용함으로써 동시에 몰리는 DB 부하 부담을 줄여줄 수 있다. (애플리케이션 1, 2에서 각각 10개씩 총 20개의 요청이 동시에 DB로 몰리는 것보다, 큐를 통해 각 애플리케이션에서 1개씩 총 2개의 요청이 DB로 몰리는 것이 DB 부하 부담을 줄여줄 수 있다.)
  • 큐에 요청이 들어왔는지 확인하기 위해 계속 while 루프를 돈다. 
    • 그러나 이 문제는 자바의 BlockingQueue를 사용하여 해결할 수 있다.
    • BlockingQueue는 큐가 비어있는데 소비자가 큐의 아이템을 처리하려고(take()) 하면 블로킹되어 대기 상태가 된다. 그리고 큐에 아이템이 추가되면 대기 중이던 스레드가 다시 실행된다. 
    • 따라서 소비자 스레드가 비효율적으로 CPU 자원을 소모하며 while문을 도는 것이 아니라, 큐에 아이템이 들어왔을 때만 활성화되고, 그렇지 않을 경우엔 CPU 자원을 획득하지 않은 채로 블로킹되어 대기한다.

결론

처음에는 비관적 락을 통해 동시성 제어를 했다. 그러나 이후 여러 방법들을 고민해보니, BlockingQueue를 통한 방법이 더 좋다는 생각이 들었다. 향후 해당 방법도 프로젝트에 적용해봐야겠다!