대규모 시스템 설계

프로젝트 개요

항목 내용
목표 대규모 트래픽을 처리하는 MSA 기반 게시판
핵심 학습 분산 시스템 설계, 이벤트 기반 아키텍처, 캐시 최적화, 트랜잭션 관리

주요 기능

  • 게시글 CRUD
  • 계층형 댓글 (무한 대댓글)
  • 좋아요 / 조회수 통계
  • 인기 게시글 랭킹
  • 실시간 캐시 기반 조회 최적화

서비스별 역할

서비스 역할 DB 특징
Article 게시글 CUD MySQL Command, 이벤트 발행
Comment 댓글 CUD MySQL Path 기반 계층형 댓글
View 조회수 Redis + MySQL 분산 락, 배치 백업
Like 좋아요 MySQL 통계 집계
Article-Read 게시글 조회 Redis CQRS 읽기, 캐시
Hot-Article 인기글 랭킹 Redis 실시간 스코어

핵심 학습

1. CQRS

Command(쓰기)와 Query(읽기)를 분리해 각각에 맞는 저장소를 사용합니다.

  • Command: Article, Comment, View, Like → MySQL 저장 + 이벤트 발행
  • Query: Article-Read, Hot-Article → Redis 캐시 + 이벤트 구독으로 동기화
// Command: 게시글 생성 후 이벤트 발행
@Transactional
public ArticleResponse create(ArticleCreateRequest request) {
    Article article = articleRepository.save(...);
    outboxEventPublisher.publish(
        EventType.ARTICLE_CREATED,
        ArticleCreatedEventPayload.builder()...build(),
        article.getBoardId()
    );
    return ArticleResponse.from(article);
}

// Query: 이벤트 구독 후 캐시 갱신
@Override
public void handle(Event<ArticleCreatedEventPayload> event) {
    ArticleQueryModel model = ArticleQueryModel.create(event.getPayload());
    articleQueryModelRepository.create(model, Duration.ofDays(1));
}

배운 점: 읽기/쓰기를 독립적으로 확장할 수 있고, 이벤트 기반으로 서비스 간 일관성을 유지한다.


2. Transactional Outbox

DB 트랜잭션과 Kafka 메시지 발행의 원자성을 보장합니다.

문제: MySQL은 성공했는데 Kafka 발행이 실패하면 데이터 불일치가 생김.

해결:

// BEFORE_COMMIT: Outbox 테이블에 저장 (트랜잭션에 포함)
@TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT)
public void createOutbox(OutboxEvent outboxEvent) {
    outboxRepository.save(outboxEvent.getOutbox());
}

// AFTER_COMMIT: 커밋 성공 후에만 Kafka 발행
@Async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void publishEvent(OutboxEvent outboxEvent) {
    kafkaTemplate.send(...);
    outboxRepository.delete(outbox);
}

// 스케줄러: 미처리 Outbox 재발행
@Scheduled(fixedDelay = 10, timeUnit = TimeUnit.SECONDS)
public void publishPendingEvent() { ... }

배운 점: @TransactionalEventListener, Redis 샤드 기반 작업 분배, 스케줄링 재처리.


3. 캐시 최적화 (OptimizedCache)

논리적 TTL / 물리적 TTL 분리와 분산 락으로 Cache Stamped를 막습니다.

문제: TTL 만료 시 요청이 한꺼번에 원본 DB로 몰림.

해결:

public Object process(String type, long ttlSeconds, Object[] args,
                     OptimizedCacheOriginDataSupplier<?> supplier) {
    String key = generateKey(type, args);
    String cachedData = redisTemplate.opsForValue().get(key);

    if (cachedData == null) {
        return refresh(supplier, key, ttlSeconds);
    }

    OptimizedCache cache = deserialize(cachedData);

    if (!cache.isExpired()) {
        return cache.parseData(returnType); // Cache Hit
    }

    // 논리적 만료 → 분산 락으로 1개만 원본 조회
    if (!lockProvider.lock(key)) {
        return cache.parseData(returnType); // Stale-While-Revalidate
    }

    try {
        return refresh(supplier, key, ttlSeconds);
    } finally {
        lockProvider.unlock(key);
    }
}
개념 의미
논리적 TTL 데이터가 유효하다고 보는 기간 (예: 1초)
물리적 TTL Redis에서 실제 삭제되는 시점 (논리 TTL + 여유)
Stale-While-Revalidate 만료된 캐시라도 즉시 응답

효과: 원본 API 호출 95–99% 감소, 동시 요청 중 1개만 원본 접근.


4. 이벤트 기반 아키텍처

Kafka로 서비스 간 느슨한 결합을 만듭니다.

게시글 생성 (Article)
    → Outbox로 이벤트 발행
    → Kafka
    → Article-Read: 캐시 갱신
    → Hot-Article: 스코어 업데이트
// 발행
outboxEventPublisher.publish(
    EventType.ARTICLE_CREATED,
    ArticleCreatedEventPayload.builder()
        .articleId(article.getArticleId())
        .title(article.getTitle())
        .build(),
    article.getBoardId() // 샤드 키 → 순서 보장
);

// 구독
@KafkaListener(topics = "article-created")
public void listen(String message) {
    Event<EventPayload> event = Event.fromJson(message);
    articleReadService.handleEvent(event);
}

배운 점: 서비스 독립 확장, 샤드 키로 동일 게시판 이벤트 순서 보장, 실시간 동기화.


5. 분산 락

Redis SETNX로 동시성 문제를 해결합니다.

사용처: 조회수 중복 카운팅 방지, 캐시 갱신 동시성 제어

public boolean lock(String key) {
    return redisTemplate.opsForValue().setIfAbsent(
        generateLockKey(key),
        "",
        Duration.ofSeconds(3) // TTL로 데드락 방지
    );
}

public Long increase(Long articleId, Long userId) {
    String lockKey = generateLockKey(articleId, userId);

    if (!distributedLockRepository.lock(lockKey, TTL)) {
        return articleViewCountRepository.read(articleId);
    }

    try {
        return articleViewCountRepository.increase(articleId);
    } finally {
        distributedLockRepository.unlock(lockKey);
    }
}

배운 점: 원자적 락, TTL로 데드락 방지, 중복 작업 감소.


6. 계층형 댓글 (Path)

부모Path::자식ID로 무한 대댓글을 표현합니다.

public class CommentPath {
    private String path; // 예: "00001::00002"

    public CommentPath createChildCommentPath(String topDescendantPath) {
        if (topDescendantPath == null) {
            return new CommentPath(this.path + DELIMITER + generateNextId());
        }
        return new CommentPath(topDescendantPath + DELIMITER + generateNextId());
    }
}
SELECT * FROM comment
WHERE article_id = ?
  AND path LIKE '00001::%'
ORDER BY path ASC;

배운 점: 단일 컬럼으로 무한 깊이 표현, LIKE로 자식 조회, Path 정렬로 자연스러운 순서.


7. 인기 게시글 랭킹

가중치 스코어 + Redis Sorted Set으로 TOP N을 유지합니다.

// 스코어: like×3 + comment×2 + view×1
public long calculate(Long articleId) {
    return likeCount * 3 + commentCount * 2 + viewCount * 1;
}

// Sorted Set에 추가, TOP N만 유지
public void add(Long articleId, LocalDateTime time, Long score, Long limit, Duration ttl) {
    String key = generateKey(time); // "hot-article::list::20231215"

    redisTemplate.executePipelined((RedisCallback<?>) action -> {
        StringRedisConnection conn = (StringRedisConnection) action;
        conn.zAdd(key, score, String.valueOf(articleId));
        conn.zRemRange(key, 0, -limit - 1);
        conn.expire(key, ttl.toSeconds());
        return null;
    });
}

배운 점: 이벤트 기반 실시간 랭킹, Sorted Set 자동 정렬, TOP N으로 메모리 절약.


8. Redis Pipeline / 배치 조회

네트워크 라운드트립을 줄입니다.

// Pipeline: 여러 명령을 한 번에 전송
redisTemplate.executePipelined((RedisCallback<?>) action -> {
    StringRedisConnection conn = (StringRedisConnection) action;
    conn.zAdd(key, score, articleId);
    conn.zRemRange(key, 0, -limit - 1);
    conn.expire(key, ttl.toSeconds());
    return null;
});

// multiGet: 여러 키를 한 번에 조회
Map<Long, ArticleQueryModel> models = redisTemplate.opsForValue()
    .multiGet(keyList)
    .stream()
    .filter(Objects::nonNull)
    .map(json -> deserialize(json, ArticleQueryModel.class))
    .collect(toMap(ArticleQueryModel::getArticleId, identity()));

배운 점: RTT 감소, N+1 완화.


9. 커서 기반 페이징

OFFSET 대신 마지막 ID 기준 무한 스크롤을 사용합니다.

public List<Long> readAllInfiniteScroll(Long boardId, Long lastArticleId, Long limit) {
    return redisTemplate.opsForZSet()
        .reverseRangeByLex(
            generateKey(boardId),
            lastArticleId == null
                ? Range.unbounded()
                : Range.leftUnbounded(Range.Bound.exclusive(toPaddedString(lastArticleId))),
            Limit.limit().count(limit.intValue())
        )
        .stream()
        .map(Long::valueOf)
        .toList();
}

배운 점: OFFSET보다 빠름, 데이터 변경 중에도 결과 일관성 유지.


10. Redis + MySQL 이중 저장

조회수는 Redis에서 빠르게 올리고, 주기적으로 MySQL에 백업합니다.

public Long increase(Long articleId, Long userId) {
    Long count = articleViewCountRepository.increase(articleId); // Redis

    if (count % BACK_UP_BATCH_SIZE == 0) {
        articleViewCountBackUpProcessor.backUp(articleId, count); // MySQL
    }
    return count;
}

배운 점: Redis 성능 + MySQL 안정성, 배치 백업으로 균형.


기술 스택

영역 기술
Backend Java 21, Spring Boot 3.3.2, Spring Data JPA, Spring Kafka, Spring AOP
DB / Cache MySQL (Command), Redis (Query, 락, 랭킹)
Messaging Apache Kafka
ID Snowflake
Build / Test Gradle, JUnit 5

프로젝트 구조

MSA_Board/
├── common/
│   ├── snowflake/
│   ├── data-serializer/
│   ├── event/
│   └── outbox-message-relay/
└── service/
    ├── article/        # Command
    ├── comment/        # Command
    ├── view/           # Command
    ├── like/           # Command
    ├── article-read/   # Query
    └── hot-article/    # Query

달성한 것

영역 결과
성능 응답 시간 80–90% 감소, 원본 API 95–99% 감소, 캐시 히트율 95–99%
아키텍처 6개 MSA, CQRS, Kafka 이벤트 통신
일관성 Outbox, 이벤트 동기화, 분산 락
확장성 서비스별 수평 확장, 샤드 분배, 무상태 설계

주요 사항

가장 어려웠던 점 — Transactional Outbox
트랜잭션과 메시지 발행의 원자성, 분산 환경 중복 발행 방지, Redis 샤드 할당과 재처리 스케줄링.

가장 인상 깊었던 점 — OptimizedCache
논리/물리 TTL + 분산 락으로 원본 API 호출이 95–99% 줄어든 것을 직접 확인.

개선하고 싶은 점

  • Prometheus + Grafana 모니터링
  • ELK 중앙 로깅
  • Spring Cloud Gateway
  • Istio 서비스 메시

추가 학습 방향

  1. Service Mesh (Istio)
  2. API Gateway (Spring Cloud Gateway)
  3. Observability (Prometheus, Grafana, Jaeger)
  4. Kubernetes
  5. Event Sourcing
  6. Saga 패턴
대규모 시스템 설계

Start the conversation