[BOM-1122] feat: 읽기 카운트 증가 제한 추가#757
Hidden character warning
Conversation
| Long memberId, | ||
| Long articleId | ||
| Long articleId, | ||
| LocalDateTime readAt |
There was a problem hiding this comment.
원래 이벤트를 받아 이벤트 리스너나 그 이후 서비스에서 LocalDate.now(clock)을 사용하려 했으나,
이벤트를 수신하여 실행하는 시점과 실제 읽은 시점에 대한 차이가 생길 수도 있다고 생각되었습니다.
그래서 이벤트 생성 시점에 읽은 시점을 기록하여 넘기도록 했습니다.
There was a problem hiding this comment.
P4
readAt을 도입한 의도가 실제 읽은 시점 기준으로 처리하기 위함이라면,
isTodayArticle처럼 MarkAsRead를 소비하는 다른 로직들도 event.readAt() 기준으로 맞추면 더 일관적일 것 같아요!
There was a problem hiding this comment.
| 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; |
There was a problem hiding this comment.
try-catch로 감싸져있을 경우 트랜잭션 프록시로 예외가 전파되지 않아 롤백되지 않습니다.
try-catch를 제거하고 pr 코멘트에서 말씀드린 것처럼 토큰 차감과 읽기 count 증가를 한 트랜잭션으로 묶고, 펫 경험치 증가를 다른 트랜잭션에서 동작하도록 하여 펫 경험치 증가의 실행 결과가 다른 트랜잭션에 영향을 주지 않도록 했습니다.
| @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) |
There was a problem hiding this comment.
이 쿼리는 토큰 버킷 방식으로 "지금 요청 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) |
There was a problem hiding this comment.
아래와 같은 이유로 member_id를 PRIMARY KEY로 두었습니다.
- 한 회원은 무조건 한 버킷만 가진다
- 동일 회원에 대해 중복 row가 생기면 안 된다
- 조회/업데이트가 항상 WHERE member_id = ?로 일어난다
- 따라서, member_id로 자동으로 유니크와 인덱스가 걸려 효율적
There was a problem hiding this comment.
회원 탈퇴 시 row 정리만 잘 된다면 괜찮은 방법인 것 같습니다~!
There was a problem hiding this comment.
이거는 한번 다른 PR에서 싹 정리해봐야 할거 같습니다!
엔티티만 계속 추가되고 탈퇴 시 정리가 안되는거 같아서 ㅎㅎ
jira에 티켓 만들어둘게요!
6916daf to
3c47e71
Compare
Ryan-Dia
left a comment
There was a problem hiding this comment.
수고하셨습니다.
좋은 티키타카였습니다.
다음 PR에서 또하죵 👍
📌 What
이달의 독서왕 어뷰징 방지를 위한 읽기 카운트 증가 제한을 추가했습니다.
❓ Why
PATCH /articles/{id}/readAPI를 짧은 시간 안에 반복 호출하면 월간 읽기 카운트를 비정상적으로 증가시킬 수 있었습니다. 실제로 1분에 39개를 읽은 사용자가 존재했습니다.정상 사용자가 짧은 아티클을 연속으로 읽는 burst 패턴은 허용하면서, 카운트만 부풀리는 어뷰징 패턴은 차단할 필요가 있었습니다.
🔧 How
1. 알고리즘 선택: Token Bucket
Fixed Window: 윈도우 경계에서 burst 통과 문제Sliding Window: 요청 이력 저장/조회 비용 큼Leaky Bucket: 처리 속도 제어용이라 "count 인정 여부 판단"에는 부적합Token Bucket(채택): 짧은 burst 허용 + 장기적으로 일정 속도 제한. 회원당 row 1개로 관리 가능2. 정책값 (데이터 기반 결정)
버킷 용량: 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명):

burst 용량을 3으로 설정하면
2. 재충전 시간
연속된 read 이벤트 사이의 간격(
gap_seconds > 0) 분포를 분석한 결과 (총 7,890개 pair, 265명):(간격의 의미:
A 아티클 50% 이후 나머지 체류 시간+B 아티클로 이동 시간+B 아티클 50%까지 스크롤 시간)각 percentile에서 5분 / 1시간 동안 인정 가능한 read count로 환산하면:
정책값은 yml로 관리하여 운영 중 데이터 추이를 보며 조정 가능
3. 동시성 처리
SELECT → 계산 → UPDATE 구조는 race condition 발생 가능. 단일 UPDATE 문으로 원자적 처리:
affectedRows = 1 → 허용 / affectedRows = 0 → 차단
4. 라이브러리 미사용 이유
Guava, Resilience4j: 메모리 기반이라 다중 인스턴스 환경에서 상태 공유 불가
Bucket4j-MySQL: SELECT FOR UPDATE 기반이라 락 보유 시간이 길고 쿼리가 여러 번 발생
→ DB 원자적 UPDATE 직접 구현이 더 적합하다고 판단
5. 트랜잭션 설계
MarkAsReadListener.on내부 트랜잭션 구조:펫 경험치 갱신 실패는 try-catch로 격리 → 토큰 차감과 읽기 카운트에 영향 없음
읽기 카운트 갱신 실패 시에는 토큰 차감도 같이 롤백 + 펫 경험치는 호출 안 됨
👀 Review Point (Optional)
혹시 더 보고 싶으시다면.
Token Bucket에 대한 설계와 정책 결정은 여기서 더 보실 수 있습니다.