기존의 조회수 로직은 게시글이 조회될 때마다 해당 게시글의 외래키를 갖는 조회 엔티티를 생성하여 데이터베이스에 INSERT하는 방식이다. 이 방식에는 다음과 같은 문제점들이 존재한다.
데이터베이스 부하 증가이다.
게시글 조회가 발생할 때마다 INSERT 작업이 수행되므로, 트래픽이 많은 환경에서는 데이터베이스에 과도한 쓰기 부하가 발생하게 된다.
테이블 사이즈 급증이다.
모든 조회마다 별도의 레코드가 생성되므로, 조회수 데이터가 누적되어 테이블의 건수가 빠르게 증가하게 되며, 이는 저장 공간 관리와 성능 유지에 어려움을 초래한다.
집계 작업의 비효율성이다.
전체 조회수를 산출하기 위해 모든 레코드를 COUNT 하는 방식은 데이터가 많아질 경우 집계 쿼리의 성능을 저하시킬 수 있다.
동시성 및 경합 문제가 발생한다.
높은 동시 접속 상황에서 다수의 INSERT 작업이 동시에 발생할 경우, 데이터베이스의 동시성 제어나 락 경합 문제가 발생하여 전체 시스템의 성능과 안정성에 영향을 미칠 수 있다.
운영 및 유지보수 비용 증가이다.
대량의 조회수 데이터를 관리하고 분석하는 데 추가적인 리소스와 비용이 소요되며, 시스템 복잡성이 증가함에 따라 운영 및 유지보수가 어려워진다.
이를 개선하기 위해 게시글 테이블에 viewCount 필드로 하여금 조회수 필드를 활용하도록 하는 것이다.
레디스는 사용하지 않는 방향으로 개선해보도록 하자.
엔티티 INSERT 대신 조회수 필드 업데이트
기존에는 조회마다 별도의 조회 엔티티를 INSERT 하였으나, 게시글 엔티티에 조회수 필드를 추가하여 조회 시마다 해당 값을 증가시키는 방법을 채택하면,
동시 접속 상황에서 UPDATE 작업의 성능 측면
동시 접속 환경에서 INSERT 작업 대신 UPDATE 작업으로 전환하면,
다만, 동시 다발적으로 동일 게시글의 조회수가 업데이트될 경우,
결론적으로, 동시 접속 상황에서 INSERT 작업 대신 UPDATE 작업을 사용하는 방식은 전반적으로 성능 향상에 기여한다.
다만, 동일 엔티티에 대한 빈번한 업데이트로 인한 동시성 문제를 적절히 관리하기 위한 추가 전략이 필요하다.
프로젝트에서 게시글의 조회수를 관리하는 로직을 기존의 INSERT 방식에서 게시글 엔티티의 조회수 필드를 UPDATE 하는 방식으로 전환하였다.
이 방식은 데이터베이스 부하와 테이블 사이즈 증가 문제를 해결하고, 집계 작업을 단순화할 수 있다는 장점이 있다.
그러나 동시 접속 상황에서 동일 게시글의 조회수 필드를 업데이트할 때 발생할 수 있는 동시성 문제를 해결하기 위한 추가 전략이 필요하였다.
이를 위해 여러 락(Lock) 기술을 검토한 결과, 낙관적 락(Optimistic Lock)을 적용하는 것이 가장 합리적이라고 판단하였다.
기존 문제 인식
동시성 문제 발생 가능성
락 기술 검토
@Version
필드)를 통해 충돌이 발생하면 재시도를 할 수 있어, 락에 의한 성능 저하를 최소화할 수 있음.낙관적 락 선택 이유
따라서, 동시 접속 환경에서 발생할 수 있는 동시성 문제를 적절히 관리하기 위해
낙관적 락을 적용하는 것이 가장 합리적인 선택이다.
이 접근법은 조회수 업데이트의 성능 향상과 안정성을 동시에 확보할 수 있는 효과적인 전략이다.
// Post 엔티티 필드 추가
@Version
private Integer version;
private int viewCount;
@Version
어노테이션이 적용된 version
필드는 데이터베이스 레코드의 버전을 관리하는 역할을 한다.
레코드가 DB 수준에서 변경되면 해당 레코드의 버전 값이 자동으로 증가된다.
두 쓰레드가 동시에 게시글의 조회수를 증가시키려 할 경우, 낙관적 락은 다음과 같이 동작한다.
Post
엔티티를 조회한다.Post
엔티티를 조회한다.version
값이 증가하고 조회수 증가가 반영된다.version
과 DB에 반영된 version
이 달라져 OptimisticLockException
이 발생한다.위의 내용까지는 게시글 조회시 항상 Post엔티티의 viewCount++를 수행한다. 이를 막고 조회수는 하루에 한 번까지만 가능하게 해야한다.
다양한 해결책이 있지만 쿠키와 함께, 인메모리 캐시인 ehcache를 활용하여 조회수 중복 문제를 해결해보려고 한다.
@Transactional
public void increaseViewCountV2(Long postId, HttpServletRequest request, HttpServletResponse response) {
// 쿠키 파싱: postId와 조회 시점을 Map으로 가져옴
Map<Long, LocalDateTime> viewedPostsMap = getViewedPostsCookieEntries(request);
String clientIdentifier = getClientIdentifier(request);
String cacheKey = clientIdentifier + "-" + postId;
// 쿠키에 해당 postId가 있고, 조회 시점이 6시간 이내라면 조회수 증가 스킵
if (viewedPostsMap.containsKey(postId)) {
LocalDateTime lastViewTime = viewedPostsMap.get(postId);
long hoursSinceLastView = Duration.between(lastViewTime, LocalDateTime.now()).toHours();
if (hoursSinceLastView < 6) {
log.info("조회수 증가 스킵 - 최근 {}시간 이내에 조회됨 (postId: {})", hoursSinceLastView, postId);
return;
}
}
// 쿠키에 기록이 없거나, 6시간이 지난 경우 캐시 확인 (캐시에는 key:String, value:true 형식으로 저장)
Cache cache = cacheManager.getCache("viewLogCache");
if (cache != null && cache.get(cacheKey) == null) {
boolean success = false;
int retryCount = 0;
int maxRetry = 3;
Post post = postService.findById(postId);
while (!success && retryCount < maxRetry) {
try {
// 게시글의 조회수 증가
post.increaseViewCount();
entityManager.flush();
entityManager.clear();
log.info("조회수 증가 성공 - postId: {}, 새로운 viewCount: {}", postId, post.getViewCount());
// 캐시에 새로운 조회 기록 저장
cache.put(cacheKey, true);
log.info("📥 캐시에 새로운 조회 기록 저장 - cacheKey: {}", cacheKey);
// 쿠키 업데이트: 현재 시간으로 해당 postId의 조회 기록을 갱신
viewedPostsMap.put(postId, LocalDateTime.now());
String newCookieValue = buildViewedPostsCookieValue(viewedPostsMap);
setViewedPostsCookie(response, newCookieValue);
log.info("쿠키 업데이트 완료 - postId: {}, cookieValue: {}", postId, newCookieValue);
success = true;
} catch (OptimisticLockException e) {
retryCount++;
log.warn("OptimisticLockException 발생 - 재시도 {}/{} (postId: {})", retryCount, maxRetry, postId);
if (retryCount >= maxRetry) {
log.error("조회수 업데이트 실패 (최대 재시도 초과) - postId: {}", postId);
throw new RuntimeException("조회수 업데이트 실패 (최대 재시도 초과)", e);
}
// 최신 데이터를 위해 영속성 컨텍스트 초기화 후 재조회
entityManager.clear();
post = postService.findById(postId);
}
}
return;
}
// 캐시에 이미 기록이 있는 경우 (중복 조회로 간주)
log.info("조회수 증가 스킵 - 캐시에 중복 조회 기록 존재 (cacheKey: {})", cacheKey);
}
1차적으로 쿠키를 조회한다. 쿠키에는 클라이언트가 접속한 게시글의 id와 조회시 시간(timestamp)이 담기게 된다.
이를 통해 쿠키에 postId는 존재하나 6시간이 지나지 않았는 지 확인한다.
만약 지나지 않았다면 return하여 조회수 증가 로직 함수를 전체 패스 시킨다.
그렇다면 필터링된 경우는 다음과 같다.
1. 쿠키에 postId는 존재하지만 6시간이 지난 경우
2. 쿠키에 해당 postId자체가 존재하지 않는 경우
3. 쿠키자체가 없는 경우
이 세 경우는 모두 그 다음 과정인 캐시 확인이 필요하다.
캐시 확인을 하는 이유는 의도적인 쿠키 삭제를 통한 조회수 어뷰징을 막기 위해서이다.
조회수 증가 로직에서 캐시는 IP+UserAgent
를 key로 하는 캐시를 확인한다.
캐시는 TTL이 6시간으로 적용되었다.
만약 캐시 조회도 실패한다면 조회수를 증가시킨다.
이때 조회수 증가는 낙관적 락이 적용되므로 OptimisticLockException
예외를 잡아 3번의 retry를 허용하도록 한다.
조회수 증가에 성공했다면, 쿠키를 재발급(postId:현재시간)하고, 캐시를 갱신한다.