프로젝트 개요
| 항목 | 내용 |
|---|---|
| 목표 | 대규모 트래픽을 처리하는 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 서비스 메시
추가 학습 방향
- Service Mesh (Istio)
- API Gateway (Spring Cloud Gateway)
- Observability (Prometheus, Grafana, Jaeger)
- Kubernetes
- Event Sourcing
- Saga 패턴
Start the conversation