프로젝트 진행하다 보면 동시성 이슈를 해결해야할 때가 있다. 최근 사이드 프로젝트에 북마크 기능을 구현할 때 어떻게 동시성 이슈를 잡았었고 어떤 고민을 해서 잡는 방법을 채택했는지 기록해볼까 한다.
도메인 간단 이해
일단 도메인 구조를 보면, Place(장소)라는게 있고, 이 장소에 북마크를 찍는개념으로 해서 Bookmark(북마크)가 있고 1(장소):N(북마크)로 되어있다.
그리고 Place 리스트를 조회할 때 bookmark 갯수가 같이 노출돼야하기 때문에 성능 최적화를 위해 bookmarkCount라는 컬럼을 Place가 반정규화해서 들도록 했다.
/// imports...
/// annotations...
public class Place extends AbstractRootEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/// fields...
private Integer bookmarkCount;
/// constructors and methods...
}
/// imports...
/// annotations...
public class Bookmark extends AbstractRootEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long placeId;
private Long memberId;
/// constructors and methods...
}
북마크를 찍는 트랜잭션에는 다음과 같은 로직이 필요하다.
- 유저가 이미 북마크한 장소인지 검증 후 bookmark 테이블에 insert
- 해당 place에 bookmarkCount 1 증가
고민 시작
일단 동시성 이슈가 발생하는 포인트는 두 가지다.
- (placeId, memberId) 조합은 유니크해야 한다. 근데 이걸 검사하고 삽입하는 과정에서 check-then-act가 발생할 수 있다.
- bookmarkCount는 1씩 증가하는데 여러 사람이 동시에 한 장소의 bookmarkCount를 수정할 수 있다. read-modify-write가 발생한다.
다양한 동시성 처리 방법이 떠올랐는데,
- syncronized: 어플리케이션 단위 락이기 때문에 서버 다중화 되는 순간 깨진다.
- (placeId, memberId) 조합의 유니크 키를 잡아서 충돌날 경우 트랜잭션 롤백시키기.
- 테이블에 soft delete가 적용되지 않았으면 할 법한 방법이지만, soft delete가 적용되어있다.
- 따로 유니크 키를 위한 테이블을 빼는 방법도 있다고 하지만, 이렇게 구현할 시 구현이 많이 복잡해진다.
- 비관적 락 or 낙관적 락: DB단위 락이다. DB row에 기준을 두고 락을 잡는다. 이 케이스에는 place에 락을 잡으면 동시성 문제를 잡을 수는 있었다. 단, 다음과 같은 문제점이 존재한다.
- Place row가 락에 잡히기 때문에 place를 수정하는 모든 요청이 이 락에 영향을 받는다. 동시성 문제가 발생하는 로직은 이거 하나인데 이거 하나를 위해 모든 place를 수정하는 로직의 속도가 이 락에 의해 영향을 받는게 비효율적이라고 생각했다.
- 분산 락을 잡는다.
- MySql의 네임드 락 사용
- Redis 사용
고민한 결과 분산락을 이용해서 동시성을 잡기로 했고, Redis를 사용하기로 했다.
일단 syncronized와 유니크 키, 비관락,낙관락을 사용하지 않은 이유는 위에 기술한 단점 때문이고 MySql에서 제공하는 네임드 락이 아니라 Redis를 사용한 이유는 다음과 같다.
- 일단 MySql보다 Redis를 사용했을 때가 성능이 더 빠르다.
- 레디스가 세팅되지 않은 상태라면 별도의 인프라 구축 비용이 들지만, 이 프로젝트엔 이미 레디스를 활용하던 중이었기에 해당 단점이 적용되지 않았다.
레디스로 락을 잡기로 했는데, 다음과 같은 방식이 또 나뉘었다.
- setnx 명령어를 사용한 스핀 락 방식
- pub/sub을 이용한 방식
이 장단점도 한번 고민해봤는데, 스핀 락 방식의 경우 락 획득을 실패했을 때 재시도 하도록 구현하게 되면, Redis에 부하를 줄 수 있다.
pub/sub 방식으로 구현할 경우 이러한 부하를 줄일 수는 있지만, 일반적으로 레디스 채널을 만들고 메시지를 보내는 비용이 그냥 레디스에 lock 정보만 띡 저장하는 것보단 비용이 더 들며, 구현이 좀 더 복잡하다.
정리해서 다음과 같은 결론을 내렸다.
충돌이 자주 발생할 수 있고, 재시도를 해야 한다면 pub/sub이 유리하다.
충돌이 자주 발생 안하거나 충돌시 재시도가 아니라 실패응답을 하면 되는 케이스라면 그냥 스핀락 방식이 유리하다.
이 프로젝트에선 pub/sub을 사용하기로 했다. 일단 복잡한 구현의 경우 Redisson이라는 라이브러리를 사용하면 좀 간편하게 구현할 수 있었다. 그리고 해당 로직은 충돌시에 재시도를 하는 것이 맞다고 판단했다.
결론
Redis 사용해서 잡았고 Redisson라이브러리 써서 pub/sub 방식의 락을 잡게 되었다.
(서버 다중화, Redis 이미 세팅, 락 획득 실패시 재시도 해야함.)
'CS' 카테고리의 다른 글
얘는 대체 왜 이럴까? (모르면 죽을 수 있음) (1) | 2022.11.22 |
---|