실시간 피드 전파, 고빈도 상호작용 정합성, Redis·DB 장애 복구 경계 분리를 다룬 SNS 백엔드입니다.
모든 수치는 문서별 테스트 조건 기준 로컬 또는 검증 환경에서 측정했습니다.
| 항목 | Before | After |
|---|---|---|
| 피드 기본 조회 (중간 피드 테이블 없음) | 29,000ms | 45ms |
| 팔로워 1,000명 기준 피드 발행 | 1,341ms | 101ms |
10,000개 피드 key 순차 LPUSH |
8,563ms | 270ms |
| 좋아요 API 평균 응답 시간 | 500ms | 169ms |
| 좋아요 처리량 | 101.1/s | 315.5/s |
| 피드 API 평균 응답 시간 (Master 단독 → Slave 2대 + ProxySQL) | 319ms | 140ms |
| 피드 조회 처리량 (API 서버 1대 → 3대) | 34.3건/s | 79.1건/s |
처음에는 follow와 post를 직접 조회하는 구조에서 읽기 경로를 최대한 줄였습니다. Page → Slice, 인덱스 정비, @BatchSize, comment_count 컬럼 분리, post.user_name 반정규화로 피드 기본 조회를 29,000ms → 45ms까지 줄였지만, 조회 시점마다 팔로잉 관계와 게시글 조건을 다시 계산하는 구조 자체는 남아 있었습니다. 트래픽이 늘수록 이 계산 비용은 선형으로 따라 올라왔습니다.
그래서 게시글 발행 시점에 팔로워별 피드를 미리 만들어 두는 발행형 구조로 전환했습니다. 피드에는 본문이 아니라 postId만 저장하고, 사용자 피드는 feed:{userId} List, 팔로워 목록은 follower:{userId} Set, 활동 이력은 user_activity Sorted Set으로 역할을 나눴습니다.
핵심 의사결정은 세 가지였습니다. 서버별 Local Cache 대신 모든 인스턴스가 같은 피드를 바라보는 Redis 전역 캐시를 기준으로 잡았고, Sorted Set 대신 List를 택해 정렬 비용과 메모리를 줄였습니다. 팔로워별 순차 호출 대신 파이프라이닝으로 fan-out 비용을 묶었고, 게시글 저장 응답과 fan-out 완료는 @Async로 분리해 발행 응답이 fan-out 완료를 기다리지 않도록 했습니다.
- 팔로워 1,000명 기준 피드 발행: 1,341ms → 101ms
- 10,000개 피드 key 순차
LPUSH: 8,563ms → 270ms
→ 피드 읽기 경로를 중간 테이블 없이 다시 설계해 29초 쿼리를 45ms로 줄이기
→ 피드 읽기 경로를 Redis 전역 캐시 기반 발행형 구조로 바꾸고 fan-out 비용을 줄인 이유
피드 응답에는 성격이 다른 데이터가 함께 들어옵니다. 게시글 본문과 미디어는 발행 이후 거의 바뀌지 않지만, 좋아요 여부와 개수, 댓글 수는 사용자 행동에 따라 계속 변합니다. 이를 하나의 캐시로 묶으면 가변 데이터가 바뀔 때마다 안정적인 본문 캐시까지 무효화해야 하는 갱신 비용이 커집니다.
그래서 게시글 본문은 별도 캐시에서 읽고, 좋아요 여부와 카운터는 별도 계층에서 조회해 응답 단계에서 병합하도록 나눴습니다. 삭제된 게시글은 널 오브젝트 패턴으로 캐시해 삭제 이후에도 같은 postId로 DB를 반복 조회하는 캐시 관통을 막았고, like_count·comment_count는 조회 시점 집계 대신 컬럼 기반으로 읽도록 정리했습니다.
좋아요 카운터는 엔티티를 읽어 ++ 하는 방식 대신 명시적 UPDATE like_count = like_count + 1로 바꿨습니다. 같은 게시글에 100명이 동시에 요청하는 조건에서 조회 후 증가 방식은 12/100만 맞았고, 비관적 락은 정합성은 맞지만 조회 시점부터 잠금을 오래 잡는 문제가 있었습니다. 명시적 UPDATE는 카운터를 바꾸는 짧은 구간만 잠그기 때문에 같은 정합성을 더 가벼운 방식으로 확보할 수 있었습니다.
- 좋아요 API 평균 응답 시간: 500ms → 169ms
- 좋아요 처리량: 101.1/s → 315.5/s
- 좋아요 카운터 정합성: 조회 후 증가 12/100 → 명시적
UPDATE100/100
→ 피드 조회에서 게시글 캐시와 상호작용 데이터를 분리한 이유
좋아요는 Redis에 먼저 쌓고 주기적으로 MySQL에 병합하는 구조였습니다. 처음 병합 순서는 저장 후 삭제였는데, 이 순서에서는 MySQL 트랜잭션 안에서 Redis 삭제가 함께 묶여 Redis 통신 지연이 트랜잭션 장기화로 번졌습니다. 더 큰 문제는 병합 도중 들어온 새 좋아요가 마지막 DEL에 함께 지워질 수 있다는 점이었습니다. 병합 대상의 경계가 불분명했기 때문입니다.
순서를 삭제 후 저장으로 바꾸면 경계가 명확해집니다. 삭제 이전에 읽어 둔 데이터는 이번 병합 대상, 삭제 이후 들어온 데이터는 다음 경로로 자연스럽게 분리됩니다. MySQL 트랜잭션 안에서 Redis 삭제를 수행하지 않으므로 Redis 장애가 트랜잭션 장기화로 번지는 문제도 사라집니다. 대신 삭제는 했는데 MySQL 저장이 실패한 경우를 처리해야 했습니다. 삭제 전에 읽어 둔 데이터를 기준으로 저장 실패 시 Redis에 다시 적재하는 보상 복구를 넣어, 병합 실패가 바로 데이터 유실로 이어지지 않게 했습니다. 키 조회도 KEYS에서 SCAN으로 바꿔 병합 스케줄러가 Redis 전체를 오래 점유하지 않도록 했습니다.
→ 좋아요 병합에서 저장 후 삭제 대신 삭제 후 저장을 택한 이유
Redis를 읽기와 쓰기에 함께 쓰면서 장애 시 실패 비용의 성격이 다르다는 점이 먼저 보였습니다. 읽기 경로에서 Redis가 죽으면 응답이 느려지지만 DB fallback으로 결과는 돌려줄 수 있습니다. 반면 fan-out 쓰기는 그 순간 놓치면 피드 반영 자체가 유실되고, 나중에 Redis가 복구돼도 전파되지 않은 게시글은 끝내 피드에 나타나지 않습니다.
그래서 읽기 경로에는 @RetryCircuit(Retry 3회 + CircuitBreaker)과 DB fallback을, fan-out 쓰기 경로에는 @RedisRecovery와 RedisWorkQueue 기반 재처리를 분리해 적용했습니다. 쓰기 실패 시 작업을 큐에 보류했다가 Redis ping 응답과 서킷 브레이커 상태를 확인한 뒤 재처리하는 흐름은 Testcontainers 기반 통합 테스트로 확인했습니다. 다만 RedisWorkQueue는 애플리케이션 메모리 기반이라 프로세스가 재시작되면 큐에 쌓인 작업이 함께 사라지는 한계가 있고, 완전한 내구성이 필요하다면 영속 저장소로 옮겨야 합니다.
DB 라우팅도 같은 관점에서 정리했습니다. AbstractRoutingDataSource는 현재 Master가 누구인지, 장애 전환 뒤 라우팅을 어떻게 갱신할지를 애플리케이션이 계속 추적해야 하는 구조였습니다. 읽기/쓰기 라우팅은 ProxySQL로, Master 승격과 복제 토폴로지 재구성은 Orchestrator로 분리해 애플리케이션이 ProxySQL 엔드포인트만 바라보게 했습니다.
- 피드 API 평균 응답 시간: 319ms → 140ms
- 피드 API 95% 응답 시간: 1,282ms → 426ms
- 피드 조회 처리량: 34.3건/s → 79.1건/s
→ Redis 장애 시 읽기/쓰기 복구 전략 분리
→ 읽기/쓰기 라우팅과 장애 복구를 애플리케이션 밖으로 옮긴 이유
→ 서버 수평 확장으로 피드 조회 처리량을 개선한 결정 과정
| 문서 | 내용 |
|---|---|
| 피드 읽기 경로 재설계 | 중간 피드 테이블 없이 follow/post 직접 조회, 인덱스 정비, Slice, 배치 조회, 반정규화 |
| Redis 발행형 피드와 fan-out | 전역 캐시 기준점, List 선택, 활동 이력, 파이프라이닝, @Async 경계 |
| 게시글 캐시와 상호작용 분리 | 널 오브젝트 캐시, like_count/comment_count, 명시적 UPDATE, Redis Set |
| 좋아요 병합 경계 재설계 | SCAN, 삭제 후 저장, 보상 재적재 |
| Redis 읽기/쓰기 복구 전략 분리 | 읽기 DB fallback, fan-out 쓰기 보류 후 재처리 |
| 읽기/쓰기 라우팅과 장애 복구 | GTID 복제, ProxySQL, Orchestrator, Raft |
| 커넥션 풀 최적화 | 풀 확장 실험, 컨텍스트 스위칭 오버헤드, 하향 튜닝 |
Java 17 · Spring Boot 3.3.4 · Spring Data JPA · Spring Security · MySQL · Redis · Resilience4j · AWS S3 · Prometheus · Grafana · Docker