Feat/rate limit - #179
Merged
Merged
Feat/rate limit#179
Conversation
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
✏️ 작업 개요
인증 관련 무차별 대입 차단과 재화 정합성 문제를 함께 다뤘습니다.
조사 중 나온 결제 API의 중복 지급·커넥션 점유 문제를 같이 고쳤고, 마지막으로 퍼즐 풀이 보상 정책을 변경했습니다.
전역 IP rate limit은 Cloudflare에서 처리하기로 해서 서버에는 넣지 않았습니다. 이 PR의 제한은 전부 IP로는 막을 수 없는 계정·유저 단위 로직입니다.
🔨 작업 상세 내용
이메일 인증코드 무차별 대입 차단 + 코드 1회용
confirmCode에 시도 횟수 제한이 전혀 없었습니다. 6자리 숫자(10⁶)에 유효시간 5분이라,POST /api/auth/password/reset/email로 코드를 한 번 발송시킨 뒤 연타하면 비밀번호 재설정 = 계정 탈취가 가능했습니다.AuthEmailEntity에attemptCount,verified추가. 실패 시 카운트를 올리고 50회 초과 시EXCEED_EMAIL_AUTH_ATTEMPT(429)로 차단해 재발송을 유도합니다. 일반 사용자는 닿을 일 없는 값으로 잡았습니다.verified = true로 코드를 1회용 처리합니다. 기존에는 성공 후에도 5분간 재사용이 가능했습니다.count, 5회/24h)가 같이 날아가, 시도를 소진하거나 인증을 완료하는 것만으로 발송 한도를 리셋하는 우회로가 생깁니다.attemptCount는 0으로 초기화됩니다. 발송 5회 × 시도 50회 = 하루 최대 250회로, 10⁶ 대비 무시할 수준입니다.confirmCode가 쓰기를 하게 되어@Transactional(readOnly = true)→@Transactional로그인 무차별 대입 — 계정 단위 점증 잠금
LoginAttemptService신설. 실패 10회부터 잠금이 걸리고 이후 실패마다 2배로 늘어납니다 — 5분 → 10분 → 20분 → 40분 → 60분(상한)failCount는 잠금이 풀린 뒤에도 24시간 유지돼 다음 실패가 이어서 escalate 됩니다.POST /api/auth/login,POST /admin/login,POST /api/auth/test-token. 하나라도 빠지면 그쪽으로 우회됩니다.ErrorCode에EXCEED_EMAIL_AUTH_ATTEMPT(A4291),EXCEED_LOGIN_ATTEMPT(A4292) 추가. 번호 체계를 맞추려고 기존EXCEED_EMAIL_AUTH_REQUEST를A429→A4290으로 변경했습니다. (클라이언트 확인 필요)재화 변경 경로에 행 잠금
UserEntity.purchase()가currency >= price를 읽고 검사한 뒤 쓰는 구조인데 락이 없었습니다. InnoDB 기본 격리수준(REPEATABLE READ)에서 일반SELECT는 스냅샷만 읽고 락을 걸지 않아, 동시 요청 두 건이 같은 잔액을 읽고 둘 다 통과합니다. 1000원으로 1000원짜리 팩 2개를 살 수 있었습니다.UserRepository.findByIdForUpdate(SELECT ... FOR UPDATE)를 추가하고 재화를 바꾸는 7개 경로 전부를 여기로 통일했습니다.FOR UPDATE는 스냅샷이 아닌 current read라, 대기 후 커밋된 최신 값을 다시 읽습니다.@Version낙관적 락 대신 비관적 락을 택했습니다. 기존 코드가 이미 전부findById→ 엔티티 변경 패턴이라 메서드 이름 교체로 끝나고, 충돌 시 실패 대신 직렬화되므로 재시도 로직도 새 에러 코드도 필요 없습니다. 유저당 구매는 저빈도라 대기 비용이 사실상 0입니다.purchaseTrainingPack이 제일 나빴습니다. 다른 경로는 전부 새로 조회하는데 여기만 시큐리티 컨텍스트의 detached 엔티티를 직접 차감하고save()로 머지했습니다. merge는 스냅샷 전체를 덮어쓰므로, 요청 시작 시점 이후 바뀐 currency·rating·mmr이 클로버됩니다. 경합 이전에 단독으로도 틀린 코드였습니다.RankService.endRankGame에@Transactional이 없었습니다. 락은 트랜잭션 종료 시 풀리므로 트랜잭션이 없으면 보호 구간이 0입니다. 추가 후 더티 체킹이 처리하므로save()는 제거했습니다.purchaseTrainingPuzzleAnswer가 차감은 새로 조회한 엔티티에 하고 하위 호출엔 detached 엔티티를 넘기고 있어 하나로 통일했습니다.applySolveTrainingPuzzle은user_pack을 먼저 갱신하고 유저를 나중에 읽어서,purchaseTrainingPack(user → user_pack)과 락 순서가 반대였습니다. 같은 유저로 동시 요청이 오면 순환 대기가 성립합니다. 진입점이 락을 먼저 잡고applySolveTrainingPuzzle이 그 엔티티를 받도록 구조를 바꿨습니다.NoticeService의 출석 보상은 제외했습니다.UPDATE ... SET currency = currency + 200은 단일 원자 SQL이라 읽고-쓰는 구간 자체가 없어 락이 불필요합니다.결제 API 안정화
transactionId를 클라이언트가 보내면 그 값을 그대로 쓰고 구글이 검증해준 orderId를 덮어썼습니다. 동시에 두 번 쏘면 둘 다existsByPurchaseToken을 통과하고, 서로 다른transactionId때문에uk_transaction_id에도 안 걸려 영수증 하나로 재화가 2배 지급됐습니다. 이제verificationResult.transactionId()만 사용합니다.purchaseToken으로 폴백합니다.transaction_id가 nullable인데 MySQL 유니크 인덱스는 NULL 중복을 허용해서, null이면 제약이 무력화됩니다.transactionId를 애플 응답과 대조 검증하고 있어 원래 안전합니다. 손대지 않았습니다.RestClient.Builder를 주입만 받고 타임아웃 설정이 없어 connect/read 모두 무제한이었습니다. 스토어 검증은 트랜잭션 안에서 돌기 때문에, 호출이 멎으면 DB 커넥션을 무한정 붙잡습니다.RestClientCustomizer빈이라 두 검증기 모두에 자동 적용됩니다.connection-timeout: 10000— 기본 30초는 풀이 비었을 때 요청이 쌓이기만 합니다leak-detection-threshold: 20000— 외부 API에 물린 상황이 정확히 이 모양이라 로그로 드러납니다퍼즐 풀이 보상 정책 변경
TRAINING_LOW/MIDDLE/HIGH_REWARD(10/30/50) →TRAINING_REWARD(10). 모든 분기가 같은 값이 되어TrainingService의 난이도 switch는 제거했습니다.COMMUNITY_REWARD). 본인 문제와 이미 푼 문제는 0입니다.checkIsSolvedPuzzle을 씁니다. 기존solvePuzzle()의updatedRows는 행 존재 여부만 알려줘서, 좋아요만 누른 유저도 1이 나옵니다.applySolveCommunityPuzzle이 첫 풀이 여부를 반환하도록 바꿔 순서 제약을 private 메서드 안에 가뒀습니다.solvedCount)도 첫 풀이에만 증가합니다. 기존에는 같은 유저가 반복 호출할 때마다 올라갔고, 이 값은getPuzzlerRanking산정에 쓰입니다.getCommunityPuzzleAnswer)는 보상 없음 유지입니다. 정답을 사면 solved로 기록되므로 이후/solve를 호출해도 보상이 없습니다.POST /api/community/puzzle/{puzzleId}/solveVoid→SolveCommunityPuzzleResponse({ "reward": 10 })A429→A4290,A4291·A4292신설테스트
전체 229개 통과 (신규
LoginAttemptServiceTest10개,EmailServiceTest9 → 14개, 커뮤니티 보상 3개 등).Testcontainers 기반 테스트는 도커 소켓이 필요합니다. 없으면 컨텍스트 로드 실패 1건이
ApplicationContext failure threshold를 건드려 50개가 한꺼번에 실패한 것처럼 보입니다.💡 생각해볼 문제
attemptCount증가가 read-modify-write라 병렬 요청에서 일부 누락될 수 있습니다. 기존 발송count도 같은 구조고 50회가 넉넉해서 그대로 뒀는데, 고volume 병렬 추측의 실질 방어선은 Cloudflare의 IP 제한입니다.innodb_lock_wait_timeout(기본 50초) 동안 대기가 생길 수 있습니다. 현재 트랜잭션들은 짧고, 결제도 외부 호출 뒤에 락을 잡아 보유 구간이 짧습니다.RankService.resultRankGame의 rating/mmr은 여전히 경합합니다. 다만 기준값이 DB가 아니라 Redis 세션의ratingBeforePenalty라 DB 락만으로는 안 풀립니다. 증상도 중복 이득이 아니라 진행 소실(퍼즐 2개 풀고 1개분만 반영)이라 후순위로 뒀습니다. 고치려면 기준값을 DB로 옮기거나 세션에 CAS가 필요합니다.purchase_token에는 유니크 제약이 없습니다. 4번으로 실질 구멍은 막혔지만 DB 레벨 최종 방어선을 하나 더 두려면 추가할 수 있습니다.ddl-auto: update라 기존 데이터에 중복이 있으면 기동이 실패할 수 있어 확인이 선행돼야 합니다.GoogleCredentials가 자체 HTTP 트랜스포트를 써서 이번 타임아웃 설정이 적용되지 않습니다. 라이브러리 기본값 20초라 무한 대기는 아닙니다.solvedAt은 재호출 시 갱신됩니다.findUsersWhoSolvedPuzzlesSince가 랭킹 캐시 갱신 대상 선정에 쓰는 값이라 의미가 바뀌어서 건드리지 않았습니다.