S-HOOK의 좋아요 기능?
killing_part_like
, killing_part
, member
테이블이 존재합니다.
데이터베이스 조회 성능을 개선하기 위해 killing_part
테이블에 집계 컬럼인 like_count
를 추가하게 되었습니다. 통계 정보를 계속 업데이트 하기 위해 좋아요가 눌릴 때마다 like_count
를 업데이트해주어야 합니다.
즉, 다음과 같은 프로세스로 진행됩니다.
member
가 특정killing_part
에 좋아요 요청killing_part_like
테이블에killing_part
id,member
id 값을 갖는 row 가 있는지 확인- 있다 =>
killing_part_like
의is_deleted
가true
인 경우,false
로 변경.false
인 경우, 아무것도 수행하지 않음. - 없다 =>
killing_part_like
테이블에 행 추가
- 있다 =>
member
가 특정killing_part
에 좋아요 취소 요청killing_part_like
테이블에killing_part
id,member
id 값을 갖는 row 가 있는지 확인- 있다 =>
killing_part_like
의is_deleted
가false
인 경우,true
로 변경.true
인 경우, 아무것도 수행하지 않음. - 없다 => 아무것도 수행하지 않음.
- 있다 =>
KillingPart
의 업데이트 과정은 JPA 의 더티 체킹을 통해 이루어집니다.
최대한 도메인을 이용해서 비즈니스 로직을 수행할 수 있다는 장점이 있지만, 이 부분에서 동시성 문제가 발생할 수 있습니다.
왜 동시성 문제가 발생할까?
위와 같이 하나의 트랜잭션이 업데이트 쿼리를 실행하는 동안, 다른 트랜잭션이 레코드를 변경하게 된다면 다음과 같은 상황이 발생한다.
사용자 A의 트랜잭션이 업데이트 되기 전에 사용자 B 의 트랜잭션에서 좋아요 수를 읽어오기 때문에 정합성 이슈가 발생할 수 있습니다.
동시성 이슈 해결 방법
이 문제를 해결하기 위해 정말 다양한 방법을 생각해보았습니다.
비관적 락 사용하기
킬링파트에 좋아요를 하는 메서드에 비관적 락을 거는 방법이 있습니다.
비관적 락이란 대부분의 트랜잭션이 서로 충돌이 날 것이라는 상황에서 데이터베이스에 락을 걸어 동시성을 제어하는 것입니다.
이렇게 되면 A 트랜잭션이 끝날 때까지 다른 트랜잭션들이 배타락, 공유락 모두 얻을 수 없습니다. 한 번에 하나의 트랜잭션만 락을 얻을 수 있기 때문에 데이터의 정합성 문제가 해결됩니다.
그러나 비관적 락을 사용하게 되면 대기 시간이 발생할 수 밖에 없습니다.
현재 서비스에서 '킬링파트 좋아요' 는 굉장히 중요한 로직을 담당하고 있습니다. 노래가 좋아요 순으로 실시간으로 정렬되기 때문에, 좋아요의 실시간 성은 매우 중요합니다.
이런 상황에서 대기 시간이 발생하는 비관적 락은 상황에 맞지 않다고 볼 수 있습니다.
낙관적 락 @Version
사용하기
낙관적 락이란 대부분의 트랜잭션이 충돌이 나지 않을 것이라는 가정 하에, 데이터베이스에 락을 걸지 않고 애플리케이션 단에서 엔티티 버전 관리를 통해 동시성을 제어하는 것입니다.
엔티티가 변경될 때마다 Version 이 하나씩 증가하게 됩니다. 엔티티를 수정할 때 조회한 시점의 버전과 수정 시점의 버전이 일치하지 않으면 예외가 발생합니다. 단순하게 예외를 무시하게 되면 좋아요를 누른 사용자 입장에서는 이해할 수 없는 동작이 되므로, 발생한 예외는 요청을 다시 보내 달라는 등 적절한 조치가 취해져야 합니다.
그러나 저희가 처한 상황은 낙관적 락에 적합하지 않습니다.
킬링파트의 좋아요가 서비스 내부에서 다양하게 사용되기 때문에, 좋아요 기능은 사용자들이 가장 많이 사용하게 될 것입니다. 이때, 동일한 킬링파트에 좋아요를 누르는 행동이 빈번하게 일어나지 않을 것이라는 가정은 하기 어렵습니다.
직접 update 쿼리 실행하기
더티 체킹을 포기하고, DB가 직접 원자적 연산을 처리하는 것입니다.
도메인 값을 변경하지 않고, 서비스 레이어에서 직접 레포지토리의 메서드를 호출해주어야 하므로 모든 비즈니스 로직을 도메인에서 응집하기는 불가능해집니다.
그러나 객체지향을 철저하게 지키는 것보다 정합성이 더 중요하다면 사용하는 것이 좋습니다.
직접 update 쿼리를 JPQL 로 구현하여 실행하게 되면 DB 자체에서 걸어주는 배타락 덕분에 정합성 문제가 발생하지 않습니다!
JPQL 에서 update 를 실행하므로 @Modifying
을 사용하게 됩니다. 다음과 같은 속성들을 true
로 바꿔주는 것이 좋습니다.
clearAutomatically
: 기본값은false
로true
로 설정하면 쿼리 실행 후에 영속성 컨텍스트를 자동으로 clear 합니다.flushAutomatically
: 기본값은false
로true
로 설정하면 쿼리 실행 전에 영속성 컨텍스트를 플러시합니다.
이벤트 기반 아키텍처 도입하기
좋아요를 누를 때 바로 like_count
를 업데이트하지 않고, 이벤트를 생성하고 이벤트 핸들러에서 like_count
를 업데이트하는 것입니다.
이벤트 처리는 일련의 순서대로 처리될 수 있기 때문에, 이렇게 하면 여러 사용자가 동시에 좋아요를 누르더라도 이벤트 순서대로 처리됩니다. 따라서 정합성을 유지할 수 있습니다.
단, 이벤트는 순서대로 처리되어야 합니다. 만약 순서대로 처리되지 않는다면 다음과 같은 상황이 발생할 수 있습니다.
- 사용자 A와 사용자 B가 거의 동시에 같은
killing_part
에 좋아요를 누름. - 시스템은 두 이벤트를 거의 동시에 인지하고, 현재
like_count
값을 읽어옴 (예: 5). - 두 이벤트 모두
like_count
에 1을 더해 업데이트하려 함. - 결과적으로
like_count
는 6이 되지만, 원래 의도한대로라면 7이 되어야 함.
따라서 이벤트 순서를 보장하기 위한 방법 (카프카, 이벤트 소싱 등) 을 사용하는 것이 좋습니다.
참고
나중에 공부를 위해..
Redis 사용
killing_part
의 like_count
를 업데이트하기 전에 분산락을 얻는 것입니다.
락을 가진 인스턴스만 like_count
를 업데이트 할 수 있습니다.
그러나 Redis 를 사용하기 위한 러닝 커브가 존재하여, 선택하지는 않았습니다.
결론
다양한 방법을 고려해보았으나, 결론적으로 팀에서는 도메인 값을 변경하지 않고, DB 의 원자적 연산을 사용해서 집계 컬럼인 like_count
를 조작하는 것이 가장 낫다는 판단을 내렸습니다.
Redis 와 이벤트 기반 아키텍처를 도입하는 것은 러닝 커브가 크기 때문에 단기간에 수행할 수 없고, 비관적 락과 낙관적 락은 서비스에서 중요한 의미를 가지는 좋아요 도메인과 맞지 않기 때문입니다.
약간의 객체지향적 설계를 희생하게 되지만 정합성과 트레이드 오프가 되었다고 생각할 수 있을 것 같습니다.