본문 바로가기

프로그래밍_인강

좋아요 수 설계 - 비정규화, 락

좋아요 수 설계는 아래의 내용을 전제로 한다.
  • 좋아요 수는 전체 개수를 실시간으로 빠르게 보여줘야 한다.

 

좋아요 설계 방향

좋아요 수의 전체 개수를 실시간 조회(count)하는데 큰 비용이 든다면, 좋아요가 생성/삭제될 때마다 미리 좋아요 수를 갱신하는 방법이 있다. 좋아요 테이블의 게시글 별로 데이터 개수를 미리 하나의 데이터로 비정규화해두는 것이다. 

 

좋아요 수를 설계하기 위해 좋아요 수의 데이터 특성을 살펴봐야 한다. 이 데이터는 아래와 같은 특성을 가지고 있다고 가정한다. (요구 사항에 따라 적절히 판단)

  1. 쓰기 트래픽이 비교적 크지 않다.
  2. 데이터의 일관성이 비교적 중요하다. 

쓰기 트래픽이 크지 않고, 데이터 일관성이 중요하다면 RDB의 트랜잭션을 고려해볼 수 있다. 좋아요 테이블의 데이터 생성/삭제와 좋아요 수 갱신을 하나의 트랜잭션으로 묶는 것이다. 

 

 

좋아요 수를 어디에서, 어떻게 관리할 것인가?

1. 게시글 테이블에 좋아요 수 컬럼 추가

좋아요 수는 게시글과 1:1 관계의 데이터이기 때문에, 게시글 테이블에 좋아요 수 컬럼을 추가하는 것을 고려해볼 수 있다. 하지만 게시글 테이블에 좋아요 수 컬럼을 추가하여 갱신하는 것은 제약이 발생할 수 있다. 

 

게시글 테이블에 좋아요 수 컬럼을 비정규화하는 것은 Record Lock으로 인해 제약이 발생할 수 있다. 왜냐하면 게시글과 좋아요 수 변경은 LifeCycle이 다르기 때문이다. LifeCycle이 다른 근거는 아래와 같다.(주체, 트래픽)

  1. 게시글은
    • 게시글을 작성한 용자가 쓰기 작업을 수행한다. 
    • 트래픽이 상대적으로 적다.
  2. 좋아요 수는
    • 게시글을 조회한 사용자들이 쓰기 작업을 수행한다.
    • 트래픽이 상대적으로 많다.

게시글과 좋아요 수는 서로 다른 주체에 의해 Record Lock이 잡힐 수 있다. 즉, 작성자에 의한 게시글 쓰기와 조회자에 의한 좋아요 수 쓰기는 사용자 입장에서 독립적으로 수행되는 기능이다. 그렇기 때문에, 서로 다른 두 주체가 서로의 쓰기 작업에 영향을 끼칠 수 있다. 

 

따라서, 게시글과 좋아요 수는 1:1 관계이지만 독립적인 테이블로 분리할 필요가 있다. 

 

2. MSA 구조에서 게시글 서비스에 좋아요 수 테이블 추가

msa 구조를 전제로 아래와 같은 사항을 고려하고 있다. 

  1. 각 서비스 별로 독립적인 데이터베이스를 구성한다.
  2. 샤딩이 고려된 분산 데이터베이스를 사용한다.
  3. 좋아요와 좋아요 수 데이터 일관성을 위해 관계형 데이터베이스의 트랜잭션을 고려하고 있다.

트랜잭션은 보통 단일 데이터베이스 내에서 안정적으로 빠르게 지원한다. 분산된 시스템에서 트랜잭션을 지원하려면 분산 트랜잭션을 고려해볼 수 있다. 하지만 분산 트랜잭션은 상대적으로 느리고 복잡할 수 있다. 만약 게시글 서비스의 데이터베이스에 좋아요 수 테이블을 관리한다면, 좋아요 서비스와 분리된 서비스가 되기 대문에 트랜잭션 관리가 복잡해진다. 게시글과 좋아요 수가 1:1 관계이지만 좋아요 서비스가 있기 때문에 좋아요 수를 게시글 서비스가 아닌 좋아요 서비스의 데이터베이스에서 관리하는 것이 바람직해 보인다. 

 

3. 좋아요 서비스의 데이터베이스에 좋아요 수 테이블 추가

좋아요 수 데이터는 어떻게 관리할 것인가? 

분산 시스템을 가정하고, 데이터의 적절한 분산을 위해 데이터베이스의 샤딩을 고려하고 있다. 만약 좋아요와 좋아요 수가 물리적으로 다른 샤드에 있다면 분산 트랜잭션이 필요해진다. 따라서, 좋아요 수 테이블의 샤드키는 좋아요 테이블과 동일한 데이터(article_id)로 해야 한다. 

 

이렇게 설계하면, 좋아요 생성/삭제와 좋아요 수 갱신을 하나의 트랜잭션으로 묶어서 처리할수 있다. 

    @Transactional
    public void like(Long articleId, Long userId){
        ArticleLike articleLike = ArticleLike.create(snowflake.nextId(), articleId, userId);
        articleLikeRepository.save(articleLike);
        int result = articleLikeCountRepository.increase(articleId);
        if(result == 0){
            ArticleLikeCount articleLikeCount = ArticleLikeCount.init(articleId, 1L);
            articleLikeCountRepository.save(articleLikeCount);
        }
    }
    
    @Transactional
    public void unlike(Long articleId, Long userId){
        articleLikeRepository.findByArticleIdAndUserId(articleId, userId)
                .ifPresent(articleLike -> {
                    articleLikeRepository.delete(articleLike);
                    articleLikeCountRepository.decrease(articleId);
                });
    }

 


동시성 문제

좋아요와 좋아요 수를 하나의 트랜잭션에서 관리하도록 했다. 하지만 높은 쓰기 트래픽이 들어올 수 있는 상황에서는 동시성 이슈 또한 고려해야 한다. 여러 개의 요청이 1개의 좋아요 수 레코드를 동시에 수정하는 상황이 발생한다면 데이터가 유실 또는 중복될 수 있기 때문이다. 동시성 문제를 제어하기 위한 방법으로 비관적 락, 낙관적 락, 비동기 순처 처리를 고려해볼 수 있다. 

 

 

1. 비관적 락(Pessimistic Lock)

  • 데이터 접근 시 항상 충돌이 발생할 가능성이 있다고 가정한다 (비관적 관점)
  • 데이터 보호를 위해 항상 락을 걸어 다른 트랜잭션 접근 방지
    • 다른 트랜잭션은 락이 해제되기까지 대기
    • 락을 오래 점유하고 있으며, 성능 저하 또는 deadLock 등으로 인한 장애 문제가 있을 수 있다. 

 

  • 구현방법 1
    • 데이터베이스에 저장된 데이터를 기준으로 update문 수행
    • update 문 수행 시점에 락을 점유
    • 락 점유하는 시간이 상대적으로 짧다.
transaction start;

-- 데이터 삽입
INSERT INTO article_like 
VALUES(...);

-- 좋아요 수 데이터 갱신
-- Pessimistic Lock 점유
update article_like_count 
set like_count = like_count + 1 
where article_id = {articleId};

-- Pessimistic Lock 해제
commit;

 

 

  • 구현방법 2
    • for update 구문으로 조회 결과에 대해 락을 점유하겠다고 명시
    • 트랜잭션에 조회된 데이터를 기준으로 update문 수행
    • 데이터 조회 시점부터 락을 점유
    • 락 점유하는 시간이 상대적으로 길다.
    • 데이터 조회한 뒤 중간 과정을 수행해야 하기 때문에, 락 해제가 지연될 수 있다. 
transaction start;

-- 데이터 삽입
INSERT INTO article_like VALUES(...);

-- for update 구문으로 데이터 조회
-- 조회된 데이터에 대해서 Pessimistic Lock 점유
select * 
from article_like_count 
where article_id = {articleId} 
for update;

update article_like_count 
set like_count = {updatedLikeCount} 
where article_id = {articleId}

-- pessimistic Lock 해제
commit;

 

 

2. 낙관적 락(Optimistic Lock)

  • 데이터 접근 시 충돌 발생할 가능성이 없다고 가정(낙관적 관점)
  • version 컬럼을 추가하여 데이터 변경 여부 추적
    • 각 트랜잭션에서 version을 함께 조회
    • where 조건에 조회된 version을 넣고, version을 증가
    • 데이터 변경이 성공하면 충돌이 없음
    • 데이터 변경이 실패하면 충돌 (다른 트랜잭션에서 version을 이미 증가시켰음을 의미)
  • 데이터 변경 여부를 확인하여 충돌을 처리
    • 데이터가 다른 트랜잭션에 의해 수정되었는지 확인
    • 수정된 내역이 있으면 후처리 (rollback 또는 재처리)

 

3. 비동기 순차 처리

  • 모든 상황을 실시간으로 처리하고 즉시 응답해줄 필요는 없다는 관점
    • 요청을 대기열에 저장해두고, 이후 비동기로 순차적으로 처리할 수 있다. 
    • 게시글마다 1개의 스레드에서 순차적으로 처리하면, 동시성 문제도 해결할 수 있다. 
    • 락으로 인한 지연이나 실패 케이스가 최소화된다. 즉시 처리되지 않기 때문에 사용자 입장에서는 지연될 수 있다. 
  • 큰 비용이 든다.
    • 비동기 처리를 위한 시스템 구축 비용
    • 실시간으로 결과 응답이 안되기 때문에 클라이언트 측 추가 처리가 필요
      • 이미 처리된 것처럼 보이게 하고, 실패 시 알림을 주는 방식 등 추가 작업이 필요
    • 서비스 정책으로 납득이 되어야 한다.
    • 데이터의 일관성 관리를 위한 비용
      • 대기열에서 중복/누락없이 반드시 1회 실행 보장되기 위한 시스템 구축이 필요하다. 

 

4. 비관적 락을 선택하기까지의 과정

  • 비동기 순차처리 배제
    • 좋아요 쓰기 트래픽이 이를 고려할 만큼 크지 않을 것으로 판단.
  • 비관적 락 VS 낙관적 락
    • 비관적 락은 락을 명시적으로 잡아야 하지만, 게시글 단위로 좋아요가 처리되기 때문에, 좋아요 쓰기 트래픽에서는 단일 레코드에 대한 잠깐의 락은 문제되지 않을 수 있다. 
    • 낙관적 락은 락을 잡지 않기 때문에 지연은 낮을 수 있지만, 애플리케이션에서 충돌 감지 시 추가적인 처리가 필요하다. 
    • 가장 간단한 방법으로, 비관적 락의 방법 1을 채택

 

 

 


REFERENCE

 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8%EB%A1%9C-%EB%8C%80%EA%B7%9C%EB%AA%A8-%EC%8B%9C%EC%8A%A4%ED%85%9C%EC%84%A4%EA%B3%84-%EA%B2%8C%EC%8B%9C%ED%8C%90

 

스프링부트로 직접 만들면서 배우는 대규모 시스템 설계 - 게시판| 쿠케 - 인프런 강의

현재 평점 4.9점 수강생 1,164명인 강의를 만나보세요. 대규모 데이터와 트래픽을 지탱하기 위한 시스템을, 스프링부트로 직접 만들면서 배워봅니다. 대규모 시스템 디자인, Microservice Architecture, Ev

www.inflearn.com