Skip to content

Conversation

@zzaekkii
Copy link
Member

@zzaekkii zzaekkii commented Dec 12, 2025

close #91

☑️ 완료 태스크

  • 조회 기록 및 중복 방지 목적 브릿지 테이블 엔티티 생성
  • PR 리뷰 요청글 상세 조회 API에 조회수 증가 로직 추가
  • 조회수 중복 증가 방지
  • 조회수 증가 작업에서 동시성 문제 해결
  • 요청글 내용 조회 작업과 조회수 증가 작업 비동기로 분리
  • 도메인 모델 유닛 테스트 추가

🔎 PR 내용

조회수 관리는 고려할 부분이 많아서 8월부터 계속 고민을 하고 있었는데요.
이제야 최적의 방식을 찾은 것 같습니다 (지금 뇌 수준에서는)
최적의 방식을 바로 구현하는 것보단 단계별로(v1~v4) 구현하면서 지표로 비교하는 게 좋겠다 판단했습니다.

요청글 조회 API는 조회(Query)와 조회수 증가(Command) 결합되어 있고,
중복 방지, 동시성, DB 부하, 분산 환경까지 같이 고려해야 하기에 좀 까다로웠습니다.

그래서 아래 조건들을 만족하면서 점점 개선해나가는 방식으로 차례로 구현해보겠습니다.

1. 조회수 중복 증가 방지
  -> 로그인 사용자/익명 사용자를 식별하고 일정 기간 내 중복 증가를 막아야 함

2. 조회수 중복 증가 방지를 위한 데이터 저장소 선택 (RDB vs Redis )
  -> 각 선택지마다 비용/부하/운영 난이도 비교

3. 중복 증가 기간에 대한 판단 방법
  -> TTL 방식 vs 마지막 증가 시각 비교

4. 요청글 조회 API마다 UPDATE 쿼리가 나가는 데에 대한 DB 부하 완화

5. 동시성 문제 없는 조회수 증가 처리

6. aws 프리티어 인프라 환경 내에서 감당 가능할 것 

7. 분산 서버 환경이라 개별 서버 메모리를 활용하는 건 피할

1️⃣ v1: 회원-요청글 브릿지 테이블 기반 조회수 기록

v1은 가장 단순한 방식으로, 회원-요청글 브릿지 테이블(pr_review_hits)에 기록해 중복 증가를 막는 겁니다.

Image

아주 단순한 구조라 문제도 많긴 하지만 우선 장단점을 생각해보죠.

장점

  • 구현이 매우 단순하다는 점
  • 중복 조회 방지를 RDB만으로 커버 가능하다는 것
  • 조회 기록이 데이터로 남아서 향후 분석 등에 이용해볼 수도 있다는 점 (지금 상황에 크게 와닿진 않음)

단점

  • 게시글을 조회할 때마다 SELECT -> INSERT/UPDATE가 발생해 DB에 부하가 커짐
  • 트패픽이 늘어날 수록 테이블이 급격히 커짐
  • 사실 단순히 조회수 증가 기능인데 기록 방식이 무거움 (중요도를 따졌을 때 조회수가 그 정도는 아님)

중복 판단 방식 (v1에서 선택)

브릿지 테이블을 두는 방식에서도 중복 판단은 여러 방법이 있죠.

1. [삭제] 중복 증가로 볼 기간동안 데이터를 저장하고, 해당 시각 이후 테이블 내 데이터 삭제
  -> TTL 느낌인데, MySQL에서 TTL을 지원하진 않기에 배치 작업으로 삭제 처리 필요

2. [보존] 마지막 조회수 증가 시각 컬럼을 추가해, 증가 여부를 판단 ✅

👉 마지막 증가 시각(last_increased_at)을 저장하고, 현재와 비교해서 TTL(1시간) 내 중복 증가를 막는 2번을 선택했습니다.

v1 최종 ERD

image

이렇게 조회수 테이블을 두고는 있지만 중복 증가 방지 목적이 큽니다.
특히 "요청글별 조회수 수치"는 조회/정렬에서 자주 사용되고 있기 때문에,
브릿지 테이블에 매번 COUNT를 하지 않고 요청글 엔티티에 hitCount를 비정규화해서 구현했습니다.

2줄 요약

👉 마지막 조회수 증가 시각을 가진 조회수 브릿지 테이블로 중복 증가를 방지하고,
요청글의 hitCount는 별도 컬럼으로 비정규화해서 증가 처리하는 방식으로 v1을 구현했다.

넘어가기 전에 동시성 문제 처리와 조회/조회수 증가 비동기 분리 작업 설명하고 갈게요.

🛠️ 조회수 증가 시각 기록 시 동시성 문제 해결 INSERT IGNORE INTO

image

조회수 중복 증가 방지를 위해 (pr_review_id, user_id) 조합으로 복합 유니크 제약 조건을 뒀습니다.

하지만 단순히 SELECT → 없으면 INSERT 같은 흐름으로 구현할 경우엔 동시성 문제가 발생할 수 있습니다.

  1. 사용자가 새로고침을 빠르게 연타하거나
  2. 여러 장치나 탭에서 동일한 요청 글을 동시에 조회하는 경우가 있을 수 있죠.

두 요청이 거의 동시에 기록이 없다고 판단해 INSERT를 시도하면
중복 row를 만드려고 하거나 DuplicateKeyException가 터지게 될 겁니다.

특히 이 문제는 아직 테이블에 존재하지 않는 row에 대한 경쟁이라
SELECT … FOR UPDATE같은 Exclusive lock을 선점하는 것도 불가능합니다.
(존재하지 않는 row라서 잠글 대상 자체가 없기 때문이죠 💁‍♂️)

그래서 이 문제를 애플리케이션 레벨에서 재시도하거나 lock으로 해결하기보다는
앞에서 만든 (pr_review_id, user_id) UNIQUE 인덱스를 활용했습니다.

INSERT IGNORE INTO pr_review_hits (pr_review_id, user_id, last_increased_at)
VALUES (?, ?, ?)

INSERT IGNORE INTO로 아직 row가 없는 첫 조회 요청이라면 INSERT가 정상적으로 수행하고,
동시에 들어오거나 이미 row가 존재하면 UNIQUE 충돌이 발생하긴 해도 에러 없이 무시합니다.
동시에 들어온 요청이니 lastIncreasedAt의 시간도 의미 상 크게 문제되지 않죠.

즉, 동시에 여러 INSERT 요청이 들어와도 DB 차원에서 하나의 row만 생성한다는 걸 보장하니까,
애플리케이션 레벨에서 race condition을 처리해야 할 필요가 없게 된 겁니다.

🛠️ 요청글 내용 조회와 조회수 증가 작업을 비동기로 분리 @Async

요청글 조회 API는 조회(Query)와 조회수 증가(Command) 두 가지 성격의 작업이 섞여있습니다.

이 두 작업을 한 트랜잭션이나 동기로 묶어버리면,

  1. 조회 API의 응답 시간이 DB write 작업 때문에 불필요하게 길어지게 되고,
  2. 트래픽이 늘어날수록 조회 API가 쓰기 병목까지 떠안게 됩니다.

조회수 처리 로직때문에 조회 API의 성능에 직접적인 영향을 주게 되는 거죠.

해결 방법: 조회수 증가 작업은 비동기 분리

실시간성이 중요하긴 하지 조회수 증가는 응답 결과에 즉시 반영될 필요는 없기 때문에,
상세 조회 로직과 분리하고 @Async를 활용해 조회수 증가를 비동기로 처리하도록 구현했습니다.

// --------------------------
// PrReviewService
// --------------------------
public PrReviewDetailResult getPrReviewDetail(Long reviewId, Long userId) {

	PrReviewDetailData data = prReviewQueryRepository.findById(reviewId)
		.orElseThrow(PrReviewNotFoundException::new);

	boolean isWriter = data.userId().equals(userId);

	prReviewAsyncService.increaseHitAsync(reviewId, userId);

	return PrReviewDetailResult.from(data, isWriter);
}

// --------------------------
// PrReviewAsyncService
// --------------------------
@Service
@RequiredArgsConstructor
public class PrReviewAsyncService {

	private final PrReviewRepository prReviewRepository;
	private final PrReviewHitRepository prReviewHitRepository;

	@Async("hitsExecutor")
	@Transactional
	public void increaseHitAsync(Long reviewId, Long userId) {

		LocalDateTime now = LocalDateTime.now();

		PrReviewHit hit = prReviewHitRepository.findOrCreate(
			PrReviewHit.create(new PrReviewHitCreate(reviewId, userId, now)));

		if (!hit.canIncrease(now))
			return;

		prReviewRepository.increaseHitCount(reviewId);

		prReviewHitRepository.updateLastIncreasedAt(hit.getId(), now);
	}
}

코드를 보면 AsyncService를 따로 만든 것을 확인할 수 있는데요.

처음에는 같은 Service 내부에 @async 메서드를 정의했지만,
Spring의 @Async는 프록시 기반으로 동작한다는 걸 알게됐습니다.

동일 클래스 내부 호출은 프록시를 거치지 않아서 비동기가 아니라 동기 호출로 실행되는 거죠.

그래서 내부 호출 대신 self-reference 방식으로 해야 하나 했었는데,
순환 참조가 일어나는 구조를 만들기보다는 AsyncSerivce로 따로 클래스를 빼는 구조로 구성했습니다.


image

사실 CQRS 얘기해놓고 findOrCreate()가 Query/Command 역할을 동시에 수행하는 점에서
엄밀하게 따지면 CQRS 규칙은 어긴 구조입니다.

다만 존재하지 않는 row에 락을 걸 수 없는 상황이었기 때문에,
DB 차원의 유니크 제약으로 동시성 문제를 해결하기 위해 실용적으로 접근하기로 했습니다.

작업 요약

  1. hit row 중복 생성 문제 DB 유니크 제약 + INSERT IGNORE INTO로 해결
  2. TTL 내 중복 조회수 증가는 시간 값을 비교해 판단
  3. 조회 API의 성능에 영향을 주지 않게 비동기 처리

🪄 앞으로의 리팩토링 계획

아래는 앞으로 개선해 나갈 작업 계획들입니다.

2️⃣ v2: Redis에 조회를 SET으로 기록해 중복 증가 방지

요청글별로 Redis SET(pr_review_id, user_id)에 유저를 기록해 중복 조회를 판단하는 겁니다.

장점

  1. DB 조회/INSERT 제거 → DB 부하 대폭 감소
  2. Redis SET 자체가 중복을 허용하지 않고, 단일 스레드라 동시성 문제 해결
  3. TTL 설정으로 중복 증가 기간 관리 간단

단점

  1. 조회 기록이 Redis 메모리를 점유
  2. 조회수 누적 값은 여전히 매번 RDB UPDATE 필요
  3. Redis 다운되거나 OOM 발생했을 때의 후처리, 대처

3️⃣ v3: Redis에 조회 SET에 기록 중복 증가 방지 + 누적 조회수 저장/배치로 일괄 UPDATE

그대로 중복 증가 판단을 Redis SET으로 하는 것까진 v2랑 같은데,
조회수 누적을 Redis에서 하다가 일정 주기로 RDB에 배치로 일괄 반영하는 아이디어입니다.

배치로 일괄 UPDATE 하는 만큼, 여기서는 TTL 기준을 고정 시간 단위(ex. 1시간)로 잡아야 합니다.

장점

  1. 조회 API에서 RDB 쓰기 완전히 제거
  2. TTL 동안 쌓인 RDB에 배치로 일괄 UPDATE 처리 → 부하 예측 가능
  3. Redis 장애 시에도 RDB는 최종 정합성 유지

단점

  1. Redis, 배치 작업 관리 필요
  2. Redis랑 RDB 간에 정합성 맞추는 방법을 고려해야 함
  3. Redis 다운되거나 OOM 발생했을 때의 후처리, 대처

4️⃣ v4: Redis hyperloglog 중복 증가 방지 + 누적 조회수 저장/배치로 일괄 UPDATE

v3에서 중복 증가 기록을 위해 쓰던 SET 대신 HyperLogLog로 근사 집계하는 아이디어입니다.

image

hyperloglog.. 이걸 알고 나니 그동안의 모든 근심과 스트레스, 걱정이 없어지고 건강해졌습니다 👍
이런 게 있었다니, 살~짝 눈물 흘릴 뻔 했습니다.

장점

  1. 조회 기록당 메모리 사용량 대폭 절약 (≈ 12KB 고정)
  2. 트래픽·유저 수 증가에 거의 영향 없음
    -> 고트래픽 환경에서도 안정적인 조회수 처리
  3. DB 부하 최소화 + 분산 환경 친화적

단점

  1. hyperloglog는 추정 집계라 약 0.81% 오차가 존재함 (대략 1000개에 8개 꼴)
    -> 우리 서비스의 PR리뷰요청글 조회수에 이 정도 오차는 충분히 감수할 만함!
  2. Redis 다운되거나 OOM 발생했을 때의 후처리, 대처

📷 성능 스크린샷

성능 측정은 알아보고 있는데 어떻게 하는지 조사하고 결과 후첨할게요.

1. pr_review UPDATE QPS (초당 UPDATE 횟수)
4. pr_review_hits INSERT QPS
5. DB CPU 사용률
등등..

@zzaekkii zzaekkii requested a review from seungryul99 December 12, 2025 17:28
@zzaekkii zzaekkii self-assigned this Dec 12, 2025
@zzaekkii zzaekkii added :trollface: 재영 Further information is requested ✨ Feature 기능 개발 labels Dec 12, 2025
@zzaekkii zzaekkii linked an issue Dec 12, 2025 that may be closed by this pull request
4 tasks
@sonarqubecloud
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
13.6% Coverage on New Code (required ≥ 80%)
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@seungryul99
Copy link
Member

아 그리고 스레드 풀 성능 튜닝에 대해서 톰캣 스레드풀이랑 우리가 사용하는 스레드 풀이랑 동작 과정이 좀 다릅니다.

[톰캣]

작업이 밀려옴 -> min-spare 스레드로 처리 -> 감당 불가시 바로 스레드 생성해서 max-size 까지 스레드를 늘림 -> 이래도 감당 불가시 큐에 작업쌓임 -> 감당 불가시 연결 거부

[커스텀 스레드풀]

작업이 밀려옴 -> min 스레드로 처리 -> 감당 불가시 큐에 작업 쌓임 -> 큐 사이즈 초과 시 max 까지 스레드 늘어남 -> 감당 불가시 거절 정책에 따라 처리

@zzaekkii
Copy link
Member Author

[커스텀 스레드풀]

작업이 밀려옴 -> min 스레드로 처리 -> 감당 불가시 큐에 작업 쌓임 -> 큐 사이즈 초과 시 max 까지 스레드 늘어남 -> 감당 불가시 거절 정책에 따라 처리

아, 이 녀석 안그래도 이번에 @Async 공부하면서 알게 됐습니다. 허허
신경써주셔서 감사합니다

setMaxPoolSize
스레드 풀이 확장될 수 있는 스레드의 상한선을 설정하며, 스레드 풀이 관리할 수 있는 최대 스레드 수를 정의합니다.
만약 corePoolSize를 초과하는 작업이 들어올 경우, 이러한 추가 작업들은 먼저 큐에 배치됩니다.
그러나 대기열이 가득 차면 스레드 풀은 maxPoolSize에 도달할 때까지 추가 스레드를 생성하여 작업을 처리합니다.
maxPoolSize에 도달하면 스레드 풀은 새 작업을 받아들이지 않으며, 대신 정책에 따라 거부하거나 다른 방법으로 처리합니다.

@zzaekkii zzaekkii merged commit 830db21 into develop Dec 21, 2025
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

✨ Feature 기능 개발 size/L :trollface: 재영 Further information is requested

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feat: 요청글 조회수 관리 v1

3 participants