- Published on
조회수 시스템을 리팩토링하며 시간복잡도, 공간복잡도 줄인 경험
- Authors
- Name
- 김민석
Introduction
앞서 작성한 Redis를 활용한 조회수, 인기글 구현과 성능 개선 경험
에서는 Redis 도입 과정과 KEYS 명령어 문제, 반복적 Redis 호출 등의 즉시 해결이 필요한 성능 병목
을 다뤘다면,
이번 글에서는 한 단계 더 나아가 시간/공간 복잡도 관점에서의 근본적인 구조 개선
을 중심으로, 장기적으로 발생할 수 있는 확장성 문제와 메모리 비용 증가를 방지하기 위한 구조적 리팩토링 경험
을 작성합니다.
상황
Redis 기반 조회수 시스템을 성공적으로 도입한 후, 단기적인 성능 문제
들은 해결되었지만 장기적인 운영 관점
에서 보면 새로운 문제들이 드러나기 시작했다.
메모리 사용량의 지속적인 증가와 DB 동기화 범위
문제가 눈에 보이기 시작한것이다.
- Redis에
article:view:{id}
형태로 조회수 저장 - 중복 조회 방지를 위해
article:viewed:members:{articleId}
Set에 사용자 ID 누적 - TTL 기반으로 일정 시간 후 자동 삭제 예정
- 주기적으로 모든 조회수 데이터를 DB로 동기화
이 구조는 기능적으로는 문제없이 작동했지만, 트래픽 증가에 따른 확장성 측면
에서 우려사항들이 발견되었다.
문제 분석: 복잡도 관점에서의 구조적 한계
Redis 메모리 사용량의 예측 불가능한 증가
기존 구조에서는 게시글마다 article:viewed:members:{articleId}
형태의 Set 키가 생성되어, 해당 게시글을 조회한 모든 사용자 ID가 누적되었다.
공간 복잡도 분석
Redis 키 수 = 게시글 수 × 1 (각 게시글마다 Set 키 1개)
Set 내 데이터 = 게시글별 누적 조회자 수
총 메모리 = O(게시글 수 + 총 조회 건수)
실제 메모리 증가 패턴

- 10,000개 게시글 × 평균 100명 조회 =
1,000,000개 사용자 ID 저장
- 각 ID당 평균 8-12 바이트 + Set 오버헤드
- 인기 게시글의 경우
TTL
지속 갱신으로사실상 영구 보존 느낌이다.
부하 테스트를 통해 메모리 증가 시뮬레이션 결과.
기간 | 게시글 수 | 평균 조회자 / 게시글 | 예상 메모리 사용량 | 실제 측정값 |
---|---|---|---|---|
1일차 | 1000개 | 50명 | ~ 5MB | 4.2MB |
1주차 | 1000개 | 150명 | ~ 15MB | 18.7MB |
1개월 | 1000개 | 300명 | ~ 30MB | 45.3MB |
TTL 기반 메모리 관리의 한계

TTL
을 통한 자동 삭제 전략은 이론적으로는 합리적이었지만, 실제 운영에서는 예측 불가능한 동작
을 보였다.
TTL 갱신 문제
// 사용자가 게시글 조회할 때마다 실행
String key = RedisKey.getViewedMembersKey(articleId);
redisTemplate.opsForSet().add(key, String.valueOf(memberId));
redisTemplate.expire(key, DUPLICATE_PREVENTION_TTL, TimeUnit.SECONDS); // TTL 갱신
문제점
- 인기 게시글: 지속적인 접근 →
TTL
계속 갱신 →영구 보존
- 비인기 게시글: 접근 없음 →
TTL
만료 → 자동 삭제 - 결과:
메모리 사용량을 시스템이 통제할 수 없음
DB 동기화의 비효율적인 범위
조회수 동기화 시 전체 게시글을 대상으로 하는 O(N) 처리
가 문제였다.
기존 동기화 로직
// 전체 게시글을 스캔하여 조회수 변경 여부 확인
@Scheduled(fixedRate = SYNC_INTERVAL)
public void syncAllViewCounts() {
List<Article> allArticles = articleRepository.findAll(); // O(N) 전체 조회
for (Article article : allArticles) {
String redisValue = redisTemplate.opsForValue()
.get(RedisKey.getArticleViewKey(article.getId()));
if (redisValue != null) {
// DB 업데이트 수행
}
}
}
시간 복잡도 문제
- 전체 게시글 수
N
에 대해 항상O(N) 처리
- 실제 조회수 변경이 있는 게시글은 전체의
10-30% 수준
70-90%의 불필요한 처리가 반복됨
해결: 복잡도 중심의 구조 개선
1. 중복 방지 개선으로 메모리 사용량 감소
TTL 갱신 문제를 해결하기 위해 일회성 TTL 설정 전략으로 변경했다.
public void increaseViewCount(Long articleId, Long memberId) {
String key = RedisKey.getViewedMembersKey(articleId);
Long added = redisTemplate.opsForSet().add(key, String.valueOf(memberId));
// 중복 조회인 경우 즉시 종료 (TTL 갱신 방지)
if (added != RedisConstants.REDIS_SET_ADD_SUCCESS) {
return;
}
// TTL은 최초 1회만 설정
redisTemplate.expire(key, RedisConstants.DUPLICATE_PREVENTION_TTL, TimeUnit.SECONDS);
redisTemplate.opsForValue().increment(RedisKey.getArticleViewKey(articleId));
redisTemplate.opsForSet().add(RedisKey.articlesSaveDbKey(), articleId.toString());
}
개선 효과
TTL
갱신 방지 → 인기 게시글도 5분 후 자동 삭제- 메모리 사용량 상한선 설정 가능
- 공간 복잡도:
O(N×M) → O(K)
(K: 5분 내 활성 사용자 수)
2. 동기화 범위 개선으로 시간 복잡도 개선
변경이 발생한 게시글만 추적하는 방식으로 개선했다.
// 조회수 증가 시 동기화 대상으로 등록
redisTemplate.opsForSet().add(RedisKey.articlesSaveDbKey(), articleId.toString());
// 동기화 시 변경된 게시글만 처리
@Transactional
public void bulkUpdateViewCounts() {
Set<String> articleIds = redisTemplate.opsForSet().members(RedisKey.articlesSaveDbKey());
if (articleIds == null) return;
List<ViewCountUpdateDto> updateList = new ArrayList<>();
// 변경된 게시글에 대해서만 처리 O(K)
for (String articleIdStr : articleIds) {
Long articleId = Long.parseLong(articleIdStr);
String viewCountValue = redisTemplate.opsForValue()
.get(RedisKey.getArticleViewKey(articleId));
if (viewCountValue != null) {
updateList.add(new ViewCountUpdateDto(articleId, Long.parseLong(viewCountValue)));
}
}
batchUpdater.batchUpdate(updateList); // 배치 처리
clearCache(articleIds); // 처리 완료 후 캐시 정리
}
시간 복잡도 개선
- 기존:
O(N)
- 전체 게시글 스캔 - 개선:
O(K)
- 변경된 게시글만 처리 (일반적으로 K < N)
배치 업데이트로 DB 부하 개선
개별 UPDATE를 배치 처리로 변경하여 DB 호출 횟수를 대폭 감소시켰다.
@Component
public class ArticleViewBatchUpdater {
private static final int BATCH_SIZE = 500;
public void batchUpdate(List<ViewCountUpdateDto> updateList) {
String sql = """
UPDATE article
SET view_count = view_count + :viewCount
WHERE id = :articleId
""";
// 500개씩 배치 처리
for (int i = 0; i < updateList.size(); i += BATCH_SIZE) {
List<ViewCountUpdateDto> batch = updateList.subList(
i, Math.min(i + BATCH_SIZE, updateList.size())
);
namedJdbcTemplate.batchUpdate(sql, createParameterSources(batch));
}
}
}
DB 호출 개선 1,000건 처리 시 1,000번 → 2번 호출로 감소했다. 배치 사이즈로 메모리 사용량도 감소 시켰다.
인기글 정렬 O(log N) -> O(1)

Redis ZSet 실시간 정렬에서 DB 기준 주기적 캐싱으로 변경했다.
@Scheduled(fixedRate = RedisConstants.RANKING_REFRESH_INTERVAL)
public void updateRedisTopArticles() {
// DB에서 정확한 조회수 기준으로 정렬
List<Article> topArticles = articleRepository.findTopByViews(
PageRequest.of(0, RedisConstants.TOP_LIMIT));
String key = RedisKey.getTopArticleListKey();
redisTemplate.delete(key);
// 정렬된 결과만 Redis List에 저장
for (Article article : topArticles) {
redisTemplate.opsForList().rightPush(key, article.getIdAsString());
}
}
// API 조회는 O(1)
public List<ArticleListResponse> getTopRankedArticles() {
List<Long> topIds = getTopArticleIds(); // O(1) Redis List 조회
// ... DB에서 게시글 정보 조회
}
정렬 복잡도 개선
- 기존:
O(log N)
- 매 조회마다 ZSet 정렬 - 개선:
O(1
) - 사전 정렬된List 조회
결과: 시간/공간 복잡도 개선을 통한 성능 개선
메모리 사용량 안정화 (공간 복잡도 개선)
측정 환경: 1000개 게시글, 일일 평균 300명 조회 기준
지표 | 개선 전 | 개선 후 | 개선율 |
---|---|---|---|
Redis 키 증가 패턴 | 시간에 따라 지속 증가 | 5분 주기로 안정화 | 증가폭 제한 |
메모리 사용량 | ~18MB | ~6MB | 약 3배 감소 |
TTL 관리 | 인기글은 계속 갱신 | 모든 키 5분 후 삭제 | 예측 가능 |
여기서 5분은 설명을 위한 예시.
DB 동기화 성능 개선 (시간 복잡도 개선)
측정 환경: 1,000개 게시글 중 500개에서 조회수 변경
처리 대상 | 개선 전 O(N) | 개선 후 O(K) | 개선 효과 |
---|---|---|---|
스캔 대상 | 1000개 전체 스캔 | 500개만 처리 | 처리량 2배 감소 |
처리 시간 | ~3초 | ~1.2초 | 약 3배 단축 |
DB 호출 수 | 500회 (개별 UPDATE) | 1회 (배치 업데이트, 500개씩) | 대폭 감소 |
인기글 정렬 성능 개선
지표 | 개선 전 | 개선 후 | 개선 효과 |
---|---|---|---|
정렬 방식 | 매번 실시간 계산 | 주기적 캐싱 | 계산 부하 제거 |
Redis 호출 수 | 6회 (ID조회 + 각 조회수) | 1회 (ID 목록만) | 호출 횟수 감소 |
데이터 일관성 | Redis / DB 혼재 | DB 기준 일원화 | 정합성 개선 |
이번 리팩토링을 통해 조회수 시스템의 시간 복잡도와 공간 복잡도를 개선할 수 있었습니다.