Skip to content

[BOM-1122] feat: 읽기 카운트 증가 제한 추가#757

Merged
Choidongjun0830 merged 42 commits into
server-devfrom
BOM-1122-읽기-카운트-증가-제한-추가
Jun 27, 2026

Hidden character warning

The head ref may contain hidden characters: "BOM-1122-\uc77d\uae30-\uce74\uc6b4\ud2b8-\uc99d\uac00-\uc81c\ud55c-\ucd94\uac00"
Merged

[BOM-1122] feat: 읽기 카운트 증가 제한 추가#757
Choidongjun0830 merged 42 commits into
server-devfrom
BOM-1122-읽기-카운트-증가-제한-추가

Conversation

@Choidongjun0830

Copy link
Copy Markdown
Contributor

📌 What

이달의 독서왕 어뷰징 방지를 위한 읽기 카운트 증가 제한을 추가했습니다.

❓ Why

PATCH /articles/{id}/read API를 짧은 시간 안에 반복 호출하면 월간 읽기 카운트를 비정상적으로 증가시킬 수 있었습니다. 실제로 1분에 39개를 읽은 사용자가 존재했습니다.
정상 사용자가 짧은 아티클을 연속으로 읽는 burst 패턴은 허용하면서, 카운트만 부풀리는 어뷰징 패턴은 차단할 필요가 있었습니다.

🔧 How

1. 알고리즘 선택: Token Bucket

Fixed Window: 윈도우 경계에서 burst 통과 문제
Sliding Window: 요청 이력 저장/조회 비용 큼
Leaky Bucket: 처리 속도 제어용이라 "count 인정 여부 판단"에는 부적합
Token Bucket (채택): 짧은 burst 허용 + 장기적으로 일정 속도 제한. 회원당 row 1개로 관리 가능

2. 정책값 (데이터 기반 결정)

bom-bom:
  rate-limit:
    read-count:
      bucket-capacity: 3       # 연속 3개까지 burst 허용
      refill-seconds: 50       # 50초당 1개씩 충전

버킷 용량: 3: 전체 read 데이터 burst 분석에서 **연속 3개 이내 burst가 정상 사용자 패턴의 97.11%**를 차지
충전 속도: 50초: 글 사이 간격(gap_seconds) 분석에서 p30 = 62초, p20 = 39초. 50초로 잡으면 정상 사용자 약 70~80% 보호 + 1시간 read count는 58개로 제한되어 어뷰징 차단

정책값 설정 근거

회원별 read 이벤트의 전체 기간 누적 데이터를 분석하여 정책값을 결정했다.

1. 버킷 용량

연속된 read 이벤트(이전 이벤트와 60초 이내)를 burst session으로 묶어 분석한 결과 (총 6,385개 session, 354명):
image

  • **연속 3개 이내의 burst가 정상 사용자 패턴의 97.11%**를 차지한다. 즉, 일반적인 사용자는 한 번에 4개 이상의 아티클을 빠르게 연속해서 읽지 않는다.
    • 몇 퍼센트를 정상 사용자 패턴으로 정하는지가 중요함.

burst 용량을 3으로 설정하면

  • 정상 사용자의 연속 읽기 패턴(헤드라인 → 관심 섹션 등)은 그대로 허용된다.
  • 4번째 이후의 빠른 burst는 충전 속도로 자연스럽게 제어된다.

2. 재충전 시간

연속된 read 이벤트 사이의 간격(gap_seconds > 0) 분포를 분석한 결과 (총 7,890개 pair, 265명):

(간격의 의미: A 아티클 50% 이후 나머지 체류 시간 + B 아티클로 이동 시간 + B 아티클 50%까지 스크롤 시간)

image image

각 percentile에서 5분 / 1시간 동안 인정 가능한 read count로 환산하면:

구간 간격 5분 1시간
p5 16초 18개 225개
p10 22초 13개 163개
p20 39초 7개 92개
p30 62초 4개 58개
p40 84초 3개 43개
p50 116초 2개 31개
  • p20 기준만 해도 1시간에 92개의 read count가 인정된다. 이는 정상 사용자가 한 시간 동안 읽을 수 있는 양을 한참 넘는 수치로 생각된다.
    • 또한, 아티클 이동 시간을 포함하는 수치이므로 거의 읽지 않은 수준이라고 생각된다.
  • p40, p50을 어뷰징 기준으로 잡을 경우 정상 사용자의 대부분이 차단된다.
  • 그래서 p30을 기준으로 약간 여유를 두는 것이 최선이라고 생각되어 50초로 설정하였다.

정책값은 yml로 관리하여 운영 중 데이터 추이를 보며 조정 가능

3. 동시성 처리

SELECT → 계산 → UPDATE 구조는 race condition 발생 가능. 단일 UPDATE 문으로 원자적 처리:

INSERT IGNORE INTO member_read_token_bucket (member_id, tokens, updated_at)
VALUES (:memberId, :capacity, :now);

UPDATE member_read_token_bucket
SET tokens = LEAST(:capacity, tokens + TIMESTAMPDIFF(SECOND, updated_at, :now) / :refill) - 1,
    updated_at = :now
WHERE member_id = :memberId
  AND LEAST(:capacity, tokens + TIMESTAMPDIFF(SECOND, updated_at, :now) / :refill) >= 1;

affectedRows = 1 → 허용 / affectedRows = 0 → 차단

4. 라이브러리 미사용 이유

Guava, Resilience4j: 메모리 기반이라 다중 인스턴스 환경에서 상태 공유 불가
Bucket4j-MySQL: SELECT FOR UPDATE 기반이라 락 보유 시간이 길고 쿼리가 여러 번 발생
→ DB 원자적 UPDATE 직접 구현이 더 적합하다고 판단

5. 트랜잭션 설계

MarkAsReadListener.on 내부 트랜잭션 구조:

  • 같은 트랜잭션 (T1): 토큰 차감 + 읽기 카운트 갱신 → 같이 성공/롤백
  • 별도 트랜잭션 (T2, REQUIRES_NEW): 펫 경험치 갱신

펫 경험치 갱신 실패는 try-catch로 격리 → 토큰 차감과 읽기 카운트에 영향 없음
읽기 카운트 갱신 실패 시에는 토큰 차감도 같이 롤백 + 펫 경험치는 호출 안 됨

상황 토큰 읽기 카운트 펫 점수
정상 차감 증가 증가
펫 갱신 실패 차감 증가 변화 없음
읽기 카운트 갱신 실패 롤백 롤백 호출 안 됨
Rate limit 초과 변화 없음 변화 없음 호출 안 됨

토큰은 "카운트 인정 약속"이므로 카운트와 atomic해야 함. 펫 점수는 부가 보상이라 실패가 핵심 흐름을 막으면 안 됨.

👀 Review Point (Optional)

  • 정책값(bucket-capacity, refill-seconds) 기준이 적절한지: 데이터(burst 97.11%, gap p30) 기반으로 잡았음.
  • 리스너 트랜잭션 분리 구조: 펫 실패는 격리, 읽기 카운트 실패는 토큰까지 롤백되는 부분의 의도가 명확한지

혹시 더 보고 싶으시다면.
Token Bucket에 대한 설계와 정책 결정은 여기서 더 보실 수 있습니다.

@github-actions github-actions Bot added BE backend PR D-2 feat 새로운 기능 추가 D-1 and removed D-2 labels May 11, 2026
Long memberId,
Long articleId
Long articleId,
LocalDateTime readAt

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

원래 이벤트를 받아 이벤트 리스너나 그 이후 서비스에서 LocalDate.now(clock)을 사용하려 했으나,
이벤트를 수신하여 실행하는 시점과 실제 읽은 시점에 대한 차이가 생길 수도 있다고 생각되었습니다.
그래서 이벤트 생성 시점에 읽은 시점을 기록하여 넘기도록 했습니다.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P4

readAt을 도입한 의도가 실제 읽은 시점 기준으로 처리하기 위함이라면,
isTodayArticle처럼 MarkAsRead를 소비하는 다른 로직들도 event.readAt() 기준으로 맞추면 더 일관적일 것 같아요!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment on lines -28 to +33
try {
boolean isTodayArticle = articleService.isArrivedToday(event.articleId(), event.memberId());
updateReadingCount(event, isTodayArticle);
updatePetScore(event, isTodayArticle);
} catch (Exception e) {
log.error("MarkAsReadEvent 처리 실패 - memberId={}, articleId={}", event.memberId(), event.articleId(), e);

if (!readRateLimitService.checkAndConsume(event.memberId(), event.readAt())) {
log.info("읽기 rate limit 초과로 카운트 갱신 skip - memberId={}", event.memberId());
return;

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try-catch로 감싸져있을 경우 트랜잭션 프록시로 예외가 전파되지 않아 롤백되지 않습니다.
try-catch를 제거하고 pr 코멘트에서 말씀드린 것처럼 토큰 차감과 읽기 count 증가를 한 트랜잭션으로 묶고, 펫 경험치 증가를 다른 트랜잭션에서 동작하도록 하여 펫 경험치 증가의 실행 결과가 다른 트랜잭션에 영향을 주지 않도록 했습니다.

Comment on lines +24 to +30
@Query(value = """
UPDATE member_read_token_bucket
SET tokens = LEAST(:bucketCapacity, tokens + TIMESTAMPDIFF(SECOND, updated_at, :now) / :refillSeconds) - 1,
updated_at = :now
WHERE member_id = :memberId
AND LEAST(:bucketCapacity, tokens + TIMESTAMPDIFF(SECOND, updated_at, :now) / :refillSeconds) >= 1
""", nativeQuery = true)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 쿼리는 토큰 버킷 방식으로 "지금 요청 1건을 소비할 수 있으면 차감하고, 없으면 아무 것도 안 하는" 업데이트입니다.
동작 순서는 이렇습니다.

TIMESTAMPDIFF(SECOND, updated_at, :now) / :refillSeconds

  • 마지막 갱신 시점부터 지금까지 지난 시간을 초 단위로 구합니다.
  • 그 시간을 refillSeconds로 나눠서, 새로 충전될 토큰 개수를 계산합니다.
  • MySQL에선 정수끼리 나누면 소수점 이하는 버려져서, "충분한 시간이 지난 만큼만" 토큰이 찹니다.

tokens + ...

  • 기존 토큰 수에 새로 충전된 토큰 수를 더합니다.

LEAST(:bucketCapacity, ...)

  • 아무리 많이 충전돼도 버킷 최대치(bucketCapacity)를 넘기지 않게 막습니다.

SET tokens = ... - 1

  • 충전 후 토큰이 1개 이상 있으면 이번 요청에서 1개를 소비합니다.

updated_at = :now

  • 이번 계산 기준 시각을 현재로 갱신합니다.

WHERE ... >= 1

  • 충전까지 반영한 현재 사용 가능 토큰이 1개 이상일 때만 update가 수행됩니다.
  • 즉, 토큰이 없으면 row가 업데이트되지 않습니다.

결과적으로:

  • 성공 시: 토큰을 충전한 뒤 1개 차감하고 updated_at을 갱신
  • 실패 시: 아무 것도 안 바뀜

member_id BIGINT NOT NULL,
tokens DOUBLE NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (member_id)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

아래와 같은 이유로 member_id를 PRIMARY KEY로 두었습니다.

  • 한 회원은 무조건 한 버킷만 가진다
  • 동일 회원에 대해 중복 row가 생기면 안 된다
  • 조회/업데이트가 항상 WHERE member_id = ?로 일어난다
  • 따라서, member_id로 자동으로 유니크와 인덱스가 걸려 효율적

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

회원 탈퇴 시 row 정리만 잘 된다면 괜찮은 방법인 것 같습니다~!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이거는 한번 다른 PR에서 싹 정리해봐야 할거 같습니다!
엔티티만 계속 추가되고 탈퇴 시 정리가 안되는거 같아서 ㅎㅎ
jira에 티켓 만들어둘게요!

@Choidongjun0830 Choidongjun0830 force-pushed the BOM-1122-읽기-카운트-증가-제한-추가 branch from 6916daf to 3c47e71 Compare June 1, 2026 12:57
@Choidongjun0830 Choidongjun0830 requested a review from Ryan-Dia June 1, 2026 14:02

@Ryan-Dia Ryan-Dia left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다.

좋은 티키타카였습니다.

다음 PR에서 또하죵 👍

@Choidongjun0830 Choidongjun0830 merged commit b732cfc into server-dev Jun 27, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

BE backend PR feat 새로운 기능 추가

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants