Skip to content

Feat/rate limit - #179

Merged
isoo127 merged 4 commits into
developfrom
feat/rate-limit
Sep 15, 2026
Merged

isoo127 merged 4 commits into
developfrom
feat/rate-limit

Conversation

@isoo127

@isoo127 isoo127 commented Sep 15, 2026

Copy link
Copy Markdown
Member

✏️ 작업 개요

인증 관련 무차별 대입 차단과 재화 정합성 문제를 함께 다뤘습니다.
조사 중 나온 결제 API의 중복 지급·커넥션 점유 문제를 같이 고쳤고, 마지막으로 퍼즐 풀이 보상 정책을 변경했습니다.

전역 IP rate limit은 Cloudflare에서 처리하기로 해서 서버에는 넣지 않았습니다. 이 PR의 제한은 전부 IP로는 막을 수 없는 계정·유저 단위 로직입니다.

🔨 작업 상세 내용

  1. 이메일 인증코드 무차별 대입 차단 + 코드 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
  2. 로그인 무차별 대입 — 계정 단위 점증 잠금

    • IP 제한만으로는 패스워드 스프레이(한 IP에서 계정 1000개에 흔한 비번 1개)와 분산 공격(IP 1000개 × 계정당 5회)을 막지 못해, 계정(email) 키 카운터를 따로 뒀습니다.
    • LoginAttemptService 신설. 실패 10회부터 잠금이 걸리고 이후 실패마다 2배로 늘어납니다 — 5분 → 10분 → 20분 → 40분 → 60분(상한)
    • 하드 잠금이 아닌 점증 지연입니다. 영구 잠금은 그 자체로 피해자를 쫓아내는 DoS가 됩니다.
    • failCount는 잠금이 풀린 뒤에도 24시간 유지돼 다음 실패가 이어서 escalate 됩니다.
    • 존재하지 않는 이메일도 실패로 집계합니다. 안 그러면 잠금 여부 자체가 계정 존재 오라클이 됩니다.
    • 같은 자격증명을 검증하는 3경로 전부에 적용: 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으로 변경했습니다. (클라이언트 확인 필요)
  3. 재화 변경 경로에 행 잠금

    • 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이라 읽고-쓰는 구간 자체가 없어 락이 불필요합니다.
  4. 결제 API 안정화

    • 안드로이드 중복 지급 차단. transactionId를 클라이언트가 보내면 그 값을 그대로 쓰고 구글이 검증해준 orderId를 덮어썼습니다. 동시에 두 번 쏘면 둘 다 existsByPurchaseToken을 통과하고, 서로 다른 transactionId 때문에 uk_transaction_id에도 안 걸려 영수증 하나로 재화가 2배 지급됐습니다. 이제 verificationResult.transactionId()만 사용합니다.
      • 구글이 orderId를 주지 않는 경우(프로모/테스트 구매 등)를 대비해 purchaseToken으로 폴백합니다. transaction_id가 nullable인데 MySQL 유니크 인덱스는 NULL 중복을 허용해서, null이면 제약이 무력화됩니다.
      • iOS는 transactionId를 애플 응답과 대조 검증하고 있어 원래 안전합니다. 손대지 않았습니다.
    • RestClient 타임아웃 설정 (connect 3s / read 5s). RestClient.Builder를 주입만 받고 타임아웃 설정이 없어 connect/read 모두 무제한이었습니다. 스토어 검증은 트랜잭션 안에서 돌기 때문에, 호출이 멎으면 DB 커넥션을 무한정 붙잡습니다. RestClientCustomizer 빈이라 두 검증기 모두에 자동 적용됩니다.
    • HikariCP 풀 10 → 20. 기존 10이면 느린 검증 10건으로 풀이 고갈되고, 그러면 퍼즐 조회·랭킹·로그인까지 전부 커넥션을 못 받습니다. 스토어 장애가 서비스 전체 장애가 되는 구조였습니다.
      • connection-timeout: 10000 — 기본 30초는 풀이 비었을 때 요청이 쌓이기만 합니다
      • leak-detection-threshold: 20000 — 외부 API에 물린 상황이 정확히 이 모양이라 로그로 드러납니다
  5. 퍼즐 풀이 보상 정책 변경

    • 트레이닝 보상을 난이도 무관 10으로 통일. TRAINING_LOW/MIDDLE/HIGH_REWARD(10/30/50) → TRAINING_REWARD(10). 모든 분기가 같은 값이 되어 TrainingService의 난이도 switch는 제거했습니다.
    • 커뮤니티 퍼즐 풀이에 보상 10 신설(COMMUNITY_REWARD). 본인 문제와 이미 푼 문제는 0입니다.
    • 판정에 checkIsSolvedPuzzle을 씁니다. 기존 solvePuzzle()의 updatedRows는 행 존재 여부만 알려줘서, 좋아요만 누른 유저도 1이 나옵니다.
    • 이 판정은 반드시 풀이 적용 전에 읽어야 해서, applySolveCommunityPuzzle이 첫 풀이 여부를 반환하도록 바꿔 순서 제약을 private 메서드 안에 가뒀습니다.
    • 풀이 수(solvedCount)도 첫 풀이에만 증가합니다. 기존에는 같은 유저가 반복 호출할 때마다 올라갔고, 이 값은 getPuzzlerRanking 산정에 쓰입니다.
    • 정답 구매(getCommunityPuzzleAnswer)는 보상 없음 유지입니다. 정답을 사면 solved로 기록되므로 이후 /solve를 호출해도 보상이 없습니다.

⚠️ 클라이언트 영향

항목 변경
POST /api/community/puzzle/{puzzleId}/solve 응답 Void → SolveCommunityPuzzleResponse ({ "reward": 10 })
에러 코드 A429 → A4290, A4291·A4292 신설
트레이닝 보상 난이도별 10/30/50 → 전부 10

테스트

전체 229개 통과 (신규 LoginAttemptServiceTest 10개, EmailServiceTest 9 → 14개, 커뮤니티 보상 3개 등).
Testcontainers 기반 테스트는 도커 소켓이 필요합니다. 없으면 컨텍스트 로드 실패 1건이 ApplicationContext failure threshold를 건드려 50개가 한꺼번에 실패한 것처럼 보입니다.

PaymentService.java / PaymentServiceTest.java는 CRLF → LF 정규화가 겹쳐 전체 파일이 diff에 잡혔지만, 실제 변경은 각각 8줄 / 6줄입니다. (--ignore-cr-at-eol 기준 전체 26파일 +656 / −106)

💡 생각해볼 문제

  • 유저 단위 rate limit은 이 PR에 없습니다. Cloudflare는 IP는 보지만 JWT의 userId는 못 봅니다. 결제 verify, 재화 구매, 닉네임 변경 같은 유저 단위 한도는 CDN으로 대체가 안 되는데, 재화 정합성은 락으로 잡혔고 나머지는 남용 방지 목적이라 일단 넘겼습니다.
  • attemptCount 증가가 read-modify-write라 병렬 요청에서 일부 누락될 수 있습니다. 기존 발송 count도 같은 구조고 50회가 넉넉해서 그대로 뒀는데, 고volume 병렬 추측의 실질 방어선은 Cloudflare의 IP 제한입니다.
  • 발송 5회/24h 제한 때문에 공격자가 피해자 주소로 5번 쏘면 그 유저는 24시간 비번 재설정이 불가능합니다. 시도 제한으로 재발송 요구가 늘면 이 창이 더 빨리 소진됩니다. 발송 한도를 시간당으로 쪼개는 걸 검토해볼 만합니다.
  • BCrypt CPU 고갈은 미대응입니다. 3코어 기준 초당 37회(분당 2,250회) 면 CPU가 포화되고, 이건 IP 한도를 전부 준수하면서도 성립합니다. Cloudflare가 문턱을 올려주지만 분산 공격은 못 막습니다. Prometheus에 CPU 징후가 보이면 비밀번호 검증에 세마포어를 거는 정도로 대응 가능합니다.
  • 이메일 발송 전역 총량 상한도 미적용입니다. IP를 분산하면 Gmail 발송 한도 소진(→ 가입·재설정 전면 중단)과 도메인 평판 하락이 가능하고, 평판은 한 번 떨어지면 되돌리기 어렵습니다.
  • 비관적 락은 DB가 하나라는 전제입니다. 앱 서버가 여러 대여도 동작하지만, innodb_lock_wait_timeout(기본 50초) 동안 대기가 생길 수 있습니다. 현재 트랜잭션들은 짧고, 결제도 외부 호출 뒤에 락을 잡아 보유 구간이 짧습니다.
  • RankService.resultRankGame의 rating/mmr은 여전히 경합합니다. 다만 기준값이 DB가 아니라 Redis 세션의 ratingBeforePenalty라 DB 락만으로는 안 풀립니다. 증상도 중복 이득이 아니라 진행 소실(퍼즐 2개 풀고 1개분만 반영)이라 후순위로 뒀습니다. 고치려면 기준값을 DB로 옮기거나 세션에 CAS가 필요합니다.
  • purchase_token에는 유니크 제약이 없습니다. 4번으로 실질 구멍은 막혔지만 DB 레벨 최종 방어선을 하나 더 두려면 추가할 수 있습니다. ddl-auto: update라 기존 데이터에 중복이 있으면 기동이 실패할 수 있어 확인이 선행돼야 합니다.
  • 결제 검증의 외부 호출이 여전히 트랜잭션 안에 있습니다. 타임아웃(최대 ~16초, 애플은 sandbox 재시도로 왕복 2회)과 풀 20으로 피해를 묶었다고 보고 트랜잭션 분리는 하지 않았습니다. 분리하려면 위 유니크 제약이 선행돼야 합니다.
  • 구글 OAuth 토큰 발급은 GoogleCredentials가 자체 HTTP 트랜스포트를 써서 이번 타임아웃 설정이 적용되지 않습니다. 라이브러리 기본값 20초라 무한 대기는 아닙니다.
  • solvedAt은 재호출 시 갱신됩니다. findUsersWhoSolvedPuzzlesSince가 랭킹 캐시 갱신 대상 선정에 쓰는 값이라 의미가 바뀌어서 건드리지 않았습니다.

@isoo127
isoo127 merged commit 83502bb into develop Sep 15, 2026
1 check passed
@isoo127
isoo127 deleted the feat/rate-limit branch September 15, 2026 13:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant