시스템 설계 고민 1

가장 간단하게 구현한 시스템 부터 대규모 트래픽을 감당할 수 있을 정도의 시스템 까지
생각하면서..
시스템 설계 방법을 나열해보겠다..
(아래 내용은 트랜잭션 격리 수준에 따라 내용이 좀 달라질 수 있다.)
(틀린 점이 있을 수 있다.. 있으면 댓글 부탁드립니다.)
 
기술 스택 : Spring, JPA, MySQL, Redis
 
 

요구사항

게시물의 좋아요 기능 설계에 대해서 고민해보자..
 
좋아요 카운터를 증가시키는 트랜잭션은 2개의 쿼리로 진행했다.
1. 현재 좋아요 수 조회
2. 좋아요 수 = 현재 좋아요 수 + 1
-> JPA 에서 Dirty Check 기능으로 업데이트하는 것과 같다.
 
쿼리 1개로 하면 안되나..?
-> 밑에서 따로 다룬다.
 

시스템 설계 방법

첫번째 방법

가장 생각하기 쉬운 방법이다.

위와 같이 DB 에 post Table column 으로 like_count 를 둔다.
(3가지 컬럼 외에도 member_id, created_at 등이 있다고 생각하자.)
 

시스템은 위와 같이 설계한다.
 
코드에서는 따로 트랜잭션이나 락을 안걸었다고 생각해보자.
 
첫번째 방법의 문제점은..
트래픽이 몰려오면..
동시성 문제(Race condition) 가 생긴다는 것이다.
 

두번째 방법

비관적 락(Pessimistic Lock)을 사용하면.. 어떻게 될까..
 
조회를 한 시점 부터 업데이트 하는 시점 까지 락이 걸린 상태를 유지한다.
따라서, 그냥 트랜잭션들을 한 줄로 줄세우기를 한 것이다.
 
두번째 방법의 문제점은..
MySQL 에서의 락(select for update)은 인덱스를 잠근다.
그래서 where 문 조건에 따라 수정하려는 row 만 잠기는게 아닐 수 도 있다.
-> 생각지도 못한 다른 게시물도 락이 걸릴 수 있다.
또한, 낙관적 락보다 락의 범위가 길다.

 

<참고>
비관적 락에서 조회 시, 공유 락을 사용한다면..
데드락이 발생할 수도 있을 것 같다..
1. 트랜잭션 A 공유락 적용 
2. 트랜잭션 B 공유락 적용 
3. 트랜잭션 A 쓰기락(베타) 을 걸려고 시도 .. B의 공유락이 걸려있으므로 대기 
4. 트랜잭션 B 쓰기락(베타) 을 걸려고 시도.. A의 공유락이 걸려있으므로 대기 
5. 데드락

하지만, 비관적 락에서의 공유락은 잘 사용되지 않으며,

데이터 베이스 자체적으로 비관적 락의 베타락으로 동작한다고 한다..

 

세번째 방법

동시성 문제를 해결하기 위해 낙관적 락(Optimistic Lock)을 사용해보자.
낙관적 락은 CAS (Compare and Set) 를 통해 제어하는 방법이다.
조회를 하고 업데이트를 하는 로직이 있다면,
업데이트 할때 조회한 시점의 버전과 동일하면 변경하고 다르면 변경하지 않는 방법이다.
다르다면, 업데이트 실패 처리가 되고 Application layer 에서 이를 처리해줘야 한다.

-> 재시도 처리, 실패 처리 등..
 
낙관적 락을 사용함으로 인해서 동시성 이슈는 잡혔고..
락 범위도 업데이트할 때(단일 연산) 찰나의 순간(DB layer)에만 걸리기 때문에 최소화 된 꼴이다.
-> Application layer 에서의 락은 없다.
-> 실제로 좋아요를 많이 안 받는 게시글의 경우.. 원래 동시성 문제가 거의 없으므로, 
-> 실패로 인한 아래 세번째 방법의 문제점 상황이 잘 일어나지 않고 스무스하다.
 
세번째 방법의 문제점은..
대규모 트래픽의 상황에서는 좋아요 기능이 계속 실패가 날 수 있다..
-> 업데이트 하기 전에 조회를 했던 버전과 계속 다를 것이다..
또한, 데이터 일관성 문제가 발생 할 수 있다. (격리성 부족)
-> 좋아요 갯수를 낙관적 락 조회 시점에 가져가서 다른 DB 에 적재한다고 생각해보자..
 
 

네번째 방법

첫번째 방법 부터 세번째 방법까지해서 동시성 문제는 어느정도 해결을 할 수 있었지만..
여전히 대규모 트래픽을 감당하진 못하고 있다..
 
문제의 원인은 하나의 row 에 대한 수정을 동시에 계속 진행하려고 하는데에 있다.
 
그래서, 테이블 구조를 개편할 필요가 있다.

어떤 게시글에 대한 좋아요를 누를 때, 하나의 row (post Table) 에 접근하는게 아니라
post_like 라는 테이블을 두고 좋아요를 누를 때, row 를 하나씩 추가하는 방법으로 개편하였다.
-> 하나의 row 를 두고 동시성 문제를 없에는 것과 동시에 대규모 트래픽도 감당 가능하게 되었다.
 
네번째 방법의 문제점은..
데이터가 계속 적재가 되므로.. 대용량 데이터에 대한 처리가 필요할 수 있다.
또한, 게시글을 조회 할 때 좋아요 수를 집계할 필요가 생겼다.
이는, 게시글이 조회 될 때마다 grop by, count 를 통한 집계 함수 쿼리를 날려야하며 데이터 베이스에 부하가 생긴 것이다.
 

다섯번째 방법

부하가 생기는 원인인 post_like DB 를 post DB 와 DB 자체를 분리 시키고..
일정 주기에 따라 post_like DB 로 count 쿼리를 날리는(비동기가 되었다.) 스케쥴러 서버를 두고
해당 결과를 기존 post DB 로 삽입 해주는 방법이다.
-> 게시물을 조회 할 때마다 count 쿼리를 날려서 부하를 주던 부분을 개선하였다.
 
다섯번째 방법의 문제점은..
좋아요 갯수가 실시간으로 업데이트 되지않는다. (데이터 최신성 부족)
-> 최종적 일관성은 지킨다.
 
 

여섯번째 방법

NoSQL 중에 Redis 를 Data Store 목적으로 보고 사용한다면,
적절한 방법이 될 수 있다.(동시성, 대규모 트래픽 문제 모두 감당됨)
Redis Data Type 중, Redis Hashes 를 사용하면
조회와 수정이 모두 O(1) 이다.
Key, hashKey(field), value 를 각각
post:{post_id}, "like_count", {숫자} 로 두고 사용해보자.
-> field 에 다른 값을 추가하면, post 에 대한 다양한 정보를 빠르게 조회할 수 도 있을 것이다.
 
 
<번외>
post, {post_id}, {post} 로 둔다면..?
value 를 업데이트하거나 조회 할때 설정한 코덱 기반으로 직렬화/역직렬화 를 할텐데
post 객체를 얻기 위해서 그러한 파싱과 매핑 과정이 필요할 것이다.. 비효율적으로 생각되고..
단일 인스턴스 기준이긴 하지만, Redis Hashes 는 field-value 쌍이 약 40억 정도만 감당 된다..
 
 

참고

UPDATE post SET like_count = like_count + 1 WHERE id= :id
관계형 데이터 베이스에는 위 쿼리와 같이 단일 컬럼에 대해 increment 를 원자적으로 처리할 수 있다.
일반적으로 동시성 문제가 없어야 하는게 맞다..
 
하지만, 해당 쿼리는 DB 종류, DB 엔진 종류, 격리 수준에 따라서..
동시성 문제가 발생할 수 있다.. 즉, race condition 을 방지 하지 못한다.
 
아래는 해당 쿼리의 실제 일반적인 DB 내의 동작 단계이다.
이것 또한 DB에 따라 다를 수 있음
-> 잠금과 조회의 순서가 달라진다고 생각해봐라.. 바로 동시성 문제 생긴다.
 
1. 트랜잭션 시작
2. 레코드 잠금 (Lock)
3. 데이터 조회 (캐시 or 디스크에서 읽기)
4. 데이터 수정 (메모리에서 연산 후, 데이터 베이스의 쓰기 버퍼에 저장)
5. 데이터 쓰기 (버퍼링 되거나 디스크에 저장)
6. 레코드 잠금 해제
7. 트랜잭션 종료 및 커밋
 
따라서, DB 종속적인 쿼리를 쓰지않고.. (update 쿼리 하나로 동시성 문제 없길 바라면서 개발하진 않겠다.)
좋아요 카운터를 증가시키는 쿼리는 조회를 하고 업데이트 하는 두가지의 쿼리로 진행하였고..
이를 바탕으로 동시성 문제를 다뤄 보았다.
 

 

'대규모 시스템 설계' 카테고리의 다른 글

Layered architecture faults and improvement  (0) 2023.06.22
시스템 설계 고민 2  (0) 2023.06.09
Event-Driven 아키텍처 와 Pub/Sub 모델  (0) 2023.06.03
Hexagonal Architecture 정리  (0) 2023.05.15
CQRS Pattern  (0) 2023.02.15