Skip to content

feat: 랭킹을 집계 테이블로 전환한다 — Redis 제거, 삭제 단일화, 검증을 게이트로 - #9

Merged
Layla7120 merged 36 commits into
mainfrom
aggregate-table
Aug 23, 2026
Merged

Layla7120 merged 36 commits into
mainfrom
aggregate-table

Conversation

@Layla7120

@Layla7120 Layla7120 commented Aug 23, 2026

Copy link
Copy Markdown
Owner

main 기준 36 커밋, 130 파일, +5584 / −1313. main 보다 뒤처진 커밋은 0 개라 충돌 없이 합쳐진다.

브랜치 이름은 aggregate-table 이지만 실제 범위는 그보다 넓다. 집계 테이블을 넣다가 그 테이블이 랭킹의 두 번째 사본이 된다는 걸 알게 됐고, 사본을 하나로 만드는 과정에서 Redis · 락 · active 플래그가 차례로 빠졌다.

무엇이 바뀌나

랭킹 — 매 요청 집계에서 집계 테이블로

  • user_monthly_score 추가. 커밋 저장과 같은 트랜잭션에서 다시 센다
  • Redis 랭킹 저장소 · 이벤트 리스너 · 자가치유 스케줄러 제거 (RankingRedisRepository, RankingEventListener, RankingSelfHealingScheduler)
  • 랭킹의 사본이 하나가 되면서 "두 사본이 어긋나면 고친다" 코드가 필요 없어졌다
  • 커밋 수집 락도 제거 — 정합성을 락이 아니라 트랜잭션에 맡긴다

데이터 정합성

  • Flyway 도입, init.sqlV1__baseline.sql 로 승격. 이후 마이그레이션 5 개
  • 사용자 삭제를 물리 삭제로 단일화하고 active 플래그를 없앴다 (ADR-0001)
  • sha 유일성 범위를 전역 → 유저별로 좁혔다. 포크 커밋이 사라지던 문제 (ADR-0003)
  • GitHub 커밋 수집이 30 건에서 잘리던 걸 전체 이력으로 고쳤다. 페이지네이션 루프를 포트 아래로 내려 HTTP 를 가짜로 만들 수 있게 했다 (ADR-0004)
  • 시간 정책: 저장은 UTC, 날짜·월 판정은 KST (ServiceZone)
  • history 를 살리고 문제당 한 행으로 정의 (ADR-0005)
  • 난이도를 백준·프로그래머스·SWEA 세 축으로 보존하고 환산하지 않는다 (ADR-0010)

검증 — 사람 판정에서 기계 게이트로

  • scripts/check-dead-code.sh, scripts/check-doc-refs.sh + 각각을 테스트로 감싼 DeadCodeGateTest, DocReferenceGateTest
  • SchemaContractTest 가 스키마(FK · 인덱스 · 컬럼)를 진술로 고정한다
  • 엔드포인트 allowlist 로 문서에 없는 경로가 늘어나는 걸 막는다
  • 테스트 컨테이너를 static 싱글턴으로 — 컨테이너 7 개가 1 개로

문서

  • ADR 10 건 신설. 결정의 "왜"를 명세서에서 분리했다
  • 문서를 시제로 세 층(현재 / 미래 / 과거)으로 나누고, 백틱 게이트는 현재·미래 층만 검사한다 (ADR-0007)
  • 완료된 계획서와 bench/docs/archive/ 로 이동
  • README 를 절반으로 줄였다 — 읽을 것과 검사되는 것만 남긴다

API · 데모

  • springdoc 으로 API 문서 복구 (Flask 시절 경로 그대로)
  • 웹 데모가 빠뜨린 엔드포인트 5 개 추가

합치고 나서도 남는 것

docs/남은-일.md 에 전부 적어뒀다. 그중 이 PR 이 고치지 않는 보안 공백 2 건은 따로 말해둔다:

  1. 요청자가 누구인지 확인하지 않는다. SecurityConfigpermitAll 이고 userId 를 파라미터로 받는다. DELETE /users/delete?userId=N 한 줄로 남의 계정이 지워진다. Flask 원본과 같은 상태라 의도적으로 범위 밖에 뒀다.
  2. 레포 소유권을 확인하지 않는다. repositoryName 은 가입 때 받은 문자열일 뿐이라, 공개 레포는 아무거나 자기 것으로 등록해 커밋을 긁어올 수 있다. 인증을 붙여도 이건 따로 남는 문제다.

이 PR 은 물리 삭제로 바꾸면서 삭제의 파급 범위는 정의했지만, 누가 삭제할 수 있는가는 건드리지 않았다.

그 밖에 문서 불일치 1 건: ADR-0002 의 FK 표는 5 행인데 스키마에는 FK 가 6 개다 (fk_participate_group 누락). SchemaContractTest 는 6 개 전부 진술하므로 게이트는 통과하지만 표가 낡았다.

확인한 것

  • CI test 워크플로가 HEAD (43399c9) 에서 통과 — run 32479397025
  • 로컬에서 따로 테스트를 다시 돌리지는 않았다

리뷰 순서 제안

36 커밋은 한 번에 읽기 크다. 커밋 단위로 나눠 읽는 걸 권한다:

  1. 785ee3c3603dbc — 집계 테이블 도입과 읽기 경로 전환
  2. 5802d281ee4bad — Redis 와 락 제거
  3. 37ce6a9, 5ebb2db — 삭제 단일화와 history 복구
  4. 3a78ad3 — 앵커와 게이트
  5. 나머지는 문서

🤖 Generated with Claude Code

Layla7120 and others added 30 commits August 13, 2026 10:35
Flask 에는 flask-smorest 로 /docs/swagger-ui 가 있었는데 Kotlin 으로
옮기면서 대체물 없이 사라져 있었다. 신규 도입이 아니라 복구다.

- springdoc-openapi-starter-webmvc-ui 3.1.0 (Boot 4 지원 최소 버전)
- 경로·제목·버전을 Flask 와 동일하게: /docs/swagger-ui, "코테 독촉기", v1
- EndpointAudit 이 감사 대상을 com.reminder.server 패키지로 한정.
  springdoc 이 등록한 엔드포인트가 "테스트 없는 엔드포인트"로 잡혀
  verifyEndpointCoverage 가 빌드를 깨뜨렸다. 클래스 이름 나열 방식이었으면
  의존성이 늘 때마다 반복됐을 문제다.

검증: 테스트 74개 통과, verifyEndpointCoverage 통과, 감사 총계 19 유지.
/docs/openapi.json 이 잡은 19개가 라우팅 테이블의 19개와 일치.

문서에 Alembic 손실은 적혀 있었으나 이 손실은 기록된 적이 없었다.
아프지 않은 손실이 기록에서도 빠진 사례로 docs/기록.md 에 남긴다.

Co-Authored-By: Claude <noreply@anthropic.com>
주석의 종류가 아니라 분량을 고쳤다. 대부분 WHY 주석이라 방향은 옳았고,
문제는 여러 줄 서사로 써서 코드가 바뀔 때 아무도 안 고친다는 것이었다.
실제로 findTop30Rank 위에 "INNER JOIN이 두 경로를 일치시킨다"고 적어놓고
findUserRank 에는 적용하지 않은 사고가 있었다.

- docs/기록.md 에 이미 있는 경위 20곳 → 한 줄 + "경위: docs/기록.md"
- 테스트가 이미 고정하는 규칙 4곳 → 그 테스트 이름을 가리키게 대체
  (동점 순서·isEmpty 분기는 RankPathConsistencyTest 가 빨간불을 낸다)
- 코드가 표현할 수 없는 것(버린 대안·외부 시스템 동작)만 2-3줄로 남김
- 3줄 이하 주석은 건드리지 않았다 — 비관적 락·TOCTOU 비교 등

바로잡은 것 두 개:
- CommitLevel.from() 은 UNRATED 를 반환하는데 "즉시 실패, 호출자가 처리"라고
  적혀 있던 2줄 삭제. 예전 구현의 잔재다
- "dense_rank → native query 불가피 (JPQL 미지원)" 정정. Hibernate 7.2.7 의
  HQL 은 over() 를 지원한다. 제약이 아니라 선택이었다

코드는 한 글자도 바꾸지 않았다. 주석 289→231줄(main), 테스트 74개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
랭킹 점수의 진실 원천을 user_monthly_score 한 곳에 두기 위한 첫 단계.
읽는 쪽도 쓰는 쪽도 아직 안 붙였으므로 동작은 하나도 안 바뀐다.

- infra/init.sql: user_monthly_score (user_id, score_month) PK,
  idx_rank(score_month, score DESC)
- 컬럼명이 score_month 인 이유는 YEAR_MONTH 가 MySQL 예약어라서다
- 엔티티는 CHAR(6) 에 맞춰 @JdbcTypeCode(SqlTypes.CHAR) 를 명시했다.
  String 기본값 VARCHAR 로 두면 ddl-auto=validate 가 기동을 막는다
- recompute 는 증분이 아니라 commits 를 다시 센 절대값을 덮어쓴다.
  ON DUPLICATE KEY UPDATE 라 몇 번을 돌려도 같은 값이 된다

읽는 쪽이 붙기 전에 쿼리를 고정하려고 저장소 테스트를 먼저 뒀다 —
멱등성, 커밋 삭제 시 감점, 월 버킷 분리, 0점·비활성 유저 제외,
limit, 점수 종류 세기.

테스트 81개 통과 (기존 74 + 신규 7).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
쓰기 경로만 붙인 단계. Redis ZINCRBY 이벤트는 아직 그대로 두어
이중 기록 상태다 — 읽는 쪽은 다음 단계에서 옮긴다.

- bulkUpsert 직후, 같은 트랜잭션에서 recompute 를 호출한다.
  이벤트도 AFTER_COMMIT 도 쓰지 않는다: 커밋도 롤백도 같이 간다
- 재계산 대상은 newCommits 가 아니라 dtos 에 등장하는 모든 달이다.
  절대값이라 안 바뀐 달을 다시 써도 무해하고 이쪽이 안전하다

UserMonthlyScoreWriteTest 는 GitHub 응답을 직접 고정한다.
MockGithubClient 는 3~7건을 랜덤 시각에 주므로 "5건" 도 "월 경계" 도
통제할 수 없어서, 그 두 가지를 확인하려면 응답을 잡아야 했다.
롤백 검증은 바깥 트랜잭션을 열어 setRollbackOnly 로 되돌린다 —
AFTER_COMMIT 이었다면 점수만 남아 이 단언이 깨진다.

테스트 85개 통과 (81 + 신규 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
AND u.active = true 때문에 옵티마이저가 idx_rank 를 버리고 users 를
풀스캔하고 있었다. 50,000 유저 / 102만 커밋에서 EXPLAIN ANALYZE 로 측정:

  Top30        87.2ms → 0.484ms
    before: Table scan on u (50,000행) → nested loop → Sort limit 30
    after:  Covering index range scan on idx_rank, 30행에서 중단
  개인 순위     67ms → 11ms

읽기 경로는 아직 Redis/commitRepository 라 이 두 쿼리는 지금 아무도
부르지 않는다. 그래서 Phase 3 앞에 뺀다 — 전환 직후 첫 측정이 87ms 로
나오면 "집계 테이블이 별로네" 라는 틀린 결론이 나온다.

I5(비활성 유저는 랭킹에 없다)를 쿼리가 아니라 불변조건으로 옮긴 것이라
지키는 쪽을 세 군데에 뒀다:

- UserService.deleteUser — 비활성화하면서 점수 행을 지운다
- CommitService.fetchAndSaveCommits — 비활성 유저는 recompute 를 건너뛴다.
  안 막으면 재수집만으로 랭킹에 되살아난다
- loginOrCreate 는 기존 유저를 그대로 돌려줄 뿐 되살리지 않는다.
  active=false 는 종착 상태라 지운 행을 복구할 경로가 없다

주석도 정정했다. "distinct score 는 수십 개라 이 셈이 싸다" 는 틀렸다 —
결과는 distinct 개수지만 비용은 내 점수 위의 행을 전부 훑는 것이다
(중앙값에서 25,000행 11ms). DISTINCT 서브쿼리로 재작성해도 루스 인덱스
스캔은 안 걸린다. 커버링이라 행당이 쌀 뿐이다.

bench/seed.sql 의 TRUNCATE 목록에 user_monthly_score 를 추가했다.
FOREIGN_KEY_CHECKS=0 으로 users 를 지우고 있어서 그대로 두면 시드할 때마다
없는 유저를 가리키는 점수 행이 남는다.

UserDeactivationApiTest 는 지금은 옛 이유(findTop30Rank 의 u.active)로
통과한다. Phase 3 에서 읽기가 옮겨간 뒤부터 진짜 감시자가 된다.

테스트 89개 통과 (85 + 신규 4).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
init.sql 은 docker-entrypoint-initdb.d 라 볼륨 최초 생성 때만 실행된다.
그래서 785ee3c 가 추가한 user_monthly_score 는 이미 있는 볼륨에 영영 생기지
않고, ddl-auto: validate 가 부팅을 막는다 — 없는 볼륨을 만들어 재현했다.
docs/기록.md 가 "운영 스키마 관리: 방법이 없다"고 적어둔 그 칸이다.

infra/init.sql → server/src/main/resources/db/migration/V1__baseline.sql.
복사본을 남기지 않는다 — 스키마 원본이 두 곳이면 어긋난다는 걸 이 저장소가
Alembic vs init.sql 로 이미 겪었다. 테스트·CI·bench 도 같은 체인을 쓴다.

Boot 4 는 자동설정이 모듈로 쪼개져 있어 flyway-core 만으로는 마이그레이션이
실행되지 않는다. spring-boot-flyway 가 있어야 붙는다.

검증:
  - 빈 컨테이너에서 테스트 89개 통과 (Flyway 가 만든 스키마가 엔티티와 일치)
  - 기존 볼륨 상태를 복제해 기동 → baseline v1 기록 후 정상 기동
  - 그 DB 에 임시 마이그레이션 추가 후 재기동 → 자동 적용 확인.
    init.sql 로는 불가능했던 동작이고, 이게 도입 이유다

한계: baseline-on-migrate 는 기존 스키마를 "V1 과 같다"고 선언만 하고 V1 을
실행하지 않는다. 이미 드리프트가 난 볼륨은 여전히 같은 오류로 죽는다.
Flyway 는 과거 드리프트를 소급해 고치지 못하고 다음 것을 막는다.
복구 절차는 server/README.md 트러블슈팅에 적었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
두 문서가 랭킹·active 영역을 다르게 지시하고 있었다. untracked 라 커밋 대상인지도
애매했다. 권위를 나눈다.

PLAN-aggregate-table.md — 랭킹·집계·active 전부. 이미 Phase 1·2·2.5 가 실행돼
있다(785ee3c, 66d7fee, b169d11). 불변조건 I1~I8 과 롤백 전략이 있다.
PLAN-db-foundation.md — 키 설계·수명주기·타임존·DDL 제약·인덱스. 겹치지 않는다.

db-foundation Phase 1 을 폐기했다. "Redis 경로에 active 필터를 추가하자"였는데,
그 경로는 aggregate-table Phase 4 가 삭제할 코드다. 지울 코드에 버그를 고치는
일이 된다. 사본을 하나로 줄여서 계약 위반 가능성을 없애는 쪽이 맞다.

같은 이유로 최초 진단의 틀을 정정했다 — "집계 테이블을 아무도 안 읽음"과
"Redis·DB 경로 불일치"는 설계 실패가 아니라 Phase 3 직전에서 멈춘 이관의 중간
상태다. 785ee3c 는 커밋 제목에 "아직 아무도 읽지 않는다"고 써놨다.

Flyway 도입으로 죽은 참조가 된 §3-1 의 infra/init.sql 지시를 갱신했다.

Phase 0 서술도 정정했다. dev 볼륨에 user_monthly_score 가 있었던 건 init.sql 이
반영돼서가 아니라 사용자가 EXPLAIN 측정용으로 손수 만들어둔 것이었다. 그때 FK 가
빠져 있었고 나중에 ALTER 로 붙였다 — 드리프트는 가설이 아니라 실제로 있었고,
ddl-auto: validate 는 FK 를 검증하지 않아 잡지도 못했다.

알려진 한계 두 개를 계획서에 명시했다(고치지 않는다):
  - Phase 3~4 사이 Redis 랭킹이 탈퇴 유저를 계속 보여준다. Phase 4 에서 소멸
  - 커밋 수집 락은 Phase 4 후 정합성 장치에서 효율 장치로 격하된다. 존폐는 그때 판단

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN-aggregate-table.md Phase 3.

RankService 가 user_monthly_score 만 읽는다. Redis 분기와 ranking.redis.enabled
스위치를 걷어냈다 — 둘 다 "경로가 둘"이라서 필요했던 것이고, 그 둘이 어긋나 있던 게
51abeb7 이었다. 경로가 하나면 폴백이라는 개념 자체가 없다.

정렬·LIMIT 은 idx_rank(score_month, score DESC) 가 처리하고, 동순위는 기존
toDenseRankEntries() 를 그대로 쓴다. 개인 순위는 두 단계로 나눴다 — 행이 없는 것과
0 등인 것을 구분해야 null 의미가 유지된다.

백필을 Flyway 마이그레이션으로 넣었다. 계획서 §3-5 는 "마이그레이션 도구가 없으므로
일회성 SQL 로 실행하고 명령을 보고서에 남긴다"고 했는데, 714e049 로 그 전제가
없어졌다. 손으로 실행하는 절차가 사라지고 어느 DB 에 적용됐는지가 기록에 남는다.
이게 없으면 785ee3c 이전 커밋이 통째로 랭킹에서 사라진다.

테스트:
  RankPathConsistencyTest 삭제 → RankingRulesTest 신설. 그 테스트가 고정하던 건
  "두 경로가 같은 답을 준다"인데 비교할 경로가 없어졌다. 단언 자체는 경로 비교가
  아니라 랭킹 규칙(I3 동순위, I4 0건 제외, I6 동점 순서)이라 옮겨왔다.

  RankServiceFallbackTest 삭제 — 증명하던 주장(Redis 장애 시 DB 폴백)이 사라졌다.

  RankApiTest 는 지우지 않고 fixture 만 고쳤다. 점수를 직접 써넣지 않고 백필과 같은
  SQL 로 commits 에서 파생시킨다 — 파생 규칙이 틀리면 테스트도 같이 틀려야 한다.

검증: 85개 통과 (89 → 폴백·경로비교 6개 제거, 신규 2개).
      고의 파손 — fixture 의 재계산을 빼면 RankApiTest 2개가 깨진다. 테스트가 실제로
      집계 테이블에 의존한다는 뜻이다.
      테스트 컨테이너에 마이그레이션 2개 적용 확인.

알려진 한계(Phase 4 까지): Redis ZSET 은 이벤트·스케줄러가 계속 채우지만 아무도 읽지
않는다. 탈퇴 유저가 거기 남아 있어도 랭킹에 영향이 없다 — 읽는 쪽이 집계 테이블이라
오히려 Phase 3 이 그 불일치를 무해하게 만들었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
착수 전 "Phase 3~4 사이 Redis 랭킹이 탈퇴 유저를 보여준다"를 알려진 한계로 적었는데,
읽는 쪽이 집계 테이블로 바뀌면서 ZSET 을 아무도 안 읽게 돼 그 창이 Phase 3 에서 닫혔다.
그대로 두면 사실이 아닌 경고가 남는다.

계획서에서 벗어난 지점도 적었다 — 폴백·경로비교 테스트를 Phase 4 가 아니라 Phase 3 에서
처리했다. RankService 생성자를 직접 부르는 테스트라 컴파일이 깨져 미룰 수 없었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금 락은 정합성 보장용으로 쓰이는데 TTL 기반 Redis 락은 그 보장을 못 한다.
구멍이 둘: GitHub 응답이 30초를 넘기면 만료된 락으로 계속 돌고, 값이 "1" 이라
소유자 표시가 없어 delete 가 남의 락을 지운다.

표준 해법(소유자 토큰 + Lua + TTL 연장)은 락을 더 복잡하게 만든다. 절대값 재계산은
락이 실패해도 결과가 안 틀리게 만든다. 복잡도를 늘려 생긴 문제를 복잡도를 더 늘려
막는 대신 구조로 없애는 쪽이다.

터진 적은 없다 — 지금의 버그로 적는 게 아니라 락을 계속 가져갈 이유가 없다는 근거다.

인메모리 락으로 교체가 아니라 제거로 방향을 바꿨다. 잃는 건 GitHub 중복 호출,
응답값 정확도, InnoDB 짧은 대기뿐이고 데이터는 안 틀린다. 필요해지면 그때
측정하고 넣는다.

API 계약이 바뀌는 건 명시했다 — 중복 요청이 400 대신 200 을 받는다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN-aggregate-table.md Phase 4.

Phase 3 이후 Redis ZSET 은 아무도 읽지 않는데 이벤트와 스케줄러가 계속 채우고
있었다. 죽은 코드가 매시간 도는 상태였다.

삭제:
  RankingRedisRepository / RankingSelfHealingScheduler / RankingEventListener
  CommitsSavedEvent + CommitService 의 발행부
  RankingScoreHealTest / RankingSelfHealingSchedulerTest

함께 죽은 것:
  @EnableScheduling — @scheduled 가 저 스케줄러 하나뿐이었다
  CommitRepository.findMonthlyCommitCountPerUser + UserCommitCountProjection
  — 스케줄러 전용 쿼리였다

CommitDuplicateFetchTest 는 남긴다. ZSET 단언을 user_monthly_score 단언으로만
바꿨다 — 버그 A 재발 방지가 목적이고 그건 여전히 유효하다. 오히려 락을 없앤 뒤에야
이 테스트가 진짜 검증이 된다. 지금까지는 락이 동시 요청을 막아줘서 "락이 가려준 것"
인지 "구조가 막는 것"인지 구분되지 않았다.

bench/run.sh, bench/rank_ab.js 는 지우지 않고 헤더만 달았다. A/B 축이 사라져
측정 도구로는 못 쓰지만, docs/기록.md 의 측정 표가 이 스크립트를 근거로 서 있어서
지우면 그 표의 출처가 사라진다. 실행 도구가 아니라 기록으로 남긴다.
ZSET 을 채우던 스케줄러가 함께 사라져 워밍 단계에서 하드 실패하므로, 조용히 0 을
재는 실패 양상은 막힌다.

남기는 것: Redis 의존성·컨테이너(커밋 수집 락이 아직 쓴다), toDenseRankEntries().

검증: 81개 통과 (85 → 스케줄러·힐 테스트 4개 제거).
      전체 3회 실행 중 1회 CommitDuplicateFetchTest 가 Redis 연결 실패로 깨졌다.
      단독 실행은 통과. Testcontainers 컨테이너 수명 문제로 보이고 이 변경과
      무관해 보이지만, 초록불이라고만 적지 않는다. 재발하면 추적할 것.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phase 4 이후 Redis 가 하는 일이 이 락 하나뿐이었다.

락을 왜 없앴나. 이 락은 정합성 보장용이었는데(버그 A), TTL 기반 Redis 락은 원래
그 보장을 못 한다. 구멍이 둘이다:
  1. GitHub 응답이 TTL(30초)을 넘기면 만료된 락으로 계속 돌고, 그 사이 두 번째
     요청이 락을 얻어 동시 실행된다
  2. 값이 "1" 이라 소유자 표시가 없다. delete 가 누구 락이든 지우므로, 늦게 끝난
     요청1 이 요청2 의 락을 지운다

막으려면 소유자 토큰 + Lua + TTL 연장 + 펜싱 토큰까지 가야 한다. 락을 정교하게
만드는 대신 정합성이 락에 의존하지 않게 했다 — 커밋 삽입은 UNIQUE(sha) + ON
DUPLICATE KEY UPDATE 로 멱등이고 점수는 절대값 재계산이라, 같은 요청이 몇 번을
동시에 와도 결과가 같다. 터진 적은 없다. 계속 가져갈 이유가 없다는 근거로 적는다.

그래서 Redis 를 통째로 걷어냈다 — 의존성, 컨테이너, docker-compose 서비스,
접속 설정, IntegrationTest 의 flushDb, ContainerSmokeTest 의 Redis 검증.
spring.cache 설정도 지웠다. @Cacheable 이 하나도 없어 아무 일도 안 하던 설정이다.

API 계약이 바뀐다. 중복 수집이 400 대신 200 을 받는다. AGENTS.md 규칙대로 HTTP
계층 테스트를 함께 낸다 — CommitFetchApiTest 신설. endpoint-allowlist.txt 의
CommitController#fetchAndSave 부채도 함께 지웠다(사유가 "409 매핑"이었는데 그
동작이 사라졌다). 미도달 엔드포인트 7 → 6개.

연타 방지는 데모 페이지가 버튼 비활성화로 한다. 400 을 보여주는 것보다 낫고,
층이 맞다 — 정합성은 구조가, 연타는 프론트가 맡는다.

검증: 81개 통과 (78 + 신규 3). 테스트 실행 시간 75초 → 57초 (Redis 컨테이너 제거).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
코드에서 없어진 것이 문서에는 남아 있었다. 문서가 코드와 다르면 문서 전체를
믿을 수 없게 된다.

주석: GithubClientPort, MockGithubClient, CommitRepository, RankEntry, RankService
      — "CommitSavedEvent → Redis ZINCRBY", "ZSET에서 꺼낸", "Redis 장애 분기" 등

README: 아키텍처 다이어그램에서 Redis·스케줄러 노드 제거, 기술 스택 표에 랭킹·
        마이그레이션 행 추가, 테스트 수 74 → 81, REDIS_HOST/PORT 환경변수 삭제
server/README: rank 도메인 설명, 설정 스위치 절, "/rank 가 빈 배열" 트러블슈팅을
        집계 테이블 기준으로 다시 씀. Redis 연결 실패 절 삭제
bench/README: 기록 문서임을 헤더에 명시 (run.sh 와 같은 이유로 지우지 않는다)

server/README 의 커밋 해시도 고쳤다 — 785ee3c 는 집계 테이블 추가지 Flyway 도입이
아니다(714e049). 볼륨 드리프트가 생긴 시점은 전자가 맞다.

검증: 81개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
§0-2 는 원래 "Redis 의존성을 지우지 않는다(커밋 수집 락이 쓴다)"였는데, 그 락을
없애면서 근거가 사라졌다. 벗어난 지점이라 명시한다.

Phase 5 에 남은 것(bench/query_ab.sh)과 후속 후보(테스트 컨테이너가 컨텍스트마다
새로 뜨는 문제)도 적었다. 후자는 Phase 4 중 관측된 flake 의 원인일 수 있다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f3cfa2e 가 세운 기준(코드가 표현할 수 없는 것만 남긴다)을 새로 쓴 주석에 적용했다.

Flyway 의존성 3줄 → 1줄. 남긴 건 "flyway-core 로 바꾸지 마라"는 함정 경고뿐이다.
모듈 분리 배경과 "버전은 BOM 이 관리"는 지웠다 — 후자는 버전 문자열이 없는 것으로
이미 보인다.

endpoint-allowlist 파싱 3줄 → 1줄. 셋째 줄이 바로 아래 코드를 그대로 서술하고
있었다(주석 줄 걸러내고 첫 공백까지 자른다). 버린 대안(substringBefore)만 남겼다.

코드는 바꾸지 않았다. 81개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN-db-foundation.md Phase 2 (일부).

git sha 는 내용 주소다. 저장소를 포크하거나 같은 템플릿에서 갈라지면 서로 다른
유저가 같은 sha 를 정당하게 갖는다. 코테 스터디에서 포크는 흔하다.

전역 UNIQUE(sha) 였을 때: 두 번째 유저의 커밋이 bulkUpsert 의 ON DUPLICATE KEY
UPDATE 에 걸려 조용히 사라졌다. 예외도 로그도 없고 saved 만 0 으로 나간다.
findExistingShas 도 user_id 없이 조회해서 남의 커밋을 내 것으로 세고 있었다.

  빨간불 확인: expected: 1 but was: 0 (CommitShaScopeTest, 고치기 전)

UNIQUE(user_id, sha) 로 바꿨다. 기존 데이터는 옮길 필요가 없다 — 전역 UNIQUE 를
통과한 행은 (user_id, sha) 에서도 자동으로 유일하다. 제약이 느슨해지는 방향이라
위반이 생길 수 없다.

코너 케이스를 함께 고정했다. 지금까지 테스트가 전부 "서로 다른 sha" 만 다뤘고
같은 sha 가 겹치는 경우는 재수집 하나뿐이었다:
  - 두 유저가 같은 sha  → 둘 다 저장 (포크)
  - 같은 유저가 같은 sha → 한 번만 저장 (멱등 유지)
  - 한 배치 안에 같은 sha → 한 번만 저장
  - findExistingShas 가 유저 경계를 넘지 않는다

검증: 85개 통과 (81 + 4).
      dev 볼륨에 마이그레이션 2개가 실제 적용되는 것까지 확인 — uk_commits_sha 가
      uk_commits_user_sha(user_id, sha) 로 바뀌었다. Phase 0 없이는 못 했을 변경이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ERD: 테이블 6개의 관계와 진실 원천/파생 구분을 mermaid erDiagram 으로. 스키마를
읽는 핵심이 '무엇이 원천이고 무엇이 파생인가'라서 그 표를 같이 뒀다.

sha 를 UK 로 적었다가 고쳤다 — 4ee76dc 로 (user_id, sha) 복합이 됐다. mermaid 에
없는 PK_FK 표기도 정정.

Phase 2 는 절반이다. github_numeric_id 는 GitHub API 호출 위치가 트레이드오프라
결정이 필요해 착수하지 않았고, 선택지와 권장안을 계획서에 적었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN-db-foundation.md Phase 3.

탈퇴가 하던 일은 active=false 와 점수 행 삭제뿐이었다. 유저가 그룹에 남긴 상태는
그대로 남아서, 빨간불 네 개로 확인했다:
  - 탈퇴자가 정원을 계속 먹는다 (유령 멤버) — 남은 자리에 아무도 못 들어온다
  - 그룹 멤버 목록에 계속 보인다
  - 오너가 탈퇴하면 승계가 안 돼 아무도 그룹을 관리할 수 없다 (400)
  - 탈퇴 계정으로 그대로 로그인된다 — 쓸 수는 있는데 랭킹엔 영영 못 드는 좀비

정한 시맨틱:
  탈퇴 = 그룹에서 빠지고 랭킹에서 빠진다. 데이터는 지우지 않는다.
  - 그룹: 전부 탈퇴. GroupService.leaveGroup 에 맡긴다 — 오너 승계와 "마지막 멤버면
    그룹 삭제"가 거기 있고, 여기서 다시 구현하면 두 경로가 갈라진다
  - 점수: 행 삭제 (랭킹 쿼리에 active 필터가 없는 근거)
  - 커밋·이력: 보존. GitHub 이 원본이고 재가입 시 되살아난다
  - 닉네임·github_id: 유지. 재활성화 경로가 있으니 점유가 문제되지 않는다
  재로그인 = 재활성화 + commits 에서 점수 복구 (backfillFromCommits).
    그룹은 다시 참여해야 한다 — 남은 멤버가 모르는 사이 사람이 돌아와 있으면 곤란하다.

준영속 함정 하나: leaveGroup 의 decrementMemberCounter 가 clearAutomatically 라
영속성 컨텍스트를 비운다. 그 뒤 원래 user 참조로 deactivate() 하면 저장되지 않아
탈퇴자가 재수집만으로 랭킹에 되살아난다. 그래서 다시 읽고 비활성화한다.

  고의 파손으로 검증했더니 처음엔 안 잡혔다 — 그룹에 속한 유저의 탈퇴 후 active 를
  확인하는 테스트가 없어서, 그룹 없는 경로만 돌고 있었다. 그 케이스를 추가하고 나서야
  파손이 빨간불로 잡힌다.

검증: 90개 통과 (85 + 5).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
정한 시맨틱 표와, 계획서에 없던 함정(벌크 UPDATE 의 clearAutomatically 와 변경 감지를
한 트랜잭션에서 섞을 때)을 적었다. 고의 파손이 처음엔 안 잡힌 것도 남겼다 — 파손 검증이
테스트의 구멍을 찾아준 사례라 기록할 값어치가 있다.

계획서가 제안한 '삭제 유저 별도 테이블' 대안은 검토만 하고 택하지 않은 이유를 적었다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
규칙이 커밋 메시지·계획서·코드 주석에 흩어져 있었다. docs/삭제-명세.md 로 모았다.

담은 것:
  - 상태 기계 (active ↔ inactive, 물리 삭제 경로 없음)
  - 탈퇴 시 테이블별 처리 8개 (무엇을 지우고 무엇을 남기는지, 각각 왜)
  - 재가입 시 복구 범위 — 그룹만 복구하지 않는 이유
  - 반납하지 않는 것 (github_id, nickname)과 그 전제
  - 불변조건별 시행 지점과 강도 (DB 가 막는 것 vs 앱 코드만)

§6 "아직 정하지 않은 것"을 비워두지 않았다. 특히 보관 기간은 무기한인데, 고른 게
아니라 아무도 정하지 않아서 그렇게 된 것이다. 물리 삭제 경로 부재, member_counter
대사 부재도 같이 적었다.

명세를 쓰다 발견한 것 둘:
  - findTop30Rank / findUserRank / RankProjection / UserRankProjection 이 죽은 코드로
    남아 있었다. RankService 가 집계 테이블로 옮겨간 뒤 쓰는 곳이 없다. 명세가 죽은
    코드를 설명하면 안 되므로 먼저 지웠다
  - "재가입해도 그룹은 복구되지 않는다"가 규칙인데 테스트가 없었다. 추가했다

검증: 91개 통과 (90 + 1).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN-db-foundation.md Phase 4 (타임존).

두 시계가 9시간 어긋난 채 비교되고 있었다.

GithubClient 는 commit.author.date(UTC, "...Z")를 패턴 "yyyy-MM-dd'T'HH:mm:ss'Z'" 로
파싱한다. 'Z' 가 리터럴이라 "UTC 다"라는 정보가 버려지고 UTC 벽시계 값만 남는다.
그런데 Clock 은 Asia/Seoul 이라 경계는 KST 벽시계로 계산됐다.

  빨간불로 확인한 증상 (CommitTimezoneTest):
    - KST 00:00~09:00 커밋이 잔디에서 하루 전 칸에 찍힌다 (매일 발생)
    - 매월 1일 새벽 커밋이 지난달 점수로 들어간다

bench/seed.sql 상단이 이 문제를 이미 적어뒀는데, 벤치에서만 우회했지 운영은 그대로였다.

global/ServiceZone 을 만들어 정책을 한곳에 뒀다. 경계는 항상 두 단계다 —
KST 로 경계를 잡고 UTC 로 바꿔서 쿼리한다. 저장 규약("이 컬럼은 UTC 다")은
DATETIME 이 타임존을 안 갖는 이상 코드로만 유지되는 약속이라, 그 약속의 출처를
한 파일로 못박았다.

바꾼 곳: CommitService(재계산 월·주간활동·잔디), GroupService(멤버 랭킹 경계),
RankService(score_month 를 KST 월로), backfillFromCommits(CONVERT_TZ).

데이터 교정은 새 마이그레이션으로 했다. 앞선 백필(V20260819212939)이 UTC 월로 집계해둔
점수를 KST 월 기준으로 다시 센다. 이미 적용된 파일을 고치면 체크섬이 깨지므로 교정은
언제나 다음 마이그레이션으로 한다.

ClockInjectionTest 는 fixture 만 고쳤다 — 저장이 UTC 라는 의미를 담도록 KST 경계를
ServiceZone 으로 변환해 심는다. 테스트가 증명하던 주장(Clock 주입)은 그대로다.

bench/seed.sql 의 세션 타임존도 +09:00 → +00:00. 그 +09:00 은 어긋남을 우회하려던
것이었고, 어긋남 자체가 없어졌다.

검증: 93개 통과 (91 + 2).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
삭제 명세서의 '이번 달'·'그날'이 무슨 시계인지 적혀 있지 않았다. KST 판정 / UTC 저장과
변환 지점(ServiceZone)을 §7 로 넣었다.

Phase 4 는 절반이다 — 타임존은 끝났고 타입 축소(ascii_bin, 컬럼 폭, DATETIME 정밀도)는
정합성과 무관한 인덱스·행 크기 문제라 측정과 함께 하는 Phase 6 으로 미뤘다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
삭제 정책을 grilling 으로 다시 세우면서, 결정의 경위가 갈 곳을 만들었다.
지금까지 그게 커밋 메시지·계획서·코드 주석에 흩어져 있었다.

결정 9개 (전부 승인됨(미구현) — 구현은 PLAN-simplification.md 가 한다):
- 0001 소프트 삭제를 버리고 물리 삭제로 단일화. active 컬럼과 재활성화 경로가 사라진다
- 0002 CASCADE 는 파생값의 원천이 아닐 때만. participate 는 RESTRICT 로 남긴다 —
       CASCADE 면 leaveGroup 을 건너뛰어 member_counter 가 조용히 어긋난다
- 0003 GitHub 커밋을 전체 이력으로 수집. 지금은 페이지네이션이 없어 30건에서 잘리고,
       그래서 "GitHub 이 원본이다"가 지키지 못하는 약속이었다
- 0004 페이지네이션 루프는 포트 아래, 검증은 MockRestServiceServer 로.
       포트 더블은 포트보다 아래의 코드를 검증하지 못한다
- 0005 history 를 살린다. 읽는 코드가 0개였지만 죽은 코드가 아니라 미완성 기능이었다
- 0006 member_counter 대사를 두지 않는다. 자동 교정 대사가 이 저장소를 한 번 물었다
- 0007 문서를 시제로 나눈다 — 현재/미래/과거. 과거 층은 게이트 대상이 아니다
- 0008 검증을 앵커와 게이트로. detekt 는 확인 결과 공개 인터페이스를 못 봐서 뺐다
- 0009 주석 판정 질문 하나 — 설명하는 코드가 이 파일에 있는가

측정으로 확인한 것:
- 죽은 코드 4건. existsBySha 는 호출 0회인 데다 4ee76dc 이후 의미도 틀렸다
  (전역 sha 조회라 남의 커밋에 true)
- 문서 숫자가 세 갈래 — 기록.md 74개 / README 81개 / 실제 @test 97개
- main 1,620행 중 주석 263행(코드 대비 23%)

scripts/check-doc-refs.sh 를 프로토타입으로 만들어 이 문서들에 직접 돌렸다.
초기 24건 중 4건이 진짜였고 — 아카이브 이동으로 깨진 링크, 삭제된 init.sql 참조,
없는 게이트를 있는 것처럼 쓴 용어집, 경로 규칙의 오탐 — 전부 고쳐 초록불이다.

거기서 규약 둘이 나왔다. 백틱은 지금 실재하는 것만 감싼다.
ADR 은 구현 전까지 승인됨(미구현) 이고, 상태를 바꾸는 행위 자체가 게이트에 걸린다.

PLAN-aggregate-table.md 는 완료됐으므로 docs/archive/ 로 옮겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
PLAN-simplification.md Phase 1. ADR-0003, ADR-0004 를 구현했다.

fetchCommits 가 /commits 를 한 번만 불렀다. per_page 도 page 도 없어서
GitHub 기본값인 30건에서 잘렸고, 예외도 로그도 없어 잘렸다는 사실 자체가
보이지 않았다. 이제 per_page=100 으로 빈 페이지가 올 때까지 따라간다.
상한은 두지 않는다 — 상한이 곧 조용한 절단이고 그게 고치려던 증상이다.

실측 (2026-08-21, layla7120/Algorithms):
- GitHub 원본 커밋 191건, 저장 188건 (3건은 패턴 미매칭 — Initial commit 등)
- 191건이면 페이지가 100 + 91 이라 2페이지에서 멈춘다
- 고치기 전이라면 최대 30건. 약 158건이 조용히 누락되고 있었다
- 첫 수집 1.545s, 재수집 1.089s ({"saved":0} — 멱등)

ADR-0003 이 "잃는 것"으로 적어둔 "첫 수집은 수 초"는 이 규모에서 1.5초다.
비동기화는 만들지 않는다 — ADR 이 정한 대로 측정하고 나서 결정할 일이고,
측정 결과가 문제가 아니다.

루프는 포트 아래에 둔다. 포트 시그니처는 그대로고, 대신 GithubClient 자체를
HTTP 레벨에서 본다 — MockGithubClient 를 끼우면 루프를 통째로 건너뛰므로
종료 조건을 반대로 써도 초록불이 나기 때문이다 (ADR-0004).

GithubClientTest 5개 (MockRestServiceServer, spring-test 는 이미 classpath 에 있다):
100건→빈 페이지 / 단일 부분 페이지 / 100·100·37 → 237건 / 2페이지째 500 →
부분 반환 없이 예외 / 파싱 못 한 커밋이 섞여도 원본 개수로 종료 판정.

고의 파손: 종료 조건을 < 에서 <= 로 뒤집으면 5개 중 4개가 빨간불.
안 깨진 하나는 단일 부분 페이지로, 양쪽 다 첫 페이지에서 멈추는 게 맞다.

ADR-0004 가 "실제 배선은 Spring 이 한다"고 적었는데 틀렸다. 이 classpath 에는
RestClient.Builder 빈이 없다 — Boot 4 가 RestClientAutoConfiguration 을
spring-boot-restclient 모듈로 분리했고 그게 의존성에 없다. 필수 인자로 두면
컨텍스트가 못 뜬다. Kotlin 기본값을 줘서 운영 배선은 그대로 두고 테스트만
자기 빌더를 넣는다. 모듈 추가는 버렸다 (AGENTS.md §스택).
틀린 예상은 지우지 않고 ADR 결과 절에 경위로 남겼다.

두 ADR 을 승인됨(미구현) → 승인됨 으로 바꿨다. 이제 백틱 게이트가
GithubClientTest 의 실재를 검사한다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
ADR-0010. 전체 이력을 받게 되니(ADR-0003) 비로소 보인 결함이다.

실측 (2026-08-21, layla7120/Algorithms 188건):
  백준 Gold IV 등          92건  → 정상
  프로그래머스 level 1~4    87건  → 전부 UNRATED
  SWEA D3·D4                9건  → 전부 UNRATED
96건, 절반이 UNRATED 로 뭉쳐 있었다.

몰라서가 아니다. 주석이 '매핑 실패("level 1" 등 비BOJ 포맷) → UNRATED' 라고
이름까지 적어두고 삼키는 쪽을 골랐다. 안 보였던 이유는 랭킹 점수가 COUNT(*) 라
난이도를 안 쓰기 때문이다 — 커밋은 정상 저장되고 개수도 맞아서, 증상이
GET /commits/level 한 칸에만 갇혀 있었다. 30건 절단과 같은 종류의 결함이다.

환산표(level 2 → SILVER 식)는 버렸다. 환산은 사실이 아니라 의견이고,
원본 표기를 버리고 의견을 저장하면 나중에 환산표를 고쳐도 못 되돌린다.
대괄호 값을 문자열 그대로 저장하는 안도 버렸다 — 집계 키가 무한대가 된다.

스키마 변경 없다. level 이 VARCHAR(20) 이고 새 값 중 가장 긴 게 LEVEL0(6자)다.
랭킹·점수·순위는 영향 없다.

CommitLevelTest 30개. 핵심은 회귀 앵커다 — 위 실측 21종 188건을 넣어
고친 뒤의 분포(GOLD 55, LEVEL2 47, …)가 그대로 나오는지 본다.
"잘 되게 고쳤다"가 아니라 그때 그 입력에서 이 표가 나오는가를 묻는다.

고의 파손: 프로그래머스 갈래 제거 11개, enum 에서 D4 제거 11개,
BOJ 티어 번호 처리 제거 9개 빨간불.

그 과정에서 SWEA 정규식이 죽은 코드로 드러났다 — 지워도 아무 테스트도 안
깨졌다. D3·D4 는 enum 이름과 글자가 같아 이름 대조가 이미 잡고 있었다.
AGENTS.md §2 에 걸리므로 규칙을 넓히지 않고 갈래를 지웠다.

GET /commits/level 의 응답 키가 늘어 계약이 바뀌었으므로 AGENTS.md §1 에 따라
CommitLevelApiTest 를 함께 냈고, endpoint-allowlist.txt 에서 이 엔드포인트를
지웠다 (6개 → 5개). 응답 키가 CommitLevel 이름인지와 분포의 합이 저장된 커밋
수와 같은지를 HTTP 로 고정한다.

프런트에 넘기는 비용이 있다: 세 축을 한 줄로 세울 수 없으므로 출처별로 나눠
그려야 한다. 환산을 골랐다면 안 냈을 비용이다.

이미 저장된 96행은 안 고쳐진다 — level 이 updatable=false 이고 upsert 가
ON DUPLICATE KEY UPDATE sha = sha 다. 지우고 다시 받아야 한다.
백필 여부는 ADR-0010 에 미결정으로 남겼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
Phase 1 을 끝내고 현재 층 문서를 훑어 어긋난 곳 네 종류를 고쳤다.

**① 문서에 적힌 명령이 이 기계에서 안 돈다.**
README.md 와 PLAN-simplification.md §0-4 가

    export JAVA_HOME="$(/usr/libexec/java_home -v 21)"

라고 적는데 "Unable to locate a Java Runtime" 이 난다. homebrew 설치라
시스템 경로에 없다. AGENTS.md 만 맞는 경로를 적고 있었다 — 네 파일이
이제 같은 한 줄이다. §0-4 는 계획서를 읽고 시작하는 에이전트가 만나는
첫 명령이라 여기서 막히면 그 뒤가 전부 막힌다.

**② "81개 통과" 를 131 로 갱신하지 않고 뺐다.**
README.md 2곳, server/README.md 1곳. 갱신하면 다음 변경에서 또 어긋난다 —
어긋난 이력이 이미 세 갈래였다(기록.md 74 / README 81 / 실제 97).
게이트가 없는 숫자는 지우는 게 고치는 것이다. ADR-0008 이 이미
"❌ 뺀다. CI 배지가 이미 증명한다" 라고 정해둔 항목이라 그대로 따랐다.

**③ 부채 개수를 이번 세션에 내가 어긋나게 만들었다.**
CommitLevelApiTest 를 내면서 endpoint-allowlist.txt 가 6개 → 5개가 됐는데,
ADR-0005 와 PLAN Phase 3 은 아직 "부채 6개 → 5개" 라고 적고 있었다.
계획서는 5 → 4 로 고쳤고, ADR-0005 에서는 개수를 아예 뺐다 —
그 파일이 진실 원천인데 사본을 만들어 두니 어긋난 것이다.
파생 사본이 여러 개면 반드시 어긋난다는 규칙(docs/용어집.md)이
코드가 아니라 문서에서 그대로 재현됐다.

**손대지 않은 것.** docs/기록.md 와 docs/archive/ 의 "81개"·"74개" 는
그 시점의 사실이라 그대로 둔다(ADR-0007). docs/adr/0008 의 "81개" 도
그 숫자를 빼기로 한 결정의 근거라 남긴다. ADR 들의 실측값(96건 51%,
주석 23% 등)은 날짜가 박힌 과거 진술이라 드리프트 대상이 아니다.

문서에 적힌 그대로 실행해 BUILD SUCCESSFUL 을 확인했고,
check-doc-refs.sh 도 초록불이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
PLAN-simplification Phase 2. 근거는 ADR-0001(물리 삭제)·ADR-0002(CASCADE 범위).

**스키마** V20260821135217__physical_user_deletion.sql
FK 를 두 갈래로 나눴다. commits·history·user_monthly_score 는 유저에 종속된
소유 데이터고 이 행들을 원천 삼는 것이 없어 CASCADE. participate·groups 는
그대로 둔다 — groups.member_counter 가 participate 행에서 파생되므로 CASCADE 로
걸면 leaveGroup 을 우회해 카운터가 조용히 어긋난다. 유지하면 FK 위반으로 즉시
터진다: 조용한 손상 대신 시끄러운 실패다. 마지막에 users.active DROP.

베이스라인 FK 는 이름 없이 만들어져 MySQL 이 <table>_ibfk_N 을 붙였다.
재생성하면서 fk_ 접두사 이름을 준다. fk_ums_user 만 두 문장으로 나눴다 —
한 ALTER 안에서 같은 이름을 DROP+ADD 하면 ERROR 1826 이 난다.

**코드에서 사라진 것**
- User.active·deactivate()·reactivate()
- UserService.loginOrCreate 의 비활성 분기와 restoreScores
- UserMonthlyScoreRepository.backfillFromCommits·deleteAllByUserId (호출자 소멸)
- CommitService 의 if (user.active) 가드 — 랭킹 부활을 막던 가드가 필요 없어졌다

deleteUser 는 leaveGroup 루프 뒤 userRepository.delete(user) 하나로 끝난다.
decrementMemberCounter 가 clearAutomatically 라 user 가 준영속이 되는데,
delete() 는 merge 후 지우므로 그대로 나간다. 변경 감지에 기대던 이전 코드는
여기서 다시 읽어야 했다.

**API 계약 변경** UserResponse 에서 active 필드가 빠진다. AGENTS.md §1 대로
HTTP 계층 테스트로 고정했다.

**테스트** UserDeactivationApiTest → UserDeletionApiTest 개명(이름이 사실과
어긋나게 됐다). UserLifecycleApiTest 에 세 가지를 새로 고정 — 탈퇴 후 닉네임
반납, 자식 행이 있는 유저의 CASCADE 삭제, 재가입 시 새 user_id.

131개 통과, verifyEndpointCoverage 초록불, check-doc-refs.sh 초록불.

**ADR 상태는 그대로 둔다.** ADR-0001·0002 가 약속한 SchemaContractTest 가
아직 없다(Phase 4). 계획서 §0-3 대로 게이트가 생긴 뒤에 승인됨 으로 바꾼다.

**남은 어긋남** docs/삭제-명세.md §1~5 는 여전히 소프트 삭제를 서술한다.
Phase 5 가 담당한다 — 백틱 게이트는 식별자 실재만 보고 서술의 진위는 안 본다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
PLAN-simplification Phase 3. 근거는 ADR-0005.

이 테이블은 오래 쓰기 전용이었다. POST /history 하나만 있고 findByUser 는
어디서도 호출되지 않았다 — 데이터가 들어가기만 하고 나오지 않았다.
죽은 코드가 아니라 만들다 만 기능이라는 사용자 확인을 받고 살리는 쪽으로 갔다.

**스키마** V20260821142109__history_rebuild.sql — 되돌릴 수 없다.

solved_at 을 NOT NULL 로 넣어야 하는데 기존 행에 그 값을 만들 근거가 없다.
마이그레이션 시각으로 백필하면 없는 사실을 지어내는 것이고, 그 시각으로 정렬한
화면은 거짓을 보여준다. 그래서 DELETE 후 ALTER 다.

  solve_time  VARCHAR(10) → INT      초 단위
  solved_at   DATETIME(6) NOT NULL   신규. 저장은 UTC
  uk_history_user_problem UNIQUE (user_id, problem_num)

FK 는 손대지 않았다 — 직전 커밋이 이미 fk_history_user 를 CASCADE 로 재생성했다.

solve_time 이 문자열이면 DB 가 "1:05:00"·"01:05:00"·"65분"을 전부 받아들이고
정렬·평균·비교가 안 된다. INT 는 타입 자체가 검증이다. 엔티티 주석이
"하위호환 리스크로 타입 변환 보류"라고 적어뒀는데, 실사용자가 없으므로
그 리스크는 지금 존재하지 않는다.

**API**
- GET /history?userId= 신규 — [{problemNum, solveTime, solvedAt}], solvedAt DESC.
  historyId 는 안 내보낸다. 클라이언트가 행을 지목할 키는 문제 번호다
- POST /history — 같은 문제를 다시 등록하면 행을 늘리지 않고 갱신한다.
  @Valid 추가(problemNum @notblank, solveTime @positive). 엔티티 직렬화도 끝났다
- 표시 형식("01:05:00")은 클라이언트가 만든다. 서버가 정하면 화면이 둘 될 때 서버를 고쳐야 한다

ServiceZone.nowUtc 를 추가했다. "저장은 UTC" 정책의 출처를 한 곳으로 유지하기 위해서다 —
인라인으로 쓰면 정책 사본이 하나 더 생긴다.

**고의 파손 확인** (AGENTS.md §2) — 셋 다 빨간불
- 정렬 DESC → ASC              : "최근에 푼 문제부터" 실패
- upsert 조회를 빗나가게       : "행이 늘지 않고 갱신된다" 실패
- @Valid 제거                  : 400 을 기대하는 두 테스트 실패
upsert 파손은 컴파일 에러로 인한 가짜 빨간불을 배제하려고 컴파일되는 형태로 다시 냈다.

**부채 명세서** endpoint-allowlist.txt 5개 → 4개.
HistoryController#saveHistory("어느 계층에도 테스트 없음") 가 해소됐다.
신규 getHistory 는 추가하지 않는다 — HistoryApiTest 가 200·400·404 를 전부 지난다.

**같은 커밋에서 고친 문서** README.md 의 ERD 가 solve_time 을 "HH:MM:SS 문자열"로
그리고 있었다. 스키마를 바꾸면 그걸 그리는 문서도 같은 커밋에서 고친다(AGENTS.md §문서).

140개 통과(신규 9개), verifyEndpointCoverage 초록불, check-doc-refs.sh 초록불.

**ADR-0005 상태는 그대로 둔다.** 약속한 SchemaContractTest 가 아직 없다(Phase 4).
계획서 §0-3 대로 게이트가 생긴 뒤에 승인됨 으로 바꾼다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
Phase 4. 근거: docs/adr/0008-검증을-앵커와-게이트로-한다.md

세 개를 만들었다.

SchemaContractTest — information_schema 를 읽어 FK 6개의 DELETE_RULE,
users 에 active 없음, history.solve_time = int / solved_at NOT NULL,
uk_history_user_problem 을 대조한다. 마이그레이션 SQL 을 파싱하지 않는다 —
Flyway 가 그 SQL 로 이 DB 를 만들었으니 항상 통과하는 동어반복이 된다.

FK 표는 스키마의 파생 사본이 아니라 독립적인 제2진술이다. participate 와
groups 의 RESTRICT 는 의도적 선택인데 마이그레이션 파일만 보면 ON DELETE 절을
빠뜨린 것과 구별이 안 된다. 여기서 진술하면 나중에 누가 편의로 CASCADE 를 걸 때
빨간불이 나고 이유가 적혀 있다. containsAllEntriesOf 가 아니라 완전 일치로 쓴 것도
같은 이유다 — 진술 없는 새 FK 는 들어오면 안 된다.

ADR-0002 표는 FK 를 5개로 적었는데 스키마에는 6개다(participate.group_id 가
표에 없다). 테스트는 6개 전부를 진술한다. 빠진 하나를 검사에서 빼면 그 FK 만
아무도 안 보게 된다.

DocReferenceGateTest / DeadCodeGateTest — 프로토타입으로 있던
scripts/check-doc-refs.sh 를 빌드에 연결하고, 죽은 코드 스크립트를 새로 썼다.
별도 Gradle 태스크로 빼지 않았다. verifyEndpointCoverage 가 태스크인 건 테스트
실행의 부산물을 읽어야 해서인데 이 둘은 그런 순서 의존이 없다.

죽은 코드 규칙을 함수로 좁힌 근거는 측정이다. 제외 없는 브로드 규칙은 24건을
뱉었고 그중 참이 4건이었다 — 오탐 83%. @bean / @ExceptionHandler / 요청 매핑 /
JPA projection getter / override / main 을 빼면 참 4건만 남는다. 넓히지 않고
좁힌 것은 ADR-0008 의 규칙이다: 오탐이 늘면 규칙을 넓히지 말고 게이트를 지운다.

지운 것은 계획서가 센 4건이 아니라 5건이다.

  CommitRepository.existsBySha       호출 0회. 의미도 틀렸다 — 4ee76dc 가 sha
                                    유일성을 (user_id, sha) 로 좁혔는데 이 함수는
                                    전역 sha 로 조회한다. 남의 커밋에 true 를 낸다
  GroupRepository.findByGroupName    호출 0회
  UserRepository.findByNickname      호출 0회
  GithubClientPort.existsRepository  호출 0회. 구현체 2개까지 같이 지웠다.
                                     b16cdf0 에서 포트를 뽑을 때 들어와 한 번도
                                     불린 적이 없다. 게이트가 새로 잡은 것이다
  RankProjection.kt → Projections.kt 파일명이 가리키는 타입이 파일 안에 없었다

고의 파손 확인. 임시 마이그레이션으로 fk_participate_user 를 CASCADE 로 바꾸고
uk_history_user_problem 을 떨구고 active 를 되살리고 solved_at 을 NULL 로
풀었더니 SchemaContractTest 4개가 전부 빨간불이 났다. 문서에 없는 테스트
이름과 없는 경로를 넣고 안 불리는 함수를 하나 더해 게이트 둘도 각각 확인했다.

ADR-0001·0002·0005 를 승인됨(미구현) → 승인됨 으로 바꾼다. 이 전환 자체가
검사 스위치라는 것도 확인했다 — 같은 파손을 두고 승인됨 에서는 빨간불,
승인됨(미구현) 에서는 초록불이었다.

테스트 146개 통과 (기존 140 + 신규 6).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
Phase 5. 근거: docs/adr/0007-문서를-시제로-세-층으로-나눈다.md
              docs/adr/0009-주석은-이-파일의-코드를-설명할-때만-남긴다.md

현재 층 문서 2105 → 1164줄 (-45%). 루트에 남는 md 는 README 와 AGENTS 둘뿐이다.

docs/삭제-명세.md (164줄) 삭제. 이 문서는 전부 소프트 삭제를 서술하고 있어서
개정이 아니라 제거가 맞았다 — §1~5 는 ADR-0001·0002 가 이미 담고 있고, §6 의
미결정 4개는 전부 결정됐고, §7(시간)은 global/ServiceZone 과 용어집에 있다.

README 153 → 81. ERD 에서 컬럼을 뺐다. 마이그레이션이 원천인데 손으로 옮겨 적으면
낡는데, 백틱 게이트는 식별자만 보므로 표 내용의 드리프트를 못 잡는다. 관계는 거의
안 바뀌고 컬럼은 자주 바뀐다 — 컬럼을 빼면 그 표는 게이트 없이도 안 낡는다.
"진실 원천과 파생" 표는 남겼다. 그게 이 ERD 의 진짜 값어치고, 관계에 대한
진술이라 잘 안 낡는다. 87ms → 0.5ms 에 측정 시점(2026-08-17)을 붙였다.

AGENTS 78 → 58. 게이트가 강제하는 것과 사람이 지켜야 하는 것을 갈랐다.
전자는 표 한 개로 줄었다 — 규칙을 산문으로 다시 설명할 이유가 없다.
주석 규칙(ADR-0009)을 넣고도 순 감소다.

용어집 117 → 58. 정의는 표로, 왜는 ADR 링크로. "현재 동작 중인 게이트는
verifyEndpointCoverage 하나뿐" 이라는 낡은 문장을 지웠다.

PLAN 2개를 docs/archive/ 로 옮겼다. PLAN-db-foundation 은 하단 체크박스가
Phase 1~6 전부 [ ] 인데 본문에는 ✅·🟡 마커가 3개 붙어 있었다 — 한 파일 안에서
두 진술이 어긋난 채였다. 아카이브는 그 시점의 사실을 얼리는 것이라, 얼리기 전에
체크박스를 본문에 맞췄다. 미착수분 7건은 docs/남은-일.md (13줄) 로 뽑았다.
계획서를 통째로 아카이브하면 남은 일이 같이 안 보이게 된다.

주석 정리 — 판정 질문은 하나다. 이 주석이 설명하는 코드가 이 파일에 있는가.
  CommitService  없어진 Redis 락의 경위 9줄. 락이 없는 이유(멱등)만 남겼다
  CommitService  "sha 정렬 후 bulk upsert". 정렬은 CommitJdbcRepository 안에서
                 하고 거기 같은 설명이 이미 있다
  UserMonthlyScoreRepositoryTest  없어진 UserDeactivationApiTest 와 없어진
                 "비활성 유저 제외"를 가리키던 KDoc

게이트가 이동으로 깨진 링크 8건을 그 자리에서 잡았다 (아카이브로 옮긴 계획서의
docs/adr/ 상대 경로). 그리고 ADR-0008 이 백틱으로 감싸고 있던
build/endpoint-audit.txt 는 gitignore 된 빌드 산출물이라 깨끗한 체크아웃에서
게이트를 빨간불로 만들 수 있었다 — 백틱을 뺐다. 실제로 파일을 치우고 확인했다.

ADR-0006·0007·0008·0009 를 승인됨 으로 바꾼다. 열 개 전부 승인됨 이 됐다.
ADR-0008 의 게이트 표에 실제 테스트 이름을 적었다 (신규 백틱 게이트 →
DocReferenceGateTest, 죽은 코드 스크립트 → DeadCodeGateTest).

테스트 146개 통과. 게이트 4개 초록불.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
Layla7120 and others added 6 commits August 21, 2026 15:52
서버에는 엔드포인트가 20개인데 데모 카드는 15개였다. 눌러볼 데가 없으면
없는 기능과 구별이 안 된다 — 특히 Phase 3 에서 만든 history 두 개가 그랬다.

  DELETE /users/delete      회원 탈퇴
  POST   /history           풀이 기록
  GET    /history           풀이 조회
  GET    /group/check/name  그룹 이름 중복 확인
  PATCH  /group/password    그룹 비밀번호 변경

탈퇴에만 confirm 을 붙였다. 물리 삭제라 되돌릴 수 없고, 데모 페이지는
실수로 누르기 쉬운 자리다.

실제로 띄워서 다섯 개를 전부 눌러 확인했다.

  POST /history 를 같은 문제 번호로 두 번   → 행 1개, solveTime 이 덮어써짐
  GET  /history                             → solvedAt 내림차순, historyId 미노출
  GET  /group/check/name?groupName=알고리즘 → {"available":true}, 파라미터 누락 400
  PATCH /group/password 오너                → 204
  PATCH /group/password 오너 아닌 사람      → "그룹 소유자만 수행할 수 있습니다"
  DELETE /users/delete                      → 204, 이후 GET /users 404,
                                              마지막 멤버였던 그룹도 사라지고
                                              닉네임이 반납됨

테스트 146개 통과. 게이트 4개 초록불.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
데모로 눌러보다 확인한 것이다. 둘은 다른 문제다.

  요청자 미확인 — permitAll + userId 파라미터. DELETE /users/delete?userId=N
                  한 줄로 남의 계정이 지워진다. Flask 원본과 같은 상태라
                  의도적으로 범위 밖이고 README 가 그렇게 적고 있다.

  레포 소유권 미확인 — repositoryName 은 가입 때 받은 문자열일 뿐이라
                  공개 레포는 아무거나 자기 것으로 등록해 커밋을 긁어올 수 있다.
                  인증을 붙여도 따로 남는다 — "이 사람이 이 레포의 소유자인가"를
                  GitHub 에 물어야 하는 별개의 검증이다.

두 번째가 README 에 적혀 있지 않아서 여기 남긴다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
랭킹 A/B 는 2026-08-19 에 이미 축을 잃었고(ranking.redis.enabled 제거)
bench/README.md 와 run.sh 머리에 그렇게 적혀 있다. 문제는 그 다음이다.

Phase 2 의 물리 삭제가 users.active 를 떨궜는데 bench/seed.sql 이 그 컬럼에
INSERT 하고 있었다. run.sh 는 seed.sql 을 파이프로 먹이므로, 실제로는 시딩에서
Unknown column 'active' 로 죽는다. 그런데 run.sh 주석은 실패 지점을 "워밍
단계(114행)" 라고 적고 있었다 — 서술이 실제와 다르다.

bench/ 는 백틱 게이트의 스캔 대상이 아니라 아무도 못 잡았다. 게이트 밖이라는 게
규칙이 아니라 우연이다 (아래 보고 참조).

고친 것:

  seed.sql   active 컬럼 제거. 주석도 고쳤다 — "active = TRUE 여야 랭킹 쿼리에
             잡힌다"는 이제 거짓이다. 랭킹은 users 를 조인하지 않고,
             user_monthly_score 에 행이 있는 것이 곧 포함이다
  run.sh     실패 지점을 시점별로 적었다. 줄 번호(114행)는 뺐다 — 편집할 때마다
             어긋나는 참조라 같은 실패를 반복한다
  두 파일    아카이브로 옮긴 PLAN-aggregate-table.md 경로 갱신

임시 컨테이너에 마이그레이션 체인을 다 태우고 확인했다 (개발 DB 로는 못 한다 —
seed.sql 이 TRUNCATE 로 시작한다):

  고친 seed.sql   @target_users=1000 → users 1000, commits 20500
  고치기 전 형태  ERROR 1054 Unknown column 'active' in 'field list'

그리고 Phase 5 에서 루트 README 가 bench 를 "성능 측정 방법" 이라고 가리키고
있었다. 안 도는 스크립트를 방법이라 부른 것이라 "기록" 으로 고쳤다.
server/README.md 의 "측정 재현" 도 같다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
세 군데다.

README 실행 — export 줄을 지우고 .env 를 쓰게 했다. bootRun 이 저장소 루트의
.env 를 읽어 환경 변수로 넣는다(server/build.gradle.kts). export 를 안내하면
그 자동 주입을 모르는 채로 두 벌을 관리하게 된다. .env.example 이 이미 추적되고
있고 키도 다 들어 있어서, cp 한 줄이면 된다.

  확인: DB_* 와 GITHUB_TOKEN 을 env -u 로 전부 지우고 bootRun →
        Started ServerApplicationKt. .env 만으로 뜬다.

README·기록.md 맺음말 — "필요 없는 복잡도를 넣었고, 거기서 버그가 나왔고,
측정해보니 그 복잡도가 애초에 필요 없었다"를 지우고 무엇을 넣었다 뺐는지 적었다.
읽는 사람이 "그래서 뭘 넣었는데"를 물을 수밖에 없는 문장이었다.

  Redis ZSET 랭킹 캐시 + 자가치유 스케줄러  2026-08-19  5802d28
  커밋 수집 Redis 분산 락                   2026-08-20  1ee4bad
  users.active 소프트 삭제                  2026-08-21  37ce6a9

대체한 것까지 같은 줄에 적었다 — 뺐다는 것만으로는 그 자리가 지금 어떻게
메워져 있는지 알 수 없다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
bench/README.md 와 run.sh 는 2026-08-19 부터 머리에 "더 이상 A/B 가 성립하지
않는다" 고 적고 있었다. ranking.redis.enabled 축이 사라져 두 조건이 같은 것을
잰다. 그런데 위치는 저장소 루트였고, 루트 README 가 이걸 "성능 측정 방법" 이라고
가리키고 있었다 — 안 도는 스크립트를 방법이라 부른 셈이다.

층으로도 어디에도 안 들어가 있었다. ADR-0007 의 세 층은 현재(docs/·루트) /
미래(PLAN-*) / 과거(기록.md·archive/) 인데 bench/ 는 그 중 어디도 아니었고,
게이트가 안 보는 것도 규칙이 아니라 스캔 목록에서 빠진 우연이었다.
그 우연의 대가가 직전 커밋(06d3703)이다 — seed.sql 이 없어진 users.active 를
참조한 채 남아 있었다.

이동을 막고 있던 것: docs/기록.md 가 ../bench/ 로 링크를 걸고 있었고 그 파일은
손대지 않기로 한 것이었다. 사용자가 고쳐도 된다고 해서 경로만 바꿨다 — 서술은
그대로 두고 링크와 명령의 경로만 옮겼다. 경로는 그 시점의 사실이 아니라
포인터라서 낡으면 그냥 깨진 것이다.

게이트 스캔 범위를 스크립트 주석에 명시했다. .md 와 server/src/**.kt 만 본다.
.sh/.sql 로 넓히지 않는 이유도 적었다 — 변수 전개와 힙독이 섞여 "경로처럼 생긴
문자열"의 오탐률이 .md 와 비교가 안 된다. 넓히는 대신 현재 층에서 뺐다. (ADR-0008)

확인: 과거 층 링크가 실제로 검사되는지 고의 파손으로 봤다.
      docs/archive/bench/README.md 의 링크를 없는 경로로 바꾸니 빨간불이 났다.
      ①②③(식별자)은 과거 층 제외지만 ④(링크)는 층과 무관하다.

이제 루트에 남는 디렉터리는 server/ app/ migrations/ docs/ scripts/ 다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
@bean 이 만드는 컨테이너는 Spring 테스트 컨텍스트마다 하나씩 생긴다.
이 저장소는 컨텍스트가 7개로 갈린다:

  test 프로파일 x MOCK / RANDOM_PORT
  load-test 프로파일 x MOCK / RANDOM_PORT
  @import(FixedClockConfig) / @import(TimezoneClockConfig)
  @MockitoBean(GithubClientPort)

-i 로 재보니 "Container mysql:8.0 started" 가 정확히 7번, 각 5초였다.
컨테이너는 컨텍스트가 아니라 JVM 에 묶이는 게 맞다.

  컨테이너   7개 → 1개
  test 태스크  54초 → 19초

멈추지 않는다. Ryuk 이 JVM 종료 시 정리한다.

공유해도 되는 이유는 원래 구조가 그랬기 때문이다 — IntegrationTest.clearStores()
가 매 테스트 앞에서 테이블을 비운다. 격리는 컨테이너를 나눠서가 아니라 데이터를
지워서 얻고 있었고, 컨테이너가 7개인 건 그 격리에 기여한 적이 없다.

고의 파손으로 확인했다. clearStores() 의 DELETE 를 주석 처리하니 146개 중 16개가
깨졌다 — 컨테이너가 실제로 공유되고 있고 격리를 데이터 삭제가 지고 있다는 뜻이다.
싱글턴이 아니었다면 컨텍스트가 갈린 만큼은 안 깨졌을 것이다.

docs/남은-일.md 에서 이 항목을 지운다.

테스트 146개 통과. 게이트 4개 초록불.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KXUxtTF2N13mxX4HdiBxtX
@Layla7120
Layla7120 merged commit 259c0d0 into main Aug 23, 2026
3 checks passed
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