“아프리카TV처럼” 충돌 없는 댓글 시스템 설계 + 오픈소스 구현 (요약: 자바 편)

궁금하면 500원·2025년 6월 3일
0

미생의 개발 이야기

목록 보기
45/58

대기업 아키텍처 엿보기

구)아프리카 현) SOOP의 “충돌 없는” 댓글 시스템, 오픈소스로 재현하기

고성능 댓글 시스템이 필요할 때, 우리는 흔히 Redis 캐싱, Kafka 비동기 처리, 그리고 DB 인덱싱 같은 기술을 떠올립니다.
하지만 이것들을 어떻게 설계 수준에서 결합하느냐에 따라 아키텍처의 성능과 안정성은 완전히 달라집니다.

이번 포스팅에서는 아프리카Tv 의 실전 댓글 시스템 아키텍처를 분석하고, 오픈소스 기술 스택으로 구현하는 방법까지 정리 합니다.
단순히 따라만 하는 것이 아니라, 왜 그렇게 설계했는지도 같이 봅니다.

1. 실전에서 마주치는 댓글 시스템의 병목

대규모 트래픽 환경에서 댓글 시스템은 아래 두 가지 문제에 직면하였습니다.

🧨 1. 쿼리 성능 병목

인기순, 최신순 댓글 정렬은 결국 DB에 부담을 줍니다.

인기 게시글 하나에 댓글 수천 개가 달리면, 매 요청마다 정렬/필터링하는 쿼리는 DB를 죽입니다.

🧨 2. 단일 장애 지점(SPOF)

모든 댓글이 하나의 MySQL에 몰려 있으면, 그 DB 하나가 죽는 순간 서비스도 같이 멈춥니다.

2. SOOP의 해결책 요약

✅ 원본 데이터는 TiDB/MySQL에 저장
✅ 인기/최신 댓글 인덱스는 Redis Sorted Set
✅ 변경 사항은 Kafka로 전달, Redis 실시간 반영
✅ Redis 장애 시에도 최대한 대응 가능한 헷지 다운그레이드 전략

3. 아키텍처 상세 구성

3.1 인덱스 + KV 모델

  • 댓글 본문: TiDB/MySQL
  • 댓글 인덱스: Redis Sorted Set
// Redis 인덱스 갱신 예시 (인기순 정렬)
redisTemplate.opsForZSet().add("post:123:popular", commentId, likeCount);
  • 인기순 댓글 10개 조회
Set<String> ids = redisTemplate.opsForZSet()
    .reverseRange("post:123:popular", 0, 9);

// 이후 댓글 본문은 별도 조회 or 캐싱된 Map에서 병합

3.2 실시간 동기화 (TiDB → Kafka → Redis)

  • TiCDC / Debezium: MySQL/TiDB의 Binlog를 캡처
  • Kafka: 변경 이벤트 전달
  • Spring Boot Consumer: Kafka 메시지 수신 후 Redis 갱신
# application.yml 일부
spring:
  kafka:
    consumer:
      bootstrap-servers: localhost:9092
      group-id: comment-sync
@KafkaListener(topics = "comment-events", groupId = "comment-sync")
public void onCommentChanged(String message) {
    CommentEvent event = objectMapper.readValue(message, CommentEvent.class);

    // Redis 인덱스 갱신
    if (event.getType().equals("LIKE_UPDATED")) {
        redisTemplate.opsForZSet().add(
            "post:" + event.getPostId() + ":popular",
            event.getCommentId(),
            event.getLikeCount()
        );
    }
}

3.3 낙관적 잠금 및 데이터 정합성 보장

  • 댓글 테이블에 version 컬럼 추가 (낙관적 락)
ALTER TABLE comment ADD COLUMN version INT DEFAULT 0;
  • 업데이트 시 version 체크
@Modifying
@Query("UPDATE Comment c SET c.likeCount = :likeCount, c.version = c.version + 1 " +
       "WHERE c.id = :id AND c.version = :version")
int updateLikeCount(@Param("id") Long id,
                    @Param("likeCount") int likeCount,
                    @Param("version") int version);
  • 주기적 정합성 검증 (예: XXL-Job, Quartz 사용)
// Redis와 DB의 좋아요 수가 불일치할 경우 정정
void reconcileData() {
    for (String postId : allPostIds()) {
        Set<ZSetOperations.TypedTuple<String>> topComments =
            redisTemplate.opsForZSet().reverseRangeWithScores("post:" + postId + ":popular", 0, 100);
        
        for (var tuple : topComments) {
            Long commentId = Long.valueOf(tuple.getValue());
            Integer likeInRedis = tuple.getScore().intValue();
            Integer likeInDB = commentRepository.findLikeCountById(commentId);

            if (!Objects.equals(likeInRedis, likeInDB)) {
                redisTemplate.opsForZSet().add("post:" + postId + ":popular", String.valueOf(commentId), likeInDB);
            }
        }
    }
}

4. 시스템 보호 전략을 위한 헷지 다운그레이드(Hedge Downgrade)

  • DB가 느릴 때, Redis 캐시만 우선 응답 합니다
  • Redis도 없으면 “현재 댓글 조회가 어렵습니다” 메시지 반환 합니다

5. 구축 기술 스택

구성요소기술
기본 스토리지TiDB / MySQL
캐싱 및 인덱스Redis (Sorted Set)
데이터 동기화TiCDC / Debezium + Kafka
API 서버Spring Boot
정합성 조정XXL-Job / Quartz
인프라Docker / Kubernetes 추천

6. 구축 가이드 (코틀린 버전 참조)

  • TiDB/MySQL + Redis 설치 (Docker)
  • 댓글 테이블 설계 (version 포함)
  • Spring Boot API 구현 (RESTful)
  • TiCDC or Debezium + Kafka 설치 및 설정
  • Kafka Consumer로 Redis 실시간 갱신 로직 작성
  • 정기적 검증 Job 구현 (XXL-Job 등)
  • 장애 대응 로직 구현 (Fallback, 캐시만 응답)

마무리

실전에서 진짜 중요한 건 "분산 전략"

이 아키텍처의 핵심은 단순히 기술을 나열한 것이 아니라고 생각합니다.
각 계층의 역할을 분리하고, 장애를 고려해 설계하며,
데이터 일관성을 실제 운영 환경에서 유지하는 방식을 다루는 것이라는것을 배웠습니다.

댓글 시스템 하나에도 수많은 트래픽과 장애 가능성이 숨어 있습니다.
단순히 빠르기만 한 것이 아니라, “어떤 상황에서도 멈추지 않는” 구조를 목표로 해야 합니다.

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글