티스토리 뷰
요구 사항
- 그룹 랭킹과 그룹 별 회원 랭킹을 보여줘야 한다.
- 회원 랭킹은 각 회원의 GitHub 기여도(커밋, 이슈, pr, 코드리뷰) 개수를 모두 더한 값을 포인트로 하여 랭킹을 매긴다.
- 각 기여도는 각 다른 GitHub API를 통해 조회하여 저장한다.
- 그룹 랭킹은 그룹 내에 속한 회원들의 포인트(GitHub 기여도)를 모두 더한 총합을 토대로 랭킹을 매긴다
문제 상황
- 동시에 여러 번 호출할 경우 DB 데이터가 중복 저장되는 현상 빈번히 발생
- 특히, 그룹의 포인트 갱신 시 동시성 이슈 발생
- 한 번의 요청에 종류가 다른 4가지 멤버의 기여도를 가져와 하나의 같은 테이블을 업데이트, 다른 한 테이블에는 insert
DB Lock 적용
- DB Lock을 통해 데이터의 중복 저장을 막을 수 있을 것이라 기대
시도 1 : @Version 기반 낙관적 락
- 성능을 생각해 낙관적 락을 먼저 걸어보기로 했다.
- Jpa 엔티티에 @Version 필드를 추가해 락을 적용한다.
- 다중 요청시 version 필드의 충돌로 인해 롤백이 자주 발생했다.
- 쓰기 작업에 더 높은 수준의 락이 필요하다고 느꼈다.
시도 2: 베타락
- 사용할 JpaRepository의 메소드에 Lock 어노테이션을 통해 베타락을 적용했다.
- 동시성 문제는 해소되었다. 하지만, 데드락이 간헐적으로 발생했다.
데드락 관련
- MySQL은 쿼리 수행 시 베타 락에 대해 timeout을 제공하지 않는다. (자체 설정된 값이 있긴 하지만, 전역 설정 처럼 동작한다.)
- 대신, JPA의 QueryHint 기능으로 timeout을 걸 수 있기에 시도했다.
- 데드락은 어느정도 해소되었지만, JPA 기능으로 timeout을 적용하는 경우 MySQL로 인해 예외를 던지는 것이 아니기에, 예외를 반드시 던져 롤백시키거나, 재시도를 통해 무조건 성공하도록 구현해야 한다.
- 서버 전체 tps에 영향을 줄 수 있을 것이라 판단했다. 따라서 다른 방식을 고민하였다.
- 그리고, 개발하는 도중 아키텍처에 Redis가 추가되었다. Redis 로도 동시성을 제어할 수 있기 때문에, 고려하기로 했다.
시도 3: Redis 분산락
- Redis List 등으로 순차 처리하는 방식, INCR를 사용하는 방식, 그리고 setnx 를 통한 스핀락이라는 다른 동시성을 제어하는 방법이 존재했다.
- Redis List (mq와 같은 방식)
- Redis List를 통해 모든 요청을 순차처리 하는 방식이다.
- 동시성 이슈가 발생하는 요청이 많은 상황에서 모든 요청을 순차 처리하기에는 랭킹이라는 시스템 자체가 빠르게 갱신되어야 한다는 점에서 좋은 선택지는 아니라고 생각했다.
- List를 여러개 만드는 방법도 있다. 그룹 별로 만들면 구현은 가능하지만, 스케줄링을 통해 BRPOP 연산을 지속적으로 수행하는 부분에서 구현 비용과 서버 자원을 단순 락보다 많이 사용할 것이라 판단해 배제했다.
- INCR 방식
- 랭킹 포인트가 최대치가 정해지지 않았기에 도입하기 어려웠기에 배제했다.
- setnx 스핀락
- 스핀락은 반복문을 돌며 락 획득이 가능한지 계속해서 확인하는 작업을 수행하는데, cpu 자원을 많이 소모할 수 밖에 없는 구조다. Redisson RedLock은 이러한 부분을 pub/sub을 통해 해소한 락이기에, 우선 적용하기로 했다.
- 추가로, 백엔드 서버와 레디스가 모두 다중 서버였다.
- Redis List (mq와 같은 방식)
- 분산락은 처음 락을 획득한 요청이 우선 처리되도록 구현했다.
- 다음 요청에 우선권을 주면 무수히 많은 요청이 오는 경우에 정상 처리되는 요청이 계속 바뀌며 처리가 늦어질 수 있다.
세부 구현 (전파 레벨과 락 해제 시점)
- 분산락은 어노테이션과 AOP를 통해 구현하여 어노테이션이 붙은 메서드에서 동작하도록 설정했다.
- 또한, 전파 레벨을 REQUIRES_NEW 로 만들어 기존 트랜잭션 유무와 상관 없이 새로운 트랜잭션이 생성되어 그 안에서 실행되며, try-catch-finally의 finally에서 락을 해제하여 해당 새로운 트랜잭션이 끝난 이후 락을 해제하도록 하였다.
@Transactional(propagation = Propagation.REQUIRES_NEW)
왜 트랜잭션이 끝나고 락을 해제할까?
- 첫번째 요청의 트랜잭션이 끝나기 전에 락을 해제하고 두번째 요청이 락을 점유하게 되면, 두번째 요청의 락 점유 이후에 커밋이 되어 데이터 정합성이 깨질 수 있다.
- 커밋 시점에 두번째 요청이 락을 점유한다는 것은, 커밋 이전에 두번째 요청이 데이터를 읽어왔기에 변경 이전의 데이터를 읽은 것이기 때문이다.
결과
- 불안정하게 처리되었던 동시 요청을 안정적으로 처리가 가능해졌다.
이러한 과정을 통해...
- 여러 락 종류에 대해 학습해볼 수 있었고, 데이터 정합성에 대한 고민도 할 수 있어 뜻 깊었다.
'프로젝트 탐구 > GitRank' 카테고리의 다른 글
Github API의 느린 응답 이슈 개선을 위한 스케줄링을 통한 DB 업데이트 (0) | 2024.03.25 |
---|---|
Redis Sorted Set으로 기존 DB 페이징 기반 랭킹 시스템 개선 (0) | 2024.03.25 |