Conversation
company.address 에 "주소 없음"이 두 가지로 저장되고 있었다. 등록 신청은 blankToNull 로 "" 를 NULL 로 접는데, 기업 설정 수정(PATCH /api/companies/me)은 접지 않는다. CompanyJpaEntity.updateProfile 의 병합이 null 검사만 하므로 "" 는 그대로 통과해 컬럼에 빈 문자열이 남는다. 읽는 쪽이 address != null 로 판단하면 빈 주소를 주소로 본다. 화면이 빈 칸을 막고 있지만 그것이 서버의 보장은 아니다. name 에도 @notblank 가 없어 "" 가 들어오면 기업명이 빈 값으로 덮인다. - PATCH 요청 5개 필드에 @pattern(".*\S.*") 을 걸어 "보냈으면 빈 값 금지"만 막는다. @notblank 를 쓰지 않는다 — null 까지 튕겨서 한 필드만 고치는 요청이 400 이 되고 부분 수정 계약(null = 미변경)이 깨진다. @pattern 은 명세상 null 을 통과시키므로 "안 보내는 건 허용, 보냈으면 빈 값 금지"가 한 애노테이션에 들어간다. - 등록 신청의 employeeScale·purpose 도 blankToNull 에 태운다. address 와 같은 선택 입력값인데 접기가 빠져 있어 "" 로 저장되고 있었다. 값 없음의 표현을 NULL 하나로 맞춘다. 전체 교체(전 필드 @notblank)로 가지 않는다. address·phone 은 등록에서 선택이거나 아예 받지 않아 NULL 로 시작하는데, 필수로 걸면 주소 없는 회사가 회사명도 못 고친다. 검증: PATCH 빈 값 7케이스 400 + 유스케이스 미호출, 필드 생략은 계속 200, 등록 선택값 빈 문자열·공백 접기와 strip 확인. 전체 테스트 통과. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@pattern 은 Matcher.matches() 로 문자열 전체를 검사하고 Java 의 . 은 줄 종결 문자를 매칭하지 않는다. 그래서 ".*\S.*" 는 "서울시\n강남구" 를 매칭하지 못하고 400 을 낸다 — 비어 있지도 않은 값에 "빈 값으로 보낼 수 없습니다" 가 나갔다. 등록 경로는 이 값을 이미 받는다. blankToNull 이 String.isBlank() 로 판정하는데 "서울시\n강남구".isBlank() 는 false 이고 strip() 은 양끝만 떼서 안쪽 줄바꿈은 남는다. 그대로 두면 등록에 저장된 값을 PATCH 로 되돌려 보낼 때 400 이 되어, 이 PR 이 없애려던 "같은 값인데 경로마다 결과가 다르다" 가 다시 생긴다(코드래빗 지적). - 정규식에 (?s) 를 붙여 줄바꿈을 포함한 값도 통과시킨다. 이제 "빈 값" 의 정의가 두 경로 모두 String.isBlank() 와 일치한다. - 줄바꿈만 있는 값("\n", " \n ")은 여전히 400 이다 — \S 가 하나도 없다. 회귀 케이스로 고정한다. 줄바꿈 자체를 금지하는 건 별개 결정이고 등록 경로에도 같이 걸어야 의미가 있어 이 PR 에 넣지 않는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
설명가능한 토큰 과금을 위해 사용량 원장을 두 축으로 신뢰 가능하게 만든다. ■ in/out 방향 차등 단가 (설명가능성) - V7.11: company_token_plan 에 input/output_token_price_per_1k 추가(기존 총량 단가로 백필). - CompanyTokenPlan.usageAmountKrw(in,out) 신설 — 입력·출력을 각각 1k 올림해 서로 다른 단가 적용. 총량 단일 단가 메서드는 비교 기준선으로 유지. - 대시보드가 회사·부서 금액을 방향 차등으로 계산, 응답에 directionalAmountKrw 노출. - 요금제 설정 API 는 방향 단가 선택 입력(미지정 시 총량 단가로 대체 → 기존 요청 호환). ■ 원장 보장 전달 — outbox + 릴레이 + dead-letter (과소청구 방지) - 기존: 원장 기록 실패 시 로그만 남기고 유실(복구 없음). - V7.12: token_usage_outbox durable 큐 추가. - record(): 정상=원장 직행(동기·멱등), 실패 시 유실 대신 outbox 적재. - TokenUsageRelay(@scheduled): 지수 백오프 재시도 → 성공 DONE, 한계 초과 DEAD(관측 가능). - 멱등 규칙을 TokenUsageLedgerAppender 한 곳으로 모음(no-@transactional 근거 포함). 멱등 싱크(중복 차단) + at-least-once 릴레이(재시도) = 효과적 exactly-once. ■ 테스트 - 멱등성 / 원장 불변식(total=in+out) / 릴레이 복구 / dead-letter / 방향 단가 차이·올림 검증. - metering 단위테스트 및 풀 컨텍스트 부팅 통과. - 마이그레이션 SQL(MySQL 전용)은 CI migrationCheck(Testcontainers)로 검증 필요. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
D(회의)가 회의 상세 화면에서 요약 상태를 구분해야 하는데, 기존 findStalledSummaries 는 깨진 회의만 걸러내는 계약이라(마이페이지 카드) DONE·RUNNING·미시작을 담지 않는다. 같은 포트에 전체 상태를 답하는 메서드를 추가한다. 요청받은 5개 상태(NONE·PROCESSING·DONE·STALLED·FAILED)로는 화면이 거짓말을 한다. 계층 기록을 쓰기 전에 분석이 생략되는 경로가 둘 있고 (발화 0건 · 받아쓰기 미완) 둘 다 NONE 이 되는데, 후자는 정상적으로 진행 중인 회의다. 그래서 WAITING_TRANSCRIPT 를 더했다(모성진 협의). WAITING_TRANSCRIPT 는 NONE 을 쪼갠 값이고 다른 상태를 가리지 않는다. 이미 분석된 회의에 새 녹음이 붙어도 DONE 을 유지한다 — 그 회의에는 사람이 볼 요약이 실제로 있다. if 분기가 아니라 질문 대상 자체를 좁혀서 보장한다: 계층 기록이 없는 회의에만 받아쓰기를 묻는다. 물어보지 않은 값으로는 "DONE 인데 받아쓰기 대기"를 만들 수 없다. 접기는 ProcessingStatus 를 그대로 쓴다. 여기서 따로 판정하면 마이페이지 카드·CAP-06 처리 상태 화면·MEET-04 가 같은 회의를 다르게 말한다. 배치 조회는 파생 쿼리 + id 단일 프로젝션이다(Gate1 QUERY_002 — @query 없음). 엔티티를 읽으면 쓰지 않는 행이 회의 수 × 블록 수만큼 올라온다. IN 절 청킹 크기는 AnalysisLayerPersistenceAdapter 와 같은 값을 쓴다 — 같은 화면에서 오는 목록이라 한쪽만 나누면 안 된다. stt_block 행이 아예 없는 회의는 WAITING_TRANSCRIPT 로 판정하지 않는다. 같은 "행 0개"가 "녹음이 막 시작된 회의"와 "녹음 없이 끝난 회의" 둘을 뜻하고 A 는 녹음 상태를 소유하지 않는다. 그 구간은 D 가 MeetingStatus 와 조합해 덮는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ProcessingStatus 의 DONE 을 그대로 썼는데 그쪽은 "실패도 없고 도는 것도 없다"까지만 본다. 그래서 계층 일부만 남은 회의도 DONE 이었다 — 예전 실행이 markDone(L4) 을 커밋한 뒤 tryLock(L5) 전에 죽으면 RUNNING 행이 없어 #177 의 멈춤 판정에도 안 걸린다. 그 상태를 DONE 으로 답하면 이 계약이 약속한 것이 깨진다. DIST 가 돌지 않아 하달된 액션이 0건인데, D 는 pendingActionCount == 0 을 함께 보고 「정상 완료」로 그린다. 아무것도 하달되지 않은 회의가 완료로 보인다. CAP-06 은 계층 목록을 함께 주니 사람이 빈 계층을 볼 수 있지만 이 계약은 값 하나로 접어서 그 정보가 없다. 판정 기준을 오케스트레이터에서 가져온다(pipelineLayers()) — isFullyAnalyzed 와 같은 집합이어야 한다. 다르면 화면은 「정상 완료」인데 재실행은 그대로 도는 회의가 생긴다. 정상 경로에서는 열 계층 전부 행이 남으므로(L7·DIST 의 "생략" 분기는 runLayer 안에 있다) 이 검사가 멀쩡한 회의를 걸지 않는다. findStalledSummaries 도 같은 판정을 쓰게 바꿨다(#318 동작 변경). 예전에는 거기서 ProcessingStatus 를 직접 접어서, 같은 부분 완료 회의가 카드에는 안 뜨고 회의 상세에서는 중단으로 뜨는 상태가 됐다 — 두 화면이 같은 회의를 다르게 말하는 것을 막으려고 있는 클래스가 정작 그걸 만들고 있었다. STALLED 주석에 재시도 경로를 적었다. 계층 사이에서 죽은 경우는 재개(ANLZ-02) 가 거절된다 — firstBrokenLayer 가 행이 있는 계층만 후보로 보므로 ANLZ-008 이 난다. 재실행(ANLZ-01)은 열린다. 화면의 재시도는 재실행이어야 한다. 테스트 — 완료 픽스처를 pipelineLayers() 에서 만들게 바꿨다(계층이 늘면 따라 간다). 부분 완료가 DONE 이 아님 · 부분 완료도 카드에 오름 2건 추가. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FIX] 기업 정보의 빈 문자열 입력을 서버에서 막는다
…om/Z-Groupware/BACKEND into feat/cap-storage-quota-integration
이 리포에서 반복해서 터진 사고는 "회사 A 사용자가 회사 B 데이터를 본다"이고, 그건 코드 패턴 위반이 아니라 도메인 제약 위반이라 범용 SAST가 잡지 못한다. 우리 도메인을 아는 룰만 잡을 수 있어서 Gate 1에 직접 넣는다. - TENANT_001 — 파생 쿼리 이름에 CompanyId 가 없으면 다른 회사 행까지 읽는다 - AUTHZ_001 — @PreAuthorize 없는 엔드포인트는 로그인한 아무나 호출할 수 있다 query.yml 과 같은 baseline-diff 운영이다. 기존 위반(TENANT 104 · AUTHZ 12)은 base 커밋 기준으로 통과시키고 PR 신규분만 실패시킨다 — 전면 차단하면 개발이 멈추므로 출혈부터 멈춘다. 검증(semgrep 1.172.0, CI 컨테이너와 동일 버전): - 변경 없는 PR → 0건, exit 0 - 위반 1건 신규 추가 → 그 1건만 검출, exit 1 (기존 6건은 통과) - @PreAuthorize 있는 컨트롤러 오탐 0건 정당한 예외는 근거 주석과 nosemgrep 으로 남긴다 — 그래야 "판단했다"와 "빼먹었다"가 코드에서 구분된다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gate 1 도메인 룰을 붙이고 정적 검출과 기존 테스트의 커버리지를 도메인 단위로 대조했더니 metering 이 "정적만" 칸에 남았다 — TENANT_001·AUTHZ_001 이 짚었는데 교차 회사 시나리오를 보는 테스트가 0건이다(테스트 자체는 7개 있다). 이 도메인은 인가를 컨트롤러 @PreAuthorize 가 아니라 서비스에서 한다 (MeteringController 에 @PreAuthorize 가 없다 — 나머지 120곳과 집행 위치가 다르다). 그래서 회사 경계를 서비스 테스트에서 단언하지 않으면 아무 데서도 안 본다. 3개 서비스에 6건 추가 — 요금제 설정·조회, 저장 요금제 설정·조회, 회사·팀 대시보드. 전부 "companyId 는 요청이 아니라 principal 에서만 온다"를 본다. 팀 대시보드는 팀 id 를 그대로 두고 회사만 바꿔 부르지 않는지도 본다(팀 id 는 회사 간에 겹칠 수 있다). 벙어리 테스트가 아님을 확인했다 — requireOwnerOrAdmin 이 principal.companyId() 대신 상수를 돌려주게 바꾸면 새 테스트 2건만 정확히 실패하고 기존 5건은 통과한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
지금 개요 칸에는 L3 의 주제 요약을 이어 붙인 값이 들어 있다("· 주제이름\n
요약" × 주제 수). 틀린 값은 아니지만 개요가 아니라 목차와 본문이고, 주제가
8개면 문단 8개가 개요 자리에 들어간다.
OVERVIEW 계층을 파이프라인 **맨 끝**에 붙인다. DIST 뒤인 이유는 이 산출물이
가장 덜 중요하기 때문이다 — 앞에 두면 개요 생성 실패가 액션 분배까지 막고,
읽을 문장 하나 때문에 사람 보드에 할 일이 안 꽂힌다.
완료 판정에서 뺀다(REQUIRED_FOR_DONE 신설). RUN_LAYERS 와 갈라 둔 첫 사례다 —
개요를 필수로 두면 그 실패가 회의를 「미완」으로 만들고, 화면은 「분석 중단」을
보여주고 ANLZ-01 재실행이 열 계층의 토큰을 전부 다시 태운다. 표시용 문장
하나 때문에. 실패해도 개요 칸은 비지 않는다(이어 붙인 값이 남는다).
입력이 산문이 아니라 구조다. 주제 이름과 확정 항목을 넘긴다 — L3 산문을
넘기면 재실행·재개에서 **자기 출력을 다시 압축**하고(개요 칸을 이 계층이
덮으므로) 돌릴수록 내용이 사라진다. 주제·항목은 meeting_decision 에 있어
몇 번 돌려도 같다. 확정(CONFIRMED)만 담는다 — 논의 중인 항목을 개요에 넣으면
합의되지 않은 것이 합의된 것처럼 읽힌다(L4 와 같은 규칙).
replaceOverview 를 따로 둔다. replace 는 교체라서 항목 id 가 전부 새로
발급되는데, 그러면 review_log.target_id·meeting_tuple_vector·열려 있는 검토
폼이 가리키는 대상이 개요 문장 하나 때문에 사라진다.
빈 응답으로 덮지 않는다. 이어 붙인 값보다 나쁘다 — 개요 칸이 통째로 비고
화면은 요약 없는 회의처럼 보인다. 던지지도 않는다.
⚠ Python 엔드포인트는 아직 없다. 404 가 AiLayerException 이 되어 이 계층만
FAILED 로 남고 파이프라인은 끝까지 돈다 — 위 설계가 의도한 동작이고, Spring 을
먼저 올려도 안전한 이유다. analysis_layer.layer 가 VARCHAR(8) 이라
마이그레이션은 없다(OVERVIEW 가 정확히 8자).
테스트 5건 추가 — 덮어쓰기 · 입력이 구조인지 · 실패해도 완료 · 빈 응답은
안 덮음 · 완료 판정이 개요를 안 봄. 기존 픽스처 하나가 계층 목록을 손으로
적고 있어 pipelineLayers() 에서 파생하게 바꿨다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
POST /api/companies/me/onboarding 이 500(Z-003)으로 떨어진다는 제보를 받았는데, 원인을 되짚을 수단이 없었다. GlobalExceptionHandler 의 마지막 그물이 예외를 받아만 놓고 아무것도 남기지 않아, 응답의 traceId 로 대조할 로그 라인 자체가 없었다. 로깅을 넣고, 그 김에 이 엔드포인트에서 500 이 날 수 있는 경로 셋을 4xx 로 돌린다. 셋 다 사용자가 화면에서 만들 수 있는 입력이라 500 이어서는 안 된다. - 미처리 예외에 스택트레이스를 남긴다. traceId 는 메시지에 직접 박는다 — logback 설정 파일이 없어 Spring 기본 패턴을 쓰는데 거기엔 MDC 가 없다. 응답 본문은 그대로 traceId 만 내보낸다(원인 노출 없음). - 직급 tempId 중복: Collectors.toMap 이 IllegalStateException 을 던져 500 이 됐다. IllegalArgumentException 이 아니라서 기존 400 핸들러에도 안 걸린다. - 부서·역할 tempId 중복: HashMap.put 이라 예외 없이 덮어써, 부서 하나가 사라진 채 200 이 나가거나 초대가 엉뚱한 역할에 붙었다. - 부서명·직급명 중복: UK_TEAM_COMPANY_NAME · UK_POSITION_COMPANY_NAME 까지 가서 DataIntegrityViolationException 으로 터졌다. 배열끼리 tempId 가 겹치는 건 허용한다 — 해석 맵이 부서·역할·직급 따로라 서로를 가리지 않는다. 배열마다 1부터 번호를 매기는 프런트를 막을 이유가 없다. 이름 비교 키는 strip().toLowerCase() 로 맞춘다. 두 인덱스의 콜레이션이 utf8mb4_unicode_ci 라 대소문자·끝 공백을 무시해, 원문 비교로는 "Dev"/"dev" 가 검증을 통과한 뒤 DB 에서 터져 결국 500 이 된다. 새 에러코드는 AU-036~038 이다. 기존 TEAM_NAME_DUPLICATED(AU-016) · POSITION_NAME_DUPLICATED(AU-022)와 코드를 공유하지 않는다 — 그쪽은 409(이미 저장된 것과 충돌), 여기는 400(같은 요청 안에서 자기들끼리 중복된 입력값 자체가 잘못됨)으로 HTTP 상태가 달라 상수를 나눈다(TEAM_LEADER_DUPLICATED 주석과 같은 이유). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Gradle 의 Test 태스크 기본 힙은 512m 인데 이 스위트에는 부족해서 OutOfMemoryError 가 났다. CI 와 로컬 양쪽에서 재현된다. 원인 스프링은 테스트 컨텍스트를 JVM 안에 캐시해두고 재사용한다(기본 최대 32개 동시 보유). 설정이 서로 다른 컨텍스트가 @SpringBootTest 51개 + @WebMvcTest 16개만큼 있어서 캐시가 512m 를 넘긴다. 터진 자리가 Jackson 빈(JacksonMixinModuleEntries)이라 Jackson 문제로 보이지만 아니다 — 하필 그 순간 메모리를 요청한 지점일 뿐이다. 실제 예외는 "java.lang.OutOfMemoryError: Java heap space" 다. 증상 ProjectAttachmentController 7건 · ProjectController 1건이 ApplicationContext 로드 실패로 떨어졌다. develop 브랜치 자체도 같은 이유로 실패 중이었다. Gradle Test 가 필수 체크가 아니라 머지를 막지 않아 방치돼 있었다. 검증 (로컬 전체 스위트, 같은 커밋에서 전후 대조) 512m(기본값) OutOfMemoryError 38건, 스위트 붕괴 2g OutOfMemoryError 0건, BUILD SUCCESSFUL in 4m 51s 건너뜀 6건은 전부 *LiveTest (API 키 없을 때의 정상 동작) 값 근거 CI 러너(ubuntu-latest)는 16GB, 개발 장비도 그 이상이라 2g 포크는 안전하다. migrationCheck 에도 함께 적용된다(tasks.withType(Test)) — 같은 이유로 컨텍스트를 띄우는 태스크라 같은 한계를 갖는다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이 리포의 셸은 배포(infra/deploy.sh)·리뷰 루프 러너(scripts/*.sh)·pre-push 훅이다.
전부 "조용히 잘못 동작하면 알아채기 어려운" 자리에 있는데 지금까지 아무 검사도 없었다.
기준선을 실제로 측정하고 정책을 정했다
오류(error) 0건 → 유예할 부채가 없어 바로 차단으로 넣는다
경고~스타일 9건 → 확인 결과 전부 오탐/의도된 것이라 머지를 막지 않는다
SC2154 6건 — review-score-domains.sh 의 awk 프로그램 안 변수를 셸 변수로 오인
SC2034 3건 — deploy.sh 의 재시도 카운터, pre-push 의 훅 stdin 필드(형식상 필요)
측정 과정에서 처음 나온 689건 중 656건이 SC1017(리터럴 캐리지 리턴)이었는데,
이건 윈도우 로컬 체크아웃(core.autocrlf=true)이 만든 작업본 현상이다.
git blob 을 바이트로 세어보면 CR 이 0개다 — CI(리눅스)가 보는 것은 LF 이고
SC1017 은 CI 에서 나지 않는다. 실제 기준선은 위의 9건이다.
gate1-semgrep.yml 에 붙이지 않고 파일을 나눈 이유
그 파일은 PR #374 에서 이미 고쳐지는 중이라 같은 자리에 잡을 더하면 반드시 충돌한다.
체크 이름이 따로 잡히면 나중에 필수 체크로 올릴 때 개별로 고를 수 있다.
서드파티 액션을 쓰지 않는다 — shellcheck 는 ubuntu 러너에 기본 설치돼 있다.
게이트 하나 때문에 공급망 표면을 늘릴 이유가 없다.
파일 목록을 ${{ }} 로 보간하지 않는다 — PR 이 파일명에 셸 메타문자를 심어
명령을 주입할 수 있다. find -print0 / mapfile -d '' 로 받는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[HOTFIX] Gradle 전체 테스트 Heap 메모리 확장
정적 분석(TENANT_001)은 "회사 조건이 빠진 쿼리"를 찾아내지만, 그 구멍이 실제로
악용됐는지는 알려주지 못한다. 교차 회사 접근이 몇 번, 어느 회사 데이터를 향해
일어났는지는 사후에 로그로만 확인할 수 있다. 스캐너가 채울 수 없는 자리다.
거부된 요청을 한 줄로 남긴다 — 누가(actor) · 어느 회사 소속으로(tenant) ·
무엇에(path) · 무슨 요청을(method) · 왜 막혔는지(code) · 어느 요청인지(trace).
AUTHZ_AUDIT outcome=DENIED_DOMAIN actor=42 tenant=7 authority=MEMBER
admin=false method=GET path=/api/v1/... code=MT-001 trace=...
붙인 자리가 두 곳인 이유
SecurityErrorResponder 만으로는 정작 필요한 기록이 남지 않는다. 이 프로젝트의
교차 회사 거부는 시큐리티 필터가 아니라 서비스·컨트롤러의 도메인 규칙에서
나온다(MT_FORBIDDEN_SCOPE·HO_ACCESS_DENIED·MEETING_READ_FORBIDDEN 등).
그것들은 BusinessException 으로 던져져 GlobalExceptionHandler 로 간다.
그래서 두 곳 모두에 붙이고 outcome 으로 층을 구분한다.
UNAUTHENTICATED 401 신원 확인 실패
DENIED_FILTER 403 필터 체인·@PreAuthorize
DENIED_DOMAIN 403 도메인 규칙 ← 교차 회사 거부가 주로 여기
빈이 아니라 정적 메서드인 이유
호출부 중 하나가 @RestControllerAdvice 다. @WebMvcTest 슬라이스는 @configuration
은 스캔하지 않지만 @RestControllerAdvice 는 등록하므로, 여기에 빈 의존성을
추가하면 컨트롤러 슬라이스 테스트가 "그 타입의 빈이 없다"로 전부 깨진다.
상태도 의존성도 없는 로깅 파사드라 정적으로 두는 것이 맞다.
기록하지 않는 것 — 토큰·요청 본문·이메일은 남기지 않는다. 식별자만 남긴다.
쿼리스트링도 붙이지 않는다(검색어가 감사 로그로 새는 것을 막는다).
감사 로그가 그 자체로 유출 경로가 되면 안 된다.
감사 기록 실패가 에러 응답을 망가뜨리지 않게 write()는 예외를 밖으로 내지 않는다.
⚠️ 한계 — 로그는 로테이션으로 사라진다. 장기 보존이 요구되면 수집기로 내보내야 한다.
그리고 이것은 "거부" 기록이다. 경계가 뚫려 성공한 접근은 여기 남지 않는다.
검증
AuthzAuditLoggerTest 5건 통과 — 실제 출력 문자열을 ListAppender 로 받아 검증한다
(조사할 때 grep 하는 대상이 그 줄이므로, 목 호출 여부가 아니라 줄 자체가 계약이다)
컨트롤러 테스트 39개 클래스 192건 통과 — GlobalExceptionHandler 변경의 파급 확인
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
GitHub Actions 는 표현식을 run: 블록 안의 주석까지 포함해 어디서든 파싱한다.
주입 위험을 설명하려고 주석에 빈 표현식을 리터럴로 적었더니 워크플로 전체가
파싱 실패했고, shellcheck 잡이 아예 시작되지 않았다.
증상 — 워크플로가 등록은 되는데 pull_request 실행이 하나도 생기지 않고,
push 이벤트로 conclusion=failure 인 빈 실행만 남는다. 로그도 없어서
("log not found") 원인을 바로 알 수 없다.
주석을 바꾸고, 같은 실수를 반복하지 않도록 이유를 파일에 남긴다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
실사용 코드가 없던 값을 정리한다. AI 분배 정확도 측정은 capture 도메인의 gate_signals.autoConfirmed(boolean)가 대신 담당하므로 기능 손실 없음 (review(A, 이태연) 확인). Action.java의 HUMAN_REJECTED 오탈자 주석(실제 값은 REJECTED)도 같이 정리하고, 확정 시각 가드를 HUMAN_CONFIRMED 단일 조건으로 단순화한다.
첫 실행에서 PR 9개가 한꺼번에 올라와(액션 5 + gradle 4) 진행 중인 작업과 섞였다. 그 9개는 전부 닫고 브랜치도 지웠다. 재도입 계획은 없다.⚠️ 대가를 적어 둔다 — SHA 고정된 액션 25개를 갱신하는 주체가 사라진다. SHA 로 박으면 사람이 손으로 올리기 어렵고, 그 갱신을 Dependabot 이 맡고 있었다. 앞으로 액션과 gradle 의존성은 그 자리에 고정된 채 낡는다. 올릴 때는 손으로 해야 한다. SHA 고정 자체는 유지한다 — 갱신이 멈추는 것과 고정을 푸는 것은 다른 얘기이고, 고정은 공급망 방어의 핵심이다. gate1-semgrep.yml 의 container 이미지 주석은 Dependabot 동작을 근거로 삼고 있었다. Dependabot 이 없어도 "digest 로 박으면 손으로 계속 올려야 한다"는 결론은 그대로라 설명만 다듬는다. 되살릴 일이 생기면 이 커밋을 되돌리면 된다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[CHORE] Dependabot 을 걷어낸다
REFACTOR ActionReviewStatus.AUTO_CONFIRMED 미사용 값 제거
deploy.sh가 spring만 up해서 CD 실행 시 redis_exporter가 생성되지 않던 문제, REDIS_PASSWORD Compose 변수 치환에 필요한 --env-file 누락 문제를 고친다. 성진님이 배포 절차 검토 중 발견. README 스모크테스트 절도 redis_exporter가 Backend EC2에서 실행된다는 걸 명시해서 Monitoring EC2에서 127.0.0.1로 잘못 호출하지 않게 한다.
#386(action 도메인 · 김민섭)이 plannedStartDate 컬럼을 분리해 넣었고, RVW 쪽 배선을 스코프 밖으로 남겼다. 그 후속이다. <h2>포트는 plannedStartDate 만 나른다 — projectDueDate 는 C 가 채운다</h2> Action.applyHumanReview 는 범위검증(익일~프로젝트 마감일) 때문에 projectDueDate 도 받는데, 그 값을 포트에 넣지 않았다. A 는 프로젝트 마감일을 갖고 있지 않다 (ReviewTarget 에 없다). A 가 굳이 조회해 넘기면 **검증의 기준선을 A 가 정하게** 되고, 그 값이 낡았을 때 잘못된 상한으로 통과·거절이 갈린다. C 의 어댑터가 action.projectId 로 자기 데이터에서 꺼낸다(findProjectReferences — 분배가 기한 기본값을 채울 때 쓰는 그 조회). plannedStartDate 가 null 이면 조회하지 않는다 — applyHumanReview 도 그때는 그 값을 보지 않으므로, 담당자만 고치는 판정에 쿼리를 하나 더 내보낼 이유가 없다. ⚠ 인터페이스를 넓히면 구현체가 컴파일되지 않아 ActionReviewApplyAdapter(C 파일)도 함께 손댔다. null 두 개를 그냥 넘기는 방법도 있었지만, applyHumanReview 가 plannedStartDate != null && projectDueDate == null 이면 던지므로 실제 요청이 500 이 된다. 김민섭 리뷰가 필요하다. <h2>이 값은 AI 산출물이 아니다 — 그래서 예외가 둘이다</h2> meeting_assignment_tuple 에 대응 컬럼이 없어 AI 가 애초에 내지 않는다. 사람이 검토 화면에서 처음 정하는 값이다. ① CONFIRM 가드에서 뺀다. 다른 넷은 CONFIRM(=AI 값이 맞다)과 함께 오면 모순이라 422 인데, 예정 시작일은 고칠 대상 자체가 없다. "AI 값은 다 맞으니 확정하고 시작일만 정해 둔다"가 자연스러운 조합이고, 막으면 화면이 확정 직후 별도 요청을 한 번 더 보내야 한다. ② WRONG_* 라벨을 만들지 않는다. 붙이면 모델이 말한 적도 없는 것을 틀렸다고 가르치게 되고, 그 라벨이 few-shot 예시로 뽑히면 다음 회의 프롬프트가 존재하지 않는 필드를 교정하려 든다. 대응하는 RejectReason 값도 새로 만들지 않았다 — 사유 목록은 "AI 가 어느 계층에서 틀렸나"의 분류이고 여기엔 틀린 계층이 없다. 다만 판정 자체는 남긴다. review_log 가 비면 "사람이 이 액션을 봤다"가 아무 데도 없다. 사유 없이 한 건 남긴다(CONFIRM 과 같은 모양). MODIFY 의 "값이 하나도 없으면 거절"에는 포함된다 — 예정 시작일만 보내온 MODIFY 는 무엇을 고쳤는지 분명하다. 그 검사가 막는 것은 빈 요청이다. RVW-05 일괄확정은 null 을 넘긴다. 회의 단위 버튼 한 번이라 액션마다 시작일을 고르는 자리가 아니고, 임의로 채우면 사람이 정하지 않은 날짜가 타임라인에 그려진다. <h2>⚠ LocalDate 파라미터가 둘이 됐다</h2> dueDate 와 plannedStartDate 다. 순서가 바뀌어도 컴파일되고 아무 예외도 나지 않는다 — 둘 다 유효한 날짜라서 기한과 예정 시작일이 서로 뒤집혀 저장된다. 포트와 호출 지점 양쪽에 경고를 남겼다. 파라미터가 더 늘면 record 로 묶어야 한다. 테스트 4건 — CONFIRM 에 실림 · 라벨 사유가 null · 시작일만 보낸 MODIFY 통과 · 반려는 반영하지 않음. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FIX] redis_exporter 배포·검증 절차 보완
1) maxHeapSize 중복 제거
tasks.named('test') 와 tasks.withType(Test) 양쪽에 '2g' 가 들어가 있었다.
서로 다른 PR 이 같은 문제를 각자 고치면서 겹쳤다.
withType(Test) 가 test 를 포함하므로 named('test') 쪽을 지운다. 남기는 쪽이
근거 주석을 달고 있고 migrationCheck 까지 함께 덮는다.
값이 같아 동작 차이는 없었지만, 양쪽에 두면 값이 갈렸을 때 어느 쪽이 이기는지
읽는 사람이 알 수 없다. 그 자리에 그 이유를 적어 둔다.
검증 — init 스크립트로 실제 적용값을 조회했다(build.gradle 미변경).
test.maxHeapSize = 2g
migrationCheck.maxHeapSize = 2g
2) csrf.disable() 에 근거 주석
CodeQL(java/spring-disabled-csrf-protection)이 이 줄을 high 로 잡는데 오탐이다.
CSRF 는 브라우저가 자동으로 붙이는 자격증명이 있을 때 성립하는데 이 API 엔 없다.
· 세션 미사용 — SessionCreationPolicy.STATELESS
· 토큰은 Authorization 헤더에서만 읽는다 — JwtAuthenticationFilter, 쿠키를 보지 않는다
· httpBasic·formLogin 모두 비활성
타 사이트가 만든 폼은 Authorization 헤더를 실을 수 없어 공격이 성립하지 않는다.
경보는 false positive 로 닫았다(2026-08-12). 근거를 코드에 남겨야 같은 지적이
다시 올라왔을 때 매번 다시 파헤치지 않는다.
⚠️ 토큰을 쿠키로 옮기면 이 전제가 깨진다는 것도 함께 적었다 — 그때는 다시 켜야 한다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Comment |
예정 시작일만 고친 MODIFY 에 사유 null 로 review_log 를 한 건 남기려 했는데,
V5.9 의 CK_REVIEW_LOG_REASON 이 ACTION + MODIFY 에 사유를 강제한다(SUMMARY_ITEM
만 예외). 제약 위반으로 **판정 트랜잭션 전체가 롤백된다** — 시작일 하나 때문에
확정이 실패한다. 단위 테스트가 ReviewLogRepository 를 가짜로 써서 못 잡았다.
마이그레이션으로 제약을 넓히지 않는다. 그 제약 주석이 왜 조인지 적어 뒀고
("액션 수정은 바뀐 필드로 사유를 자동 추론할 수 있다"), 넓히면 사유 없는 액션
수정이 라벨셋에 섞인다.
제약은 여기서 "사유 없는 ACTION MODIFY 는 유효한 라벨이 아니다"를 말하고 있고
그게 맞다 — 예정 시작일은 AI 가 내지 않는 값이라 {AI 입력 → 정답} 쌍이 성립하지
않는다. 학습할 것이 없는 행이다. 그래서 라벨을 남기지 않는 쪽으로 고쳤다.
판정 사실이 사라지는 것은 아니다. action.review_status 가 HUMAN_CONFIRMED 로
바뀌고 confirmed_at 이 찍힌다 — review_log 는 감사 로그가 아니라 학습 라벨이다.
라벨이 0건이면 벡터도 예약하지 않는다. few-shot 예시는 라벨에 딸린 것이라
가리킬 라벨이 없으면 예시로 쓸 값도 없고, review_log_id 를 비워 넣으면 그 예시가
어느 판정에서 왔는지 되짚을 수 없다. reviewLogIds.get(0) 가 터지는 자리이기도 했다.
테스트를 계약대로 다시 썼다 — 라벨 0건·벡터 0건, 그리고 다른 필드와 함께 오면
그 필드들의 라벨만 남고 시작일이 사유를 더하지 않는다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FEAT] RVW-02 예정 시작일 배선 — 포트를 넓히고 라벨·가드 예외를 둔다
…y-and-takeover [REFACTOR] MEET-07 회의 입장 API 제거
[CHORE] 테스트 힙 설정 중복을 없애고, CSRF 비활성 근거를 코드에 남긴다
CompanyOnboardingCommitter 가 positionRepository.create 의 마지막 인자로 null 을 넘겨, 모든 신규 회사의 온보딩이 100% 500(Z-003)으로 실패했다. position.description 은 NOT NULL DEFAULT ''(V2.3.14) 인데, DEFAULT 는 INSERT 문에서 컬럼을 생략했을 때만 적용된다. JPA 는 항상 모든 컬럼을 쓰므로 null 이 그대로 나가 "Column 'description' cannot be null" 이 되고, 이건 유일성 위반이 아니라서 PositionPersistenceAdapter 의 번역 대상에도 걸리지 않는다. 빈 문자열로 만든다 — 마이그레이션이 기존 행에 채운 값과 같다. 함께 고친 것: TeamPersistenceAdapter.updateLeader 가 flush 를 하지 않아 UPDATE 가 커밋 시점에 나갔다. 온보딩 커밋에서 이 호출은 모든 try/catch 바깥이라 제약 위반이 나도 아무도 잡지 못하고 500 이 된다. 같은 클래스의 create·rename 과 같은 "메서드 경계에서 flush" 패턴으로 맞추고, UK_TEAM_LEADER_MEMBER(V2.2.6) 위반을 AU-029 로 번역한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
TENANT_001 룰(51d0c34)이 이 두 코드보다 3~5시간 뒤에 들어와서, 각자 PR 에서는 안 잡히고 develop→main(#424)에서만 릴리스를 막고 있다. handover 때(ba1be43)와 같은 처리다 — 회사로 좁힐 수 없거나 좁히면 안 되는 이유를 주석으로 남기고 통과시킨다. capture_upload_state.deleteByMeetingId: 엔티티에 company_id 컬럼이 아예 없어 이름으로 좁힐 방법이 없다. meetingId 가 유일한 스코프이고, 회의는 회사 하나에만 속하므로 이 삭제가 다른 회사 행에 닿을 수 없다. 호출자 두 곳 모두 앞단에서 회의 소유를 확인한다 — DeleteRecordingService 는 accessGuard.isSameCompany 로 403 을 내고 나서야 여기까지 온다. 같은 서비스에서 바로 위 두 줄로 나란히 부르는 RecordingPartRepository·RecordingRepository 의 deleteByMeetingId 와 완전히 같은 계약인데, 그쪽은 baseline 이전이라 룰에 안 걸렸을 뿐이다. token_usage_outbox.existsByJobId: job_id 는 전역 UNIQUE 다 (V7.12 uq_token_usage_outbox_job_id). 회사와 무관하게 최대 한 행이라 회사 조건을 더해도 결과가 같고, 오히려 회사로 스코프하면 같은 jobId 가 다른 회사로 한 번 더 적재되려다 INSERT 시점에 UNIQUE 위반으로 터진다. 멱등 키 조회는 전역이어야 맞다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FIX] 온보딩 직급 생성이 description 에 NULL 을 넣어 500 나던 문제 수정
[FIX] Gate 1 TENANT_001 — 회사 스코프가 불가능/불필요한 두 조회에 근거를 남긴다
[FEAT] MEET-01 회의 안건 저장 및 개설 정책 검증
findByMeetingIdInAndStatusIn 은 companyId 를 받지 않는다. 회사 경계는 위층에서 이미 걸린다 — MeetingSummaryQueryService.findSummaryStatuses 가 filterInCompany 로 남의 회사 회의를 떨어낸 뒤 targets → notStarted 로 좁혀 넘기고, 그 경로가 프로덕션 유일 호출자다. 근거가 호출 경로의 유일성에 기대므로 그 조건을 주석에 함께 남긴다. 포트 (SttBlockRepository)가 companyId 를 받지 않아 새 호출자는 필터 없이 부를 수 있고, 진입점이 늘면 주석이 아니라 시그니처로 막아야 한다. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
[FIX] cap 업로드 상태검증 — ACTIVE 세션만 허용
[FIX] Gate 1 TENANT_001 — 미완 블록 배치 조회에 회사 스코프 근거를 남긴다
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.
No description provided.