Skip to content

fix(S15P11A705-131): Refresh 재사용 시 회원 단위 세션 폐기 - #81

Merged
cherry-go-round merged 3 commits into
devfrom
fix/S15P11A705-131-refresh-reuse-family-revocation
Jul 29, 2026
Merged

fix(S15P11A705-131): Refresh 재사용 시 회원 단위 세션 폐기#81
cherry-go-round merged 3 commits into
devfrom
fix/S15P11A705-131-refresh-reuse-family-revocation

Conversation

@cherry-go-round

Copy link
Copy Markdown
Contributor

요약

Refresh 재사용을 감지해도 401만 돌려주고 끝나, 토큰이 유출되면 공격자 세션이 최대 7일 살아남았다. 공격자가 먼저 회전하면 정상 사용자만 끊기고 공격자가 방금 받은 토큰은 유효하다. 재사용을 유출 신호로 보고 그 회원의 Refresh를 전부 폐기한다(RFC 9700 §4.14.2).

Jira (필수)

관련 GitHub Issue (선택)

변경 사항

  • RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId> Set)와 revokeAll(memberId) 추가
    • savejti를 인덱스에 넣고 인덱스 TTL을 발급마다 다시 건다
    • consume이 소비한 jti를 인덱스에서 뺀다(정리 목적, 정확성과 무관)
    • SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에 권장되지 않아 BD-32가 이미 기각한 안
  • AuthTokenService.rotate가 재사용 감지 시 revokeAll 호출 후 401
  • 테스트 2개 추가, 기존 회전 테스트 1개 재구성
  • BD-35 신규, BD-32 → Superseded by BD-35(상태만 갱신), BI-21 + 인덱스 + WORKLOG

테스트 / 검증

  • ./gradlew clean check --no-daemon
  • DB 변경 시 PostgreSQL 통합 테스트 — IntegrationContainerSupport(PostgreSQL + Redis 컨테이너) 기반
  • 되돌리기 어려운 결정을 포함하면 docs/backend/decisions/에 BD 추가 또는 기존 BD 링크 — BD-35

RED — 구현 전, 완료 조건을 그대로 옮긴 테스트가 실패했다.

$ ./gradlew test --no-daemon --tests 'com.pinlog.pinlogback.domain.auth.AuthTokenContractTests'

인증 토큰 계약 > Refresh 재사용이 감지되면 그 회원의 다른 세션도 전부 끊긴다 FAILED
    org.opentest4j.AssertionFailedError

AssertionFailedError: [재사용이 감지됐으면 다른 기기의 Refresh도 끊겨야 한다]
expected: 401
 but was: 204

13 tests completed, 1 failed
BUILD FAILED

GREEN — 구현 후 같은 명령.

$ ./gradlew test --no-daemon --tests 'com.pinlog.pinlogback.domain.auth.AuthTokenContractTests'
BUILD SUCCESSFUL in 29s   (13개 통과)

Regressiondev rebase 후 전체.

$ ./gradlew clean check --no-daemon
BUILD SUCCESSFUL in 1m 15s
총 295개 · 실패 0 · 에러 0 · skip 0

배경

BD-32가 계열 폐기를 미룬 근거는 위험의 크기가 아니라 PR 크기였다(#73이 파일 100개를 넘겼다). 이 PR이 그 트리거를 소화한다.

폐기 범위를 좁히는 안(토큰마다 계열 id 부여)을 검토하고 기각했다 — 유출된 토큰과 정상 토큰이 같은 계열에 있으므로 공격자만 골라 끊을 수 없다. 좁히기의 이득은 오탐 범위뿐인데 그 대가로 토큰에 계열 id를 넣고 회전마다 이어붙이는 구조를 진다. 자세한 트레이드오프는 BD-35에 있다.

리뷰 포인트

  1. 오탐을 감수한다. 클라이언트가 같은 Refresh를 두 번 보내기만 해도 전체 로그아웃이 된다. 서버는 재사용과 유출을 구별할 수 없고, 미탐(공격자 7일 생존)의 대가가 훨씬 크므로 안전한 쪽을 골랐다. 방어선은 공용 계약이 이미 명시한 "재발급 요청은 동시에 하나만"이다(08 §3.3). 이 판단에 이견이 있으면 여기서 봐 주세요.
  2. 기존 테스트가 정반대 사실을 고정하고 있었다. refreshRotatesAndInvalidatesPreviousToken이 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를 단언했다. 지우지 않고 순서를 뒤집었다 — 두 계약을 한 단언에 섞으면 나중에 어느 쪽이 깨졌는지 알 수 없다.
  3. 폐기 대상에 회전으로 갓 발급된 토큰이 들어간다. 유출 시나리오에서 그것이 공격자가 들고 있을 토큰이라 빼면 폐기가 무의미하다. 별도 단언으로 고정했다.
  4. 과잉 폐기 회귀도 넣었다. normalRotationKeepsOtherSessionsAlive — 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다. 구현 전에도 통과하고 후에도 통과해야 하는, 방향이 반대인 단언이다.

미결 / 후속

  • 공용 API 명세(08 §3.3)는 고치지 않았다. 명세는 "재사용: 401"까지만 정의하고 거짓을 말하지 않으며, 이 변경으로 클라이언트가 취할 행동이 달라지지 않는다(다른 세션이 끊기는 것은 서버 내부 보안 속성이다). 적어야 한다는 의견이면 별도로 올린다.
  • 오탐 관측 경로가 없다. 재사용 감지 WARN은 남지만 유출인지 클라이언트 버그인지 구별할 정보가 로그에 없다. 오탐이 문의로 올라오면 BD-35의 재검토 트리거를 따른다.
  • 회원 탈퇴(S15P11A705-65)가 같은 revokeAll을 쓴다. BD-32가 "두 작업을 같이 하는 것이 자연스럽다"고 적어 둔 이유이고, 이번에 그 연산이 준비됐다.
  • BD/BI 번호를 재배정했다. 처음 BD-34/BI-20으로 썼는데 dev의 Feed 작업(feat(S15P11A705-120): Feed 추천 MVP — 후보 3채널·결정적 점수·opaque cursor #77)이 같은 번호를 먼저 확정해 BD-35/BI-21로 옮겼다. decisions/README의 "번호는 dev 머지 순서로 확정된다" 규칙대로다.

🤖 Generated with Claude Code

재사용 감지가 401만 돌려주고 끝나 유출 시 공격자 세션이 살아남았다. 공격자가
먼저 회전하면 정상 사용자만 401로 끊기고, 공격자가 방금 받은 토큰은 최대 7일
유효하다(RFC 9700 §4.14.2). BD-32가 PR 크기 때문에 미뤄 둔 구멍이다.

RED — 한 회원의 두 세션 중 하나를 회전 후 재사용했을 때
  expected: 401 but was: 204  (다른 기기의 Refresh가 살아 있었다)

GREEN
- RefreshTokenStore에 회원별 인덱스(auth:refresh-index:<memberId> Set)와
  revokeAll(memberId) 추가. SCAN은 쓰지 않는다 — 키 공간에 비례해 운영 Redis에
  권장되지 않아 BD-32가 이미 기각했다
- rotate가 재사용을 감지하면 revokeAll 후 401

구현하며 드러난 것
- 기존 회전 테스트가 재사용 401 확인 뒤에 "회전 후 토큰은 계속 유효하다"를
  단언해 정반대 사실을 고정하고 있었다. 지우지 않고 순서를 뒤집었다
- 폐기 대상에 회전으로 갓 발급된 토큰이 들어가야 한다. 유출 시나리오에서
  그것이 공격자가 들고 있을 토큰이라, 빼면 폐기가 무의미하다
- SADD만 하면 Set에 TTL이 없어 영구 키가 된다. 발급마다 EXPIRE를 다시 건다

과잉 폐기를 잡는 반대 방향 회귀도 넣었다(normalRotationKeepsOtherSessionsAlive)
— 폐기가 감지 밖으로 새면 세션 독립성(BD-21)이 조용히 깨진다.

감수하는 것은 오탐이다. 클라이언트가 같은 토큰을 두 번 보내면 전체 로그아웃이
된다. 서버는 재사용과 유출을 구별할 수 없고 미탐의 대가가 훨씬 크므로 안전한
쪽을 골랐다. 방어선은 08 §3.3의 "재발급은 동시에 하나만"이다.

REFACTOR
- 인라인 주석을 Javadoc으로 옮겼다. 폐기 근거와 오탐 트레이드오프는 코드를 읽는
  사람만이 아니라 이 API를 쓰는 사람이 알아야 한다
- 틀린 근거를 단 주석을 지웠다 — 접두사를 나눠야 키가 겹치지 않는다고 썼지만
  auth:refresh:123과 auth:refresh:123:abc는 애초에 겹치지 않는다
- key/index → tokenKey/indexKey, valid → consumed(delete 후 변수라 "지금
  유효하다"로 읽히면 틀리다), deviceA → deviceARefresh(토큰 문자열이다)

문서: BD-35 신규(새 트레이드오프), BD-32는 Superseded by BD-35로 상태만 갱신,
BI-21 + 인덱스 + WORKLOG. 처음 BD-34/BI-20으로 썼다가 dev의 Feed 작업(#77)이
같은 번호를 먼저 확정해 재배정했다 — 번호는 dev 머지 순서로 확정된다.

회원 탈퇴(S15P11A705-65)가 같은 revokeAll을 쓴다.

Refs #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@minyongP minyongP 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.

로컬 dev(dda2ed3)와 대조해 봤습니다. RFC 9700 근거, (c) 계열 id 기각 논리("유출 토큰과 정상 토큰이 같은 계열"), 반대 방향 회귀와 전제 단언(⑤)까지 — 보안 변경으로서 갖춰야 할 것을 다 갖췄습니다. 기존 테스트를 지우지 않고 순서를 뒤집은 판단(리뷰 포인트 2)도 옳습니다. logoutrevokeAll이 아니라 consume을 쓰는 것도 맞습니다(한 기기 로그아웃이 다른 기기를 끊으면 안 됩니다).

문제는 폐기 연산이 원자적이지 않다는 것이고, 둘 다 이 PR이 막으려는 바로 그 시나리오에서 열립니다.

🔴 #80과 BD-35·BI-21 번호가 충돌합니다

같이 열려 있는 #80이 BD-35BI-21을 똑같이 씁니다.

이 PR #80
BD-35 BD-35-refresh-reuse-family-revocation.md BD-35-ai-derived-invalidation-inside-deletion-transaction.md
BI-21 BI-21-2026-07-29-refresh-reuse-family-revocation.md BI-21-2026-07-29-ai-derived-invalidation-on-delete.md

파일 이름이 달라 git이 충돌로 잡지 않습니다. WORKLOG.mdmerge=union이라 두 행이 나란히 들어가고, 각각 서로 다른 파일을 BD-35·BI-21로 링크합니다. dev의 2026-07-29 기록이 V4·BD-29·BI-18로 이미 한 번 남긴 실패 형태 그대로입니다 — "텍스트 충돌이 없는 것이 더 위험한 경우".

본문 "미결 / 후속"에 BD-34/BI-20 선점 때문에 이미 한 번 재배정하셨다고 적혀 있는데, 같은 일이 #80과 다시 걸렸습니다. decisions/README.md 규칙대로 나중에 머지되는 쪽이 BD-36·BI-22입니다. 이 PR은 decisions/README.md·implements/README.md 색인을 이미 갱신해 뒀으니, 이 PR이 먼저 머지되면 #80이 재배정 + 색인 추가를 하면 됩니다(#80은 색인을 갱신하지 않았습니다).

🔴 revokeAll의 read-then-delete 경합 — 공격자 토큰이 살아남습니다

Set<String> tokenIds = redisTemplate.opsForSet().members(indexKey);   // ① 스냅샷
...
redisTemplate.delete(tokenKeys);                                       // ③ 스냅샷만 삭제
redisTemplate.delete(indexKey);                                        // ④ 인덱스 통째 삭제

①과 ③ 사이에 정상 회전 한 건이 끼면(savej3을 인덱스에 추가) — j3은 스냅샷에 없어 ③에서 살아남고, ④가 인덱스를 지워 j3이 인덱스에서도 사라집니다. 결과는 *"유효하지만 이후 어떤 revokeAll로도 폐기되지 않는 Refresh 토큰"*입니다.

가설적이지 않다고 보는 이유: 재사용 감지는 회전 요청이 트리거이고, 유출 시나리오에서 정상 클라이언트와 공격자는 구조적으로 동시에 회전을 시도합니다. 창이 좁아도 폐기의 목적을 정확히 무력화합니다.

SMEMBERS + DEL을 Lua 스크립트 하나로 묶으면 닫힙니다. revokeAll 호출 빈도가 극히 낮으니 비용도 문제없습니다.

🔴 save의 세 왕복도 원자적이지 않습니다

redisTemplate.opsForValue().set(tokenKey(...), "1", ttl);   // ①
redisTemplate.opsForSet().add(indexKey, tokenId);           // ②
redisTemplate.expire(indexKey, ttl);                        // ③
  • ①만 성공하고 ②가 실패하면 유효하지만 인덱스에 없는 토큰이 생깁니다 → revokeAll이 영원히 못 잡습니다(7일).
  • ②만 성공하고 ③이 실패하면 BI-21 ④가 고쳤다고 적은 바로 그 영구 키가 생깁니다.

SessionCallback 파이프라인이나 Lua 하나로 셋을 묶으면 둘 다 닫힙니다.

인덱스 TTL 계산 자체는 따로 검증해 봤는데 맞습니다 — save마다 인덱스 TTL을 다시 걸므로 항상 인덱스 만료 ≥ 모든 살아 있는 토큰의 만료가 성립합니다.

🟡 BD-35의 "감수하는 것"에 빠진 것 둘

  1. revokeAll은 Access 토큰을 못 끊습니다. stateless RS256이고 application.ymlaccess-token-ttl: 30m입니다. 따라서 요약의 *"공격자 세션이 즉시 끊긴다"*는 최대 30분 어긋납니다. RFC 9700 §4.14.2는 refresh 대상이라 구현은 준수 상태지만, 사고 대응하는 사람이 필요한 숫자라 결과 절에 남아야 합니다.
  2. 만료 전 구 Refresh 토큰이 "이 사용자를 전 기기에서 로그아웃시키는" 수단이 됩니다. 반복 사용도 가능합니다. 문서는 오탐을 *"클라이언트가 실수로 두 번 보냄"*으로만 틀 잡았는데, 이건 의도적 공격이라 성질이 다릅니다. 비대칭 논거가 여전히 유효하므로 결정을 바꿀 이유는 아니고, 오탐 항목 옆에 나란히 적어야 할 것으로 봅니다.

🟡 기록의 테스트 수가 서로 다릅니다

PR 본문은 clean check "총 295개", BI-21 마지막 줄과 WORKLOG 행은 **"229개 통과"**입니다. 자리 바뀜으로 보이고 dev의 직전 WORKLOG가 292개라 295가 맞을 것 같습니다. BI-21·WORKLOG는 오래 남는 기록이니 고쳐 주세요.

🟡 ④(인덱스 TTL)를 고정하는 테스트가 없습니다

BI-21이 *"SADD만 하면 영구 키가 된다"*를 문제로 짚고 expire 한 줄로 고쳤는데, 그 한 줄을 지켜 주는 단언이 없습니다. 나중에 누가 지우면 증상은 "Redis에 회원 수만큼 영구 키가 쌓임"이고 어떤 테스트도 실패하지 않습니다. TTL auth:refresh-index:<memberId> > 0 단언 하나면 됩니다.


정리하면 설계 판단(오탐을 감수하고 전량 폐기)은 되돌릴 필요가 없고, 원자성이 남은 일입니다. 머지 전 필수는 번호 재배정입니다.

@cherry-go-round cherry-go-round changed the title fix(S15P11A705-131): Refresh 재사용을 유출로 보고 회원의 세션을 전부 폐기한다 fix(S15P11A705-131): Refresh 재사용 시 회원 단위 세션 폐기 Jul 29, 2026
리뷰에서 지적된 🔴 두 건은 실제 결함이었고, 둘 다 이 변경이 막으려는
시나리오에서 정확히 열렸다.

① revokeAll의 read-then-delete 경합
SMEMBERS로 목록을 읽고 지우고 인덱스를 지우는 세 왕복 사이에 정상 회전 한 건이
끼면, 새 jti는 읽은 목록에 없어 삭제를 피하고 뒤따르는 인덱스 삭제가 그것을
인덱스에서도 지운다 → "유효하지만 이후 어떤 폐기로도 잡히지 않는 토큰". 재사용
감지는 회전 요청이 트리거이므로 유출 시나리오에서 이 창은 구조적으로 열린다.

놓친 것은 판단의 종류였다. BD-35에 "멱등하므로 동시 요청도 문제없다"고 적었는데,
멱등성은 같은 연산을 두 번 해도 같다는 성질이고 필요한 것은 다른 연산이 중간에
끼지 못한다는 성질이었다.

② save의 세 왕복도 같은 종류
토큰만 저장되고 SADD가 실패하면 인덱스에 없는 유효 토큰(7일간 폐기 불가),
EXPIRE가 실패하면 직전 커밋이 고쳤다고 적은 바로 그 영구 키가 생긴다.

둘 다 Lua 스크립트 하나로 묶었다. 대가는 폐기 스크립트가 토큰 키를 ARGV로
조립해 Redis Cluster 규약과 어긋나는 것이다(BD-35 재검토 트리거에 기록).

③ EXPIRE 한 줄을 지켜 주는 단언 추가
지우면 증상이 "회원 수만큼 영구 키 누적"뿐이고 HTTP 계약 테스트는 전부 통과한다.
RefreshTokenStoreTest로 Redis에 직접 만료를 묻는다. 통과만으로는 증거가 아니므로
EXPIRE를 임시로 빼서 -1(만료 없음)로 실패하는 것을 확인하고 원복했다.

④ BD-35의 감수 사항 둘 보강
- revokeAll은 Access를 끊지 못한다. stateless RS256 + 30분 수명이라 "즉시
  끊긴다"가 최대 30분 어긋난다. RFC 9700 §4.14.2 대상은 refresh라 준수 상태다
- 만료 전 구 Refresh가 대상 회원을 반복해서 전 기기 로그아웃시키는 수단이 된다.
  오탐의 악의적 쌍이며, 비대칭 논거가 유효해 결정은 바꾸지 않는다

⑤ 테스트 수 정정 — BI-21·WORKLOG가 dev 병합 전 수치(229)를 들고 있었다 → 295

번호 충돌(#80이 같은 BD-35·BI-21 사용)은 현행 유지한다. 푸시 시점에 #80은
미머지이고 dev에 두 번호가 없다. 나중에 머지되는 쪽이 재배정한다.

clean check 300개 통과, 실패 0

Refs #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cherry-go-round

Copy link
Copy Markdown
Contributor Author

리뷰 감사합니다. 지적하신 🔴 두 건은 실제 결함이었고, 둘 다 이 PR이 막으려는 시나리오에서 정확히 열렸습니다. 779fe79로 반영했습니다.

🔴 revokeAll read-then-delete 경합 — 고쳤습니다

지적하신 창(①과 ④ 사이의 정상 회전)이 그대로 열려 있었습니다. SMEMBERS+DEL을 Lua 하나로 묶었습니다.

제가 놓친 지점은 판단의 종류였습니다. BD-35에 "멱등하므로 두 요청이 동시에 들어와도 문제가 없다" 고 적었는데, 멱등성은 같은 연산을 두 번 해도 같다는 성질이고 여기 필요한 것은 다른 연산이 중간에 끼지 못한다는 성질이었습니다. 멱등성을 확인하고 원자성을 확인했다고 착각했습니다. 그 문장을 지웠습니다.

🔴 save의 세 왕복 — 같이 묶었습니다

같은 종류라는 지적이 맞습니다. 특히 EXPIRE 실패 케이스가 아팠습니다 — BI-21 ④가 "고쳤다"고 적은 바로 그 영구 키가 생기는 경로였고, 저는 명령을 추가해 문제를 고쳤지만 그 명령이 실행되지 않을 경우를 보지 않았습니다.

대가 하나를 기록했습니다: 폐기 스크립트가 토큰 키를 ARGV로 조립하므로 Redis Cluster 규약과 어긋납니다. 지금 운영은 단일 인스턴스이고, BD-35 재검토 트리거에 "클러스터로 가면 이 스크립트를 먼저 손봐야 한다"로 남겼습니다.

🟡 인덱스 TTL 단언 — 추가했습니다

RefreshTokenStoreTest를 만들어 Redis에 직접 만료를 물었습니다. 지적하신 대로 그 한 줄은 지워도 계약 테스트가 전부 통과합니다.

통과만으로는 증거가 아니므로 SAVE 스크립트에서 EXPIRE를 임시로 빼서 -1(만료 없음)로 실패하는 것을 확인하고 원복했습니다.

이 클래스에는 바깥에서 관측되지 않는 성질만 두었습니다 — 만료, 키 정리, 회원 간 격리, 반환 수. HTTP 경계에서 보이는 것은 AuthTokenContractTests가 이미 봅니다.

경합 자체를 재현하는 테스트는 넣지 않았습니다. 원자성은 구조로 보장되는 성질(Lua 한 번의 호출)이라 단언할 대상이 아니고, 스레드를 겹쳐 짜면 통과가 타이밍에 걸립니다. 판단이 다르시면 말씀해 주세요.

🟡 BD-35 감수 사항 둘 — 보강했습니다

  • revokeAll은 Access를 끊지 못한다 — stateless RS256 + access-token-ttl: 30m이라 요약의 "즉시 끊긴다"가 최대 30분 어긋납니다. 선택지 표의 (b) 장점 칸도 "세션을 이어갈 수단을 잃는다(진행 중인 Access는 최대 30분 남는다)"로 정확히 고쳤습니다. Access 무효화를 요구하면 검증 경로에 Redis 왕복이 붙는 큰 변경이라 별도 결정으로 트리거에 남겼습니다.
  • 만료 전 구 Refresh가 "전 기기 로그아웃" 수단이 된다 — 반복 가능한 의도적 공격이라는 지적이 맞습니다. 오탐을 "클라이언트 실수"로만 틀 잡았던 것을 고쳐, 오탐의 악의적 쌍으로 나란히 적었습니다. 비대칭 논거가 유효해 결정은 유지합니다.

🟡 테스트 수 불일치 — 제 실수입니다

229는 dev 병합 전 수치였고 오래 남는 기록(BI-21·WORKLOG) 쪽을 갱신하지 않았습니다. 295로 고쳤습니다. 이번 커밋으로 테스트 5개가 늘어 현재는 300개 통과입니다.

🔴 번호 충돌 — 현행 유지합니다

푸시 시점에 #80은 미머지이고 devBD-35·BI-21이 없어 이 PR은 그대로 둡니다. decisions/README.md 규칙대로 나중에 머지되는 쪽이 BD-36·BI-22로 재배정하면 됩니다. 이 PR이 먼저 머지되면 말씀하신 대로 #80이 재배정 + 색인 추가를 하는 쪽이 맞습니다 — 순서가 바뀌면 제가 옮기겠습니다.

clean check 300개 통과, 실패 0입니다.

코드에서 SAVE와 REVOKE_ALL은 대칭으로 보이지만 근거가 비대칭이다. 적어두지 않으면
"이 코드베이스는 왜 Lua를 쓰지, 트랜잭션으로 바꾸자"는 정리가 REVOKE_ALL까지
건드리고, 그러면 직전 커밋이 닫은 경합이 그대로 돌아온다.

- REVOKE_ALL: MULTI로 대체 불가. 트랜잭션은 응답이 EXEC 때 와서 SMEMBERS 결과로
  삭제 대상을 정할 수 없다. 이 선택은 취향이 아니고, 바꿔도 잡는 테스트가 없다는
  사실까지 적었다
- SAVE: MULTI로도 정확하다. 여기서 막는 것은 경합이 아니라 부분 실패이고, Lua인
  것은 메커니즘 통일을 위한 선택이라 이쪽만 트랜잭션으로 바꿔도 맞다

경합을 재현하는 테스트가 없다는 것은 BI-21에 이미 적혀 있다. 그래서 이 부분의
정확성은 테스트가 아니라 기록이 지킨다 — 리팩터하는 사람이 실제로 읽는 자리인
javadoc에 둔다.

동작 변경 없음. clean check 300개 통과 유지

Refs #78

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cherry-go-round
cherry-go-round merged commit d338618 into dev Jul 29, 2026
2 checks passed
@cherry-go-round
cherry-go-round deleted the fix/S15P11A705-131-refresh-reuse-family-revocation branch July 29, 2026 05:40
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.

fix(S15P11A705-131): Refresh 재사용을 감지해도 회원의 다른 세션이 살아남는다

2 participants