[트러블슈팅] 좋아요 동시성 처리
문제 상황
Story - Like는 1:N 관계이고 스토리를 조회할 때 스토리의 좋아요 수를 같이 가져와야 했다.
이전에는 Story를 가져올 때 Like 테이블을 조인해서 좋아요 수를 같이 가져왔다.
하지만 좋아요 수가 많아질수록 조인 오버헤드가 점점 커질 것으로 판단됐다.
성능 개선을 위해 좋아요 수를 비정규화했다. 그런데 여기서 여러 문제가 발생했고 이를 해결한 과정을 공유하고자 한다.
domain
설명에 불필요한 부분은 제거하고 필요한 부분만 남겼다.
Like
Story
→ 좋아요 수(likeCount)를 비정규화해서 Story의 필드로 추가했다.
StoryService
스토리 좋아요 로직이다.
동시성 테스트
여러 스레드에서 동시에 좋아요 요청을 하는 테스트이다.
테스트를 실행시키면 데드락이 발생한다.
데드락 발생
데드락 발생 원인
Mysql 공식 사이트를 참고해보면 데드락의 발생 원인을 알 수 있다.
- fk를 포함한 데이터의 insert, delete, update 가 일어나면 참조되는 fk의 데이터에 S-Lock이 걸린다.
- 그리고 Mysql에서는 update 쿼리가 나갈 때 X-Lock 을 건다.
이 두 가지 사실을 기반으로 데드락이 걸리는 이유를 파악할 수 있었다.
<1> 에서 Story에 S-Lock이 걸리고 <2> 에서 Story에 update 쿼리를 날리기 위해 X-Lock을 얻으려고 한다.
그런데 여러 스레드가 동시에 접근한다면 동시에 여러 트랜잭션에서 S-Lock을 가지고 있을 수 있다. 그리고 여러 트랜잭션에서 Story의 likeCount를 변경하고 Dirty Checking을 통해 update 쿼리를 날리는 시점에 X-Lock을 획득하려고 할 것이다. 이때 서로 S-Lock을 가진 상태에서 X-Lock을 얻으려고 하기 때문에 데드락이 발생할 수 있다.
해결 방안
Try 1 : Optimistic Lock (실패)
사실 좋아요 같은 경우 동시에 누를 확률이 그렇게 높지 않다. 때문에 Optimistic Lock으로 처리하면 괜찮다고 생각했다. 하지만 위에서 데드락이 발생한 이유와 동일한 이유로 데드락이 발생했다.
Story (Version 추가)
Story 필드에 version 필드를 추가한다.
StoryRepository
Story 조회 부분 수정
OptimisticLockAspect
충돌이 발생했을 때 Retry 로직은 Aop 로 구현했다.
테스트 결과
예상한 것처럼 Optimistic Lock을 걸어도 데드락이 걸린다.
원인
Optimistic Lock을 사용하더라도 Mysql을 쓰는 이상 Like를 Insert 할 때 Story에 S-lock은 걸리게 되고 Dirty Checking으로 update 쿼리가 나가는 순간 똑같이 데드락이 발생한다. 때문에 이 방법으로는 문제를 해결할 수 없다.
Try 2: Pesmistic Lock (성공 But 성능 이슈)
앞서 말한 것처럼 좋아요 같은 경우 Race Condition이 발생할 확률이 그렇게 높지 않을 것으로 예상된다.
하지만 Optimistic Lock으로는 데드락이 걸리는 문제를 해결할 수 없었다.
이번에는 Pesmistic Lock을 사용해서 동시성 처리를 해보겠다.
StoryRepository
조회할 때부터 X-Lock을 가져온다.
Story 조회 부분 수정
테스트 결과
테스트 성공적으로 통과!
Story를 조회하는 시점에 X-Lock을 걸게 되면 다른 트랜잭션에서 값을 변경할 수 없기 때문에 데이터의 정합성을 보장할 수 있다.
문제점 : 트랜잭션이 끝날 때까지 다른 트랜잭션에서 대기해야 하기 때문에 성능은 좋지 않다.
Try 3 : Redis를 활용한 분산락 (최종 선택)
redis는 inmemory에서 작동하기 때문에 mysql에 비해서 속도가 훨씬 빠르다. 그리고 현재 프로젝트에서 메일 인증 기능을 구현하면서 redis를 이미 사용하고 있기 때문에 추가적인 인프라 설정 비용은 없었다.
→ redis를 사용하여 좋아요 동시성 문제를 처리하기로 결정했다.
Redisson vs Lettuce
Lettuce의 경우 분산락을 직접 구현해야 하고 재시도, 타임아웃도 직접 구현해야 한다. 그리고 스핀락 방식이기 때문에 동시에 여러 스레드에서 요청을 한다면 redis에 부하가 많이 간다.
Redisson은 pub/sub 구조로 구현되어 있기 때문에 redis의 부하가 적게 가고 Lock 획득 재시도를 기본으로 제공하기 때문에 사용하기 편리하다.
→ Redisson을 사용하기로 결정!
Redisson 라이브러리 추가
@RedissonLock
RedissonLockAop
분산락 처리는 횡단 관심사이기 때문에 Aop로 처리했다.
여기서 중요한 부분은 TargetTransaction.proceed(joinPoint);
이다.
TargetTransaction
타겟 메서드는 부모 트랜잭션과 관계없이 동작하도록 Propagation.REQUIRES_NEW
설정을 해줬다.
이렇게 해주는 이유는 트랜잭션 커밋 시점이 Lock의 해제 시점 이후일 때 동시성 문제가 발생할 수 있기 때문이다.
→ 어떤 이유로 동시성 문제가 생길 수 있는지 알아보자.
갱신 분실
트랜잭션 A(TxA)와 트랜잭션 B(TxB)가 있다고 가정하자. 그리고 현재 likeCount = 0이라고 해보자.
TxA, TxB가 좋아요 로직을 수행하기 위해서 동시에 storyLike()
메서드에 접근한다고 가정하자.
TxA가 아주 미세한 차이로 먼저 Lock을 획득하고 likeCount를 1 증가시키고 Lock을 해제시켰다. (아직 트랜잭션을 커밋하지 않았다.)
이 상태에서 TxB가 Lock을 획득하여 Story를 조회했다. (이때 조회된 Story는 TxA가 수정하기 전의 Story이다. 즉 Story의 likeCount = 0이다.)
다시 TxA에서 트랜잭션 커밋을 한다. 그러면 Story의 likeCount = 1이 된다.
그리고 다시 TxB에서 Story의 likeCount를 1 증가시키고 트랜잭션을 커밋한다. (이때 TxB에서 조회한 likeCount는 0이었기 때문에 최종적으로 Story의 likeCount는 1이 된다.)
2번의 좋아요 요청이 있었는데 likeCount는 1만 증가하게 된 것이다.
→ 위와 같이 새로운 트랜잭션에서 처리하지 않으면 트랜잭션 커밋이 Lock 해제 이후에 일어나서 갱신 분실 문제가 발생할 수 있다.
LikeService
스토리 좋아요, 좋아요 취소 로직에 @RedissonLock
에노테이션을 붙여서 Aop를 적용시켰다.
테스트 결과
정상적으로 테스트가 통과하는 것을 확인할 수 있다.
느낀점
동시성 처리를 위한 여러 방식을 직접 구현해 보고 비교해 봄으로써 각 방식의 장단점을 알 수 있었고 어느 시점에 어떤 방식을 사용하면 좋을지 감을 잡을 수 있었다.