Published on

조회수 시스템을 리팩토링하며 시간복잡도, 공간복잡도 줄인 경험

Authors
  • avatar
    Name
    김민석
    Twitter

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 (각 게시글마다 Set1)
Set 내 데이터 = 게시글별 누적 조회자 수
총 메모리 = O(게시글 수 + 총 조회 건수)

실제 메모리 증가 패턴

image.png
  • 10,000개 게시글 × 평균 100명 조회 = 1,000,000개 사용자 ID 저장
  • 각 ID당 평균 8-12 바이트 + Set 오버헤드
  • 인기 게시글의 경우 TTL 지속 갱신으로 사실상 영구 보존 느낌이다.

부하 테스트를 통해 메모리 증가 시뮬레이션 결과.

기간게시글 수평균 조회자 / 게시글예상 메모리 사용량실제 측정값
1일차1000개50명~ 5MB4.2MB
1주차1000개150명~ 15MB18.7MB
1개월1000개300명~ 30MB45.3MB

TTL 기반 메모리 관리의 한계

image.png

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)

image.png

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 기준 일원화정합성 개선

이번 리팩토링을 통해 조회수 시스템의 시간 복잡도와 공간 복잡도를 개선할 수 있었습니다.