포스트

[2탄] 좋아요 수, 굳이 따로 집계해야 할까

[2탄] 좋아요 수, 굳이 따로 집계해야 할까
시리즈 [블로그 프로젝트] 좋아요 수 성능 최적화 2편
  1. [1탄] 좋아요를 누를때마다 DB를 찌르고 있었다
  2. [2탄] 좋아요 수, 굳이 따로 집계해야 할까

Redis로 해결했다고 생각했는데, 요구사항 하나가 설계를 뒤집었다

이전 포스팅에서 좋아요 N+1 문제를 Redis로 해결한 과정을 정리했다.

해당 과정의 결론은 이거였다.

관계 데이터(누가 눌렀는지)는 DB, 카운트 데이터(몇 개인지)는 Redis

  • postLikeCountReader 인터페이스로 읽기 전략을 추상화
  • Redis 캐시 미스 시 DB로 폴백하는 read-through 패턴 구현
  • AFTER_COMMIT 이벤트로 DB 롤백 시 Redis도 동기화 되는 문제 해결

위와 같이 구현했고, 나름 잘 구현했다고 생각했다.

그런데 요구사항 하나가 추가됐다.

좋아요 순 정렬

이 기능 하나로 이전에 했던 설계 전체를 다시 생각하게 됐다.

ORDER BY는 DB 컬럼만 볼 수 있다

Redis는 인메모리 데이터 구조다. DB의 ORDER BY 절은 Redis를 참조할 수 없다.

그렇다면 좋아요순 정렬을 구현하려면 어떻게 해야 할까? 결국 이런 쿼리가 된다.

1
2
3
4
5
SELECT * FROM post p
ORDER BY (
    SELECT COUNT(*) FROM post_like WHERE post_id = p.id
) DESC
LIMIT 9

게시글 9개를 정렬해서 가져오기 위해 COUNT 서브쿼리가 9번 실행된다.

인덱스를 아무리 잘 잡아도 집계 연산 자체를 줄일 수는 없다.

Redis를 도입한 이유가 바로 이 집계 부하를 피하기 위해서였는데, 정렬 요구사항이 추가됨에 따라

Redis를 사용한 목적이 사라졌다.

비정규화로 방향을 바꿨다

정렬 요구사항을 받아들이고 나니 선택지가 거의 없었다.

좋아요순 정렬은 결국 DB가 좋아요 수를 알고 있어야 한다.

Redis에 아무리 카운트를 들고 있어도, DB의 ORDER BY가 Redis를 참조할 수는 없다.

처음에는 “그럼 DB는 정렬만 하고, 카운트는 Redis에서 읽으면 되지 않나?” 라고 생각했다.

그런데 그렇게 하면 구조가 이렇게 된다.

  • 정렬을 위해 DB에 likeCount 컬럼이 필요함
  • 화면 응답을 위해 Redis에서도 likeCount를 유지함

즉, DB에도 likeCount가 있고 Redis에도 likeCount가 있는 상태가 된다.

이건 단순히 “저장소가 하나 더 늘었다” 수준이 아니었다.

두 저장소가 같은 숫자를 각자 최신으로 유지해야 하는 구조가 된다.

정리하면 이런 문제가 생긴다.

  • DB 반영 타이밍과 Redis 반영 타이밍이 어긋나는 순간, 숫자가 다르게 보일 수 있다.
  • Redis 장애/초기화가 발생하면 결국 DB로 폴백하게 되고, 목록 조회에서는 COUNT 쿼리가 한꺼번에 터질 수 있다.
  • 그리고 무엇보다, “좋아요 수”라는 같은 데이터를 두 군데에서 관리하는 순간부터 운영 포인트가 계속 생긴다.

이전에 Redis를 선택할 때는 이 트레이드오프들이 감당할 만하다고 봤다.

그런데 정렬 요구사항이 생긴 뒤로는, Redis가 해주는 것보다 관리해야 하는 것이 더 많아졌다고 생각이 들었다.

그래서 비정규화를 하는 방식으로 방향을 바꿨다.

좋아요 수는 Post 테이블 컬럼으로 유지한다.

그리고 정렬도 그 컬럼을 그대로 사용한다.

1
2
// 좋아요순 정렬 - 서브쿼리 없이 컬럼 직접 참조
case LIKE_COUNT -> new OrderSpecifier<>(order, post.likeCount);

이 선택 덕분에 좋아요순 정렬은 단순해졌고, 인덱스 설계도 명확해졌다.

같은 트랜잭션에서 카운트를 갱신하면 DDD 원칙에 위배되는데..?

비정규화를 결정한 뒤, 가장 먼저 떠올린 구현은 아래와 같은 방식이였다.

1
2
3
4
5
6
7
8
9
10
@Transactional
public PostLikeResponse likePost(Long postId, Long memberId) {
    postLikeRepository.save(PostLike.createPostLike(postId, memberId));

    // PostLikeService가 Post 애그리거트를 직접 건드림
    Post post = postRepository.findById(postId).orElseThrow();
    post.increment(1);

    return PostLikeResponse.from(postId, post.getLikeCount(), true);
}

처음에는 이 코드가 꽤 괜찮아 보였다.

  • 한 트랜잭션 안에서 모든 작업이 끝난다.
  • 좋아요 저장과 카운트 증가가 함께 처리된다.
  • 흐름이 단순하고 이해하기 쉽다.

실제로 기능적으로도 아무 문제 없이 동작했다.

그런데 코드를 다시 보면서 한 가지가 계속 걸렸다.

PostLikeServicePost를 직접 수정하고 있다는 점이었다.

좋아요를 저장하는 서비스가, 게시글 애그리거트의 상태까지 함께 변경하고 있다.

좋아요 “행위”와 게시글 “상태 변경”이 하나의 서비스 안에 묶여 있는 구조다.

애그리거트 경계에 대한 고민

PostLike와 Post는 서로 다른 애그리거트라고 생각했다.

좋아요를 누르는 행위는 PostLike의 책임이고,

게시글은 게시글대로 또 하나의 일관성 경계를 가진다.

그런데 현재 구현을 보면 하나의 트랜잭션 안에서

  • PostLike를 저장하고
  • Post의 likeCount를 함께 수정하고 있다.

즉, 두 애그리거트를 동시에 변경하고 있는 구조다.

처음에는 크게 문제라고 느끼지 못했다.

어차피 좋아요를 누르면 카운트가 올라가는 건 당연하다고 생각했기 때문이다.

하지만 설계 관점에서 다시 보니 조금 이상했다.

좋아요 기능이 Post 애그리거트의 내부 구현에 의존하고 있는 형태였기 때문이다.

예를 들어,

  • 나중에 카운트를 Redis로 관리하게 되면?
  • 좋아요가 발생했을 때 알림 이벤트를 추가하게 되면?
  • 랭킹 점수 계산 방식이 바뀌게 되면?

좋아요라는 ‘행위’ 자체는 그대로인데,

그때마다 PostLikeService를 수정해야 하는 구조가 된다.

결국 좋아요 정책이 바뀔 때마다 서비스가 흔들리게 되는 셈이다.

애그리거트를 분리해두었는데, 실제로는 강하게 묶여 있는 구조였다는 점이 계속 마음에 걸렸다.

그래서 “이게 정말 분리된 설계가 맞는가?”라는 고민을 하게 됐다.

그래서 이벤트로 분리했다

좋아요는 좋아요만 하게 만들었다.

1
2
3
4
5
6
7
8
@Transactional
public PostLikeResponse likePost(Long memberId, Long postId) {
    postLikeRepository.save(PostLike.createPostLike(postId, memberId));
    eventPublisher.publishEvent(new PostLikeCountChangedEvent(postId, 1));

    long likeCount = postLikeRepository.countByPostId(postId);
    return PostLikeResponse.from(postId, likeCount, true);
}

카운트 증감은 이벤트 리스너에서 처리하게 변경하였다.

1
2
3
4
5
6
7
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void on(PostLikeCountChangedEvent event) {
    Post post = postRepository.findById(event.postId())
            .orElseThrow();
    post.increment(event.delta());
}

이렇게 하니 좋아요를 누르는 서비스는 카운터가 어디에 반영되는지 모른다.

그냥 “좋아요가 발생했다”는 사실만 알린다. 이게 더 자연스럽다고 느껴졌다.

AFTER_COMMIT을 한 이유

좋아요 저장이 실패했는데 카운터만 증가하는 상황을 고려하여 AFTER_COMMIT을 사용했다.

AFTER_COMMIT이면, DB 트랜잭션이 성공했을 때만 리스너가 실행된다.

롤백되면 이벤트도 실행되지 않는다.

이건 Redis 정합성 문제를 다루면서 이미 한 번 써본 패턴이었고, 여기에도 그대로 적용했다.

REQUIRES_NEW를 사용한 이유

AFTER_COMMIT 시점에는 기존 트랜잭션이 끝난 상태다.

Post.likeCount를 수정(변경감지)하려면 새로운 트랜잭션이 필요하다.

그래서 REQUIRES_NEW를 붙여서 독립 트랜잭션으로 처리했다.

덕분에 PostLike와 Post는 같은 트랜잭션에 묶이지 않고 애그리거트 경계도 지킬 수 있었다.

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.