[1탄] 좋아요를 누를때마다 DB를 찌르고 있었다
- [1탄] 좋아요를 누를때마다 DB를 찌르고 있었다
- [2탄] 좋아요 수, 굳이 따로 집계해야 할까
개요
좋아요는 post_like(post_id, member_id) 테이블로 따로 관리했다.
처음에는 그냥 단순하게 갔다.
- 게시글 목록 조회하고
- 각 게시글마다 좋아요 수를
countByPostId(postId)로 구해서 내려주기
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 게시글 좋아요 조회
* @param memberId ControllerLayer의 SecurityContext에서 가져온 로그인한 회원의 ID
* @param postId 조회할 게시글의 ID
* @return
*/
public PostLikeResponse findPostLikes(Long memberId, Long postId) {
// 이 부분 수정 예정
Long likeCount = postLikeRepository.countByPostId(postId);
boolean likedByMe = memberId != null
&& postLikeRepository.existsByMemberIdAndPostId(memberId, postId);
return PostLikeResponse.from(postId, likeCount, likedByMe);
}
문제 발견
문제는 메인화면에서 게시글 조회였다.
메인화면에서는 게시글을 9개씩 보여준다.
즉 메인화면에서 게시글과 좋아요 횟수를 조회하기 위해 쿼리가 나가는 횟수를 계산해보면
- 게시글 목록 조회 1번
- 좋아요: 게시글 9개 × (count + exists) = 18
총 19개의 쿼리가 나간다.
테스트 코드로 확인해보니, 실제로 목록 한 번 조회할 때 쿼리가 20번 가까이 나가고 있었다.
쿼리가 20번 가까이 나가는 것을 확인하고 아래와 같은 생각이 들었다.
만약 요구사항이 변경되어 메인화면에서 보여주는 게시글이 9개가 아니라 20개면? 현재는 내가 만들고 혼자 사용할 예정이지만, 추후에 다른 사용자들도 사용하게 되어 트래픽이 몰리면?
구조적으로 봤을 때 이 방식은 게시글 개수에 비례해서 쿼리가 선형적으로 증가한다.
즉, 화면 요구사항이 바뀌는 순간 성능의 문제가 생길게 분명했다.
해결 방법
며칠 간 머리를 싸매고 내가 선택할 수 있는 최선의 방안들을 추려봤다.
IN 쿼리와 GROUP BY로 한 번에 가져오기
가장 먼저 떠오른 건 정석적인 SQL 튜닝이였다. 게시글마다 하나씩 호출하던 countByPostId를 사용하지 않고, 게시글 ID 리스트를 통째로 넘겨서 그룹핑하는 방식이다.
장점
- 인프라 추가 없이 쿼리 수를 1+N에서 1+1 수준으로 획기적으로 줄일 수 있다.
- 구현이 직관적이고 데이터 정합성 걱정이 거의 없다.
단점
- 쿼리 수는 줄지만, DB가 매번 집계 연산을 수행한다는 본질적인 문제는 남는다.
- 트래픽이 몰리면 결국 DB의 CPU 점유율이 올라갈 수 밖에 없는 구조라고 생각했다.
게시글 테이블에 like_count 컬럼 추가(비정규화)
조회 성능만 생각한다면 사실 이 방법이 가장 강력하다.
Post 테이블에 아예 좋아요 갯수 컬럼을 만들고 좋아요가 눌릴 때 마다 업데이트하는 방식이다.
장점
- 조회 시 별도의 연산이 필요 없어 속도가 압도적으로 빠르다.
단점
- post_like 테이블과 Post 테이블 간의 데이터 정합성을 맞추는 게 까다롭다.
- 동시성 이슈(여러 명이 동시에 좋아요를 누를 때) 해결을 위해 Lock을 걸다 보면 오히려 성능이 안좋아질 수 있다.
Redis로 카운트 캐싱하기
마지막으로 고민한 건 관계 데이터(누가 눌렀는지)는 DB,
카운트 데이터(몇개인지)는 Redis에 나누어 저장하는 방식이었다.
장점
- 가장 비싼 작업인 집계 연산을 메모리 기반인 Redis가 처리하므로 DB 부하를 크게 낮출 수 있다.
- 확장성이 좋고, 트래픽이 튀는 상황에서도 안정적인 응답 속도를 보장한다.
단점
- Redis라는 새로운 인프라를 관리해야 하는 비용이 발생한다.
- DB와 Redis의 상태를 일치시키기 위한 추가적인 설계 (이벤트 처리 등)가 필요하다.
내가 선택한 방법은? → Redis로 카운트 캐싱하기
세가지 방법을 놓고 고민하면서 내가 생각한 결론은 두가지였다.
- 목록 조회는 트래픽이 가장 많이 몰리는 구간이고,
- 그 구간에서 DB가 계속 count 집계를 반복하는 구조는 언젠가 병목이 생길 수 있다.
이 결론을 가지고 나는 Redis를 사용하여 책임을 나누기로 결정했다.
관계 데이터는 DB, 카운트 데이터는 Redis
Redis 적용
결정을 완료했고, Redis를 적용해봤다.
내가 Redis에 저장하려는 값은 “좋아요 관계”가 아니라 “좋아요 개수”이기 때문에 게시글 별 카운트를 Redis에
저장하는 키를 잡았다.
post:like:count:{postId}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 게시글 좋아요 생성
*/
@Transactional
public PostLikeResponse likePost(Long postId, Long memberId) {
try {
postLikeRepository.save(PostLike.createPostLike(postId, memberId));
} catch (DataIntegrityViolationException e) {
throw new DuplicatePostLikeException("이미 좋아요를 누른 게시글입니다.");
}
Long likeCount = redisTemplate.opsForValue().increment(getLikeCountKey(postId));
return PostLikeResponse.from(postId, likeCount, true);
}
private String getLikeCountKey(Long postId){
return KEY_PREFIX + postId;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public PostLikeResponse findPostLikes(Long memberId, Long postId) {
String key = getLikeCountKey(postId);
String cachedCount = redisTemplate.opsForValue().get(key);
Long likeCount;
// Redis에 데이터 없는 경우 DB 조회 후 Redis에 저장
if (hasText(cachedCount)) {
likeCount = Long.parseLong(cachedCount);
} else {
likeCount = postLikeRepository.countByPostId(postId);
redisTemplate.opsForValue().set(key, String.valueOf(likeCount));
}
boolean likedByMe = memberId != null
&& postLikeRepository.existsByMemberIdAndPostId(memberId, postId);
return PostLikeResponse.from(postId, likeCount, likedByMe);
}
이렇게 하면 메인 목록 조회에서 DB count를 매번 치지 않아도 된다. (최소한 “목록 조회가 들어올 때마다 DB가 집계를 반복한다”는 구조는 끊어낼 수 있다)
Redis 적용 후 테스트를 돌려보니..DB와 Redis의 정합성 문제가 생겼다
Redis 적용 후 테스트 코드를 작성하다가 아래와 같은 궁금점이 생겼다.
서비스 로직이 중간에 실패하면 Redis 카운트는 어떻게 되지?
그래서 테스트에서 일부러 DB 저장이 실패하도록 처리했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Test
@DisplayName("DB 저장에 실패하면 Redis 카운트는 별개로 올라간다")
void DB_저장에_실패하면_Redis_카운트는_별개로_올라간다(){
// given
Long postId = 1L;
Long memberId = 100L;
String key = KEY_PREFIX + postId;
redisTemplate.opsForValue().set(key, "0");
postLikeService.likePost(memberId,postId);
// when
assertThatThrownBy(() -> postLikeService.likePost(memberId, postId))
.isInstanceOf(DuplicatePostLikeException.class);
// then: DB에는 반영되지 않음(1이어야 함)
assertThat(postLikeRepository.countByPostId(postId)).isEqualTo(1L);
// then: Redis는 이미 올라감(2이어야 함)
assertThat(redisTemplate.opsForValue().get(key)).isEqualTo(2L);
}
테스트 결과는 예상대로였다.
- DB 저장은 실패해서 롤백됐는데
- Redis 카운트는 이미 증가해버렸다.
DB에는 좋아요 관계가 없는데, Redis에는 카운트가 올라간 상황이 발생할 수 도 있다.
내가 선택한 방법은 “커밋 이후에 이벤트 처리” 였다.
- DB 트랜잭션이 커밋된 이후에만 Redis 카운트를 업데이트한다.
- 트랜잭션이 롤백되면 이벤트가 실행되지 않으니 Redis도 안 바뀐다.
DB 저장이 성공하면 이벤트를 발행한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/**
* 게시글 좋아요 생성
*/
@Transactional
public PostLikeResponse likePost(Long memberId, Long postId) {
try {
postLikeRepository.save(PostLike.createPostLike(postId, memberId));
} catch (DataIntegrityViolationException e) {
throw new DuplicatePostLikeException("이미 좋아요를 누른 게시글입니다.");
}
eventPublisher.publishEvent(new PostLikeCountChangedEvent(postId, 1));
Long likeCount = postLikeRepository.countByPostId(postId);
return PostLikeResponse.from(postId, likeCount, true);
}
1
2
public record PostLikeCountChangedEvent(Long postId, int delta) {
}
AFTER_COMMIT에서 Redis 카운트를 업데이트 한다
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
@RequiredArgsConstructor
public class PostLikeCountRedisUpdater {
private final RedisTemplate redisTemplate;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void on(PostLikeCountChangedEvent event){
String key = PostLikeRedisKeys.postLikeCount(event.postId());
if(event.delta() > 0){
redisTemplate.opsForValue().increment(key, event.delta());
}else{
redisTemplate.opsForValue().decrement(key, -event.delta());
}
}
}
- DB가 성공적으로 커밋되면 → 이벤트가 실행되고 → Redis 카운트도 올라간다.
- DB가 실패해서 롤백되면 → 이벤트가 실행되지 않고 → Redis도 그대로다.
회고
Redis를 붙이면 깔끔하게 정리될줄 알았다.
그런데 DB는 롤백됐는데 Redis는 올라가 있는걸 보고, 내가 해결했다고 생각한 문제가
사실은 다른 문제를 만들고 있었다는걸 알았다.
이 문제를 겪어보니 그때부터 고민의 기준이 조금 달라진거같다.
전에는 해결 방법이 맞는지만 봤다면, 이제는 그 선택이 어떤 상황에서 깨질 수 있는지를 먼저 생각하게 된다.
이제는 기술을 볼 때 “좋아 보이네”에서 끝나지 않고, “이건 어디서 틀어질 수 있을까?”를 먼저 떠올리게 될 것 같다.
좋아요 기능 하나였지만 생각보다 많은걸 배운거 같다.