각 항목은 가능하면 재현 테스트를 먼저 쓰고(실패 확인) → 고친 뒤 → 통과시키는 순서로 처리했습니다.
| 항목 | 값 |
|---|---|
| DART 고유번호 | 119,447개사 (StAX 스트리밍 4.2초) · 종목코드 보유 3,994 · 현재 상장(유가·코스닥) 2,649 |
| 유니버스 | 43개사 (KSIC 41 종합건설 39 · 421 토목 기반조성 3 · 수동 포함 1) |
| 재무제표 | 보고서 946건 (OK 927 · NO_DATA 19), 원천 121,675행 → 표준화 13,815행, 2021Q1~2026Q2 |
| 공시 | 3,514건 (위험 유형 52건: 회생·부도·거래정지·감사의견) |
| 지역 | V-World 시군구 269개(일반구 포함) → 화면 단위 230곳 (일반구 합성 시 13곳) |
| 지역 통계 | 21,697행 (KOSIS 미분양 36개월 · R-ONE 매매/전세지수 37개월 · SGIS 2024 총가구) → 지역 지표 40,194건 |
| 경보 | 500건 (열린 경보 44) |
| DART 호출 | 5,349건 (일일 상한 15,000 이내) |
| 검사 | 결과 |
|---|---|
| 회계 항등식 자산 = 부채 + 자본 (927개 기업·기간) | 924 일치 · 3건 ±1원 (공시 원문 반올림) |
| 차입금 합 ≤ 부채총계 | 위반 0 |
| 계정 매핑률 (핵심 12개) | 91.5% — 자산·부채·자본·유동·매출·영업이익 100%, 영업현금흐름 99.9%, 이자비용 95.4%, 단기차입금 90.3%, 장기차입금 79.4%, 사채 33.5% (사채 미발행 회사가 많음) |
| 지역 코드 매핑률 | KOSIS 98.8% · R-ONE 100% (권역 합계 41건 제외) · SGIS 98.8% — 미매핑 6건은 모두 2026-07 인천 개편 사유 기록 |
| 골든 테스트 | 실제 응답 6건 (GS건설 · 계룡건설산업 · KCC건설, 2025 사업보고서 · 2026 반기) 항등식·스냅샷 통과 |
| # | 증상 | 원인 | 해결 | 고정 테스트 |
|---|---|---|---|---|
| 1 | 강제 종료 후 restart 한 기업개황 Job 이 COMPLETED 인데 50건 누락 | JdbcPagingItemReader 가 ‘페이지 시작 키 + 페이지 안 건수’로 재시작하는데 WHERE 가 처리한 행을 빼 버려 미처리 행을 건너뜀 (850 mod 200 = 50) |
처리 여부로 걸러지는 Reader 는 saveState(false) — 재시작 지점을 DB 에서 도출 (ADR-008) |
CompanyProfileRestartIT (수정 전 41건 누락으로 실패) |
| 2 | 컨테이너를 죽이면 실행이 STARTED 로 남아 영원히 409 | 프로세스가 죽으면 JobRepository 상태를 아무도 정리하지 않음 | Step last_updated 를 하트비트로 보고 10분 넘게 멈추면 JobOperator.recover() 로 FAILED 처리 + 관리 API POST /batch/executions/{id}/recover + 화면 버튼 |
StaleExecutionIT |
| 3 | 상장폐지 회사(건영·대아건설 등)가 유니버스에 포함될 뻔함 | corpCode.xml 은 상장폐지 회사에도 종목코드를 남김 (기업개황 corp_cls = E) | 유니버스 조건에 corp_cls ∈ {Y, K} 추가 |
ClassifierAndUniverseTest |
| 4 | KOSIS 미분양 Job 실패 (err 31) | 한 요청 4만 셀 제한 — 36개월 한 번에 요청 | 6개월씩 나눠 요청 | 실데이터 재실행 |
| 5 | R-ONE Job NPE | 응답에 이름 없는 집계 행 3건 (CLS_NM·CLS_FULLNM null) |
이름 없는 행 건너뜀 | 실데이터 재실행 (FAILED → restart → COMPLETED) |
| 6a | 배치 모니터에 이미 해결된 FAILED(#7 강제 종료 검증, #15 R-ONE NPE)가 그대로 빨간색으로 남아 조치가 필요한지 알 수 없음 | 실행 이력은 감사 기록이라 지우면 안 되는데, 화면이 ‘같은 JobInstance 가 restart 로 완료됐는지’를 보여 주지 않음. 실패 메시지도 ‘Unable to process chunk’ 뿐이라 원인이 안 보임 | 실행 이력 API 에 resolvedBy(이후 완료된 실행 번호) 추가 → “→ #9 재시작으로 해결” 표시, 실패 시 종료 메시지 맨 앞에 근본 원인 한 줄(원인: DartApiException — …), 정리된 실행에 정리 사유 기록 |
FinancialStatementJobIT · StaleExecutionIT |
| 6 | 경계 파생 Step 실패 | PostGIS 의 GEOS 3.11 에 ST_CoverageSimplify 없음, 실패 후 대체 쿼리도 aborted 트랜잭션에서 실패 |
GEOS 버전을 먼저 확인해 함수 선택 | restart 로 파생 Step 만 재실행 확인 |
| # | 실제 사례 | 해결 |
|---|---|---|
| 7 | ‘유동성사채’·’사채’를 순액 줄 + 액면 줄로 두 번 공시 → 사채 이중 집계 | 바로 앞 줄과 같은 이름이면 액면 줄로 보고 제외 |
| 8 | 유동 ‘회사채’(700억)·비유동 ‘회사채’(320억) — 이름이 같아 한 줄만 셀 뻔함 | 떨어져 있는 같은 이름은 합산 (7번 규칙은 인접 줄에만) |
| 9 | ‘유동성사채’에 차입금용 IFRS 태그(CurrentPortionOfLongtermBorrowings)가 붙어 단기차입금·사채 양쪽에 집계 |
해당 태그 규칙 제거, 한 원천 행은 한 표준계정에만 |
| 10 | GS건설은 본문에 차입금이 없고 ‘단기금융부채’·’장기금융부채’로 묶어 공시 | 우선순위 50 이상 = 대체 규칙 (본 규칙에 걸린 행이 없을 때만) |
| 11 | ‘단기차입금및유동성장기부채’ · ‘유동 차입금(사채 포함)’ · ‘차입금등(비유동)’ 등 표기 변형 수십 가지 | 재무상태표 부채 구간(유동/비유동) 판별 + 구간 규칙 (V6 마이그레이션) → 단기 81.6→90.3%, 장기 68.2→79.4% |
| 12 | 이자비용이 현금흐름표 ‘이자의 지급’ 대체인지 근거에서 알 수 없음 | 지표 구성요소·R-C02 근거에 원천 계정(CF:이자지급) 표시 |
| # | 증상 | 해결 |
|---|---|---|
| 13 | ‘주권매매거래정지해제(액면병합 주권 변경상장)’이 거래정지로 분류 → HIGH 경보 오탐 | 분류 사전에 중립 키워드(해제·액면병합·변경상장 등) — 재분류 후 경보 RESOLVED |
| 14 | 공시 규칙 경보가 재분류로 거짓이 돼도 EXPIRED 로 닫힘 | 창 안이면 RESOLVED, 창 밖이면 EXPIRED |
| # | 증상 | 해결 |
|---|---|---|
| 15 | V-World 경계에 이미 인천 신설 구가 있는데 seed 의 ‘미매핑 사유’ 항목이 이름 매칭보다 먼저 적용 | 수동 항목은 이름으로 못 찾을 때만 사유로 사용, regionCd 지정 시에만 강제 |
| 16 | R-ONE ‘전국’·’수도권’·’5대광역시’가 미매핑으로 집계 | 하위 이름 없는 최상위 합계 = AGGREGATE (매핑률 분모 제외) |
| # | 증상 | 해결 |
|---|---|---|
| 17 | 비JSON 본문 · 허용 안 된 메서드 · 잘못된 Content-Type · limit=-1 이 500 |
400 · 405 · 415 · 범위 보정 |
| 18 | 동시 실행 경합 시 JobExecutionAlreadyRunningException 이 500 |
409 JOB_ALREADY_RUNNING |
| 19 | 검증 메시지가 영어 (“must be less than…”) — 컨테이너 로케일 | spring.web.locale=ko_KR 고정, 메시지를 필드: 설명 형식으로 |
| 20 | web(:3400) 경유 Swagger 의 서버 주소가 http://app:8410(도커 내부) → Try it out 실패 |
OpenAPI servers 를 상대 경로 / 로 |
| 21 | 지역 수동 매핑 404 — KOSIS 코드에 / 가 있어 경로 변수로 못 받음 |
PUT /mapping/region-codes 본문으로 받기 |
| 22 | 화면에서 추가한 매핑 규칙을 지울 방법이 없음 | DELETE /mapping/account-rules/{id} (USER 규칙만) |
| 23 | 대시보드 최신 공시일이 UTC 타임스탬프로 하루 어긋나 보임 | SQL 에서 날짜 문자열로 |
| # | 증상 | 해결 |
|---|---|---|
| 24 | 카카오 지도가 한반도에 맞춰 확대되지 않음 | 컨테이너 크기가 정해진 뒤 relayout() → setBounds, ResizeObserver |
| 25 | ‘데이터 없음’과 ‘변화 거의 없음’(중립 회색)이 구분 안 됨 | 데이터 없는 지역은 흐리게 |
| 26 | R-ONE 조사 대상이 아닌 군 지역에 “REB_API_KEY 필요” 표시 | 문구 수정 |
| 27 | 로딩 중 순위 제목이 “값이 작은 순”으로 잘못 표시 · 빈 데이터에서 범례가 ‘0’ 한 칸 | 정의 로드 전 중립 제목 · 빈 범례 숨김 |
| 시나리오 | 결과 |
|---|---|
PUT /rules/R-C01 임계 300→250 |
v2 생성, 같은 값 재요청은 v2 유지, 잘못된 심각도 400, 토큰 없음 401 |
| 규칙 재평가 | v1 열린 경보 → CLOSED RULE_CHANGED, v2 경보 새로 생성 → 원래 값으로 v3 |
PATCH /alerts/{id} ACK → OPEN |
ackedAt 기록·해제 |
| 매핑 규칙 추가 · 잘못된 정규식 · 삭제 · seed 규칙 삭제 | 201 · 400 · 200 · 400 |
| 지역 수동 매핑 · 없는 지역 · 되돌리기 | MANUAL · 404 · UNMAPPED(사유 유지) |
| 같은 Job 동시 실행 | 두 번째 409 |
| 강제 종료 → recover → restart | 실행 #7 FAILED → #9 가 같은 JobInstance 로 이어감 |
| 항목 | 값 |
|---|---|
| 공시 원문 (수주·해지·채무보증, 최근 1년) | 1,313건 — 구조화 1,312 · 일부만 1 (거래정지 공시가 보고서명에 ‘공급계약’을 포함 — 계약 서식이 아님) |
| 수주 계약 | 1,122건 (현재 739 · 정정으로 대체 360 · 해지 연결 1) — 공사 지역 시군구 922 (82%) · 시도만 93 · 해외 36 · 해석 불가 52 · 지역 없음 19 |
| 채무보증 | 170건 (PF 유형 표 66 · 정정 대체 26) — ‘총 잔액 = 보증 한도(미사용 포함)’ 주석 85 · 사용 잔액 계산 가능 32 |
| 아파트 실거래 | 256 시군구 × 25개월 = 6,400 (시군구, 월) · 거래 1,032,741 + 해제 63,438 = 1,096,179행 (파티션 2024 · 2025 · 2026) → 화면 단위 월 집계 5,520 |
| 주가 | 43종목 × 2023-09-25 ~ 2026-09-22 = 31,138행 (주말 0행) |
| 새 경보 | R-C05 15 · R-R03 6(열림 1) · R-X01 1 |
| 호출 | DART 1,356 (원문 1,313 + 기타) · 실거래 6,929 · 주가 43 — 모두 자체 일일 상한 이내 |
| # | 증상 | 원인 | 해결 | 고정 |
|---|---|---|---|---|
| 28 | worker 가 공시 원문 Job 과 주가 Job 을 동시에 시작하자 한쪽이 could not serialize access due to read/write dependencies 로 FAILED |
Spring Batch JobRepository 의 실행 생성 격리수준 기본값 SERIALIZABLE | 같은 Job 중복은 큐 부분 유니크 인덱스 + advisory lock 이 막으므로 isolation-level-for-create=read_committed + 메타 테이블 충돌 3회 재시도 |
JobRequestQueueIT.서로_다른_Job_을_동시에_시작해도_둘_다_실행된다 — 수정 전 설정으로 되돌리면 5회 중 3번째에 같은 오류로 실패, 수정 후 통과 |
| 29 | 수주 공시 24건이 PARTIAL (계약명·계약일 없음) | 자율공시 서식은 계약명을 ‘세부내용’(해지는 ‘세부물건’), 계약일을 ‘계약(수주)일’로 씀. 해지의 ‘관련공시’에 여러 공시가 나열됨 | 파서 v2 — 항목 경로 추가, 관련공시에서 ‘공급계약 체결’ 날짜를 고름 | 실제 원문 fixture 2건 추가. 재파싱 1,313건 3.8초 · DART 호출 0건 (호출 수 전후 1,356 동일) → PARTIAL 24 → 1 |
| 30 | 공사 지역 1,100건 중 시군구 784건(71%) | “경기도 용인시~경기도 화성시”(물결표), “부산시 북구”(광역시를 ‘시’로), “경기 용인”(접미사 없음), “평택시 고덕면 …”(시도 없음), “세종특별자치시 전동면”(시도 = 시군구 하나) | 경계 이름과 정확히 하나가 맞을 때만 매핑하는 규칙 추가 (추정 없음 — 전국에 둘인 ‘고성군’은 매핑 안 함, 2026-07 개편으로 없어진 ‘인천 서구’는 시도까지만) | AddressRegionMatcherTest 16 사례 → 922건(82%) |
| 31 | R-C05 가 15개사에서 발생 — 과다 의심 | 원문 확인 결과 값은 맞지만, 절반(85/170)이 ‘총 잔액은 채무보증 한도이며 미사용잔액 포함’이라는 주석을 담 | 파서 v3 — 주석 표기를 플래그로, 한도별 ‘미사용한도’ 합을 읽어 한도 합이 총 잔액과 ±1% 로 맞을 때만 사용 잔액 계산. 규칙은 공시값 그대로, 근거·화면에 ‘한도 기준 · 사용 잔액’ 표시 | FilingParserTest (IPARK: 한도 23,406억 ≈ 총 잔액 → 미사용 3,406억) · 맞지 않으면 비움 |
| 32 | 주가 차트의 경보 표시가 모두 2026-09 에 몰림 | 표시일로 first_seen_at(배치를 처음 돌린 날)을 씀 — 과거 분기 경보도 오늘 날짜가 됨 |
백테스트와 같은 point-in-time 기준(근거 공시 접수일 중 가장 늦은 날)으로 표시 | 화면 문구 · API |
| 33 | api 포트(8410)를 호스트에 열어 두면 X-Forwarded-For 를 위조해 레이트리밋·감사 로그의 IP 를 바꿀 수 있음 |
Tomcat 기본 신뢰 프록시가 사설 대역 전체 — 도커 게이트웨이에서 온 요청의 XFF 를 믿음 | api 포트를 닫음 (→ 41번에서 불충분함을 발견) | 실측: 내부망 컨테이너가 보낸 위조 XFF 가 그대로 기록됨 |
| 34 | 규칙 평가 Job FAILED syntax error at or near "NULLORDER" |
텍스트 블록 + 상수 결합에서 공백 누락 (1차의 “WHEREc” 와 같은 유형) | 결합부 공백 | RuleEvalJobIT 가 먼저 잡음 (R-X01 이 수주 조회를 부름) |
| 35 | (실행 전 검토) 주가 수집 run 의 출처 ‘FSC_STOCK’ 이 collect_run.source varchar(8) 초과 |
— | 출처 코드 ‘FSC’ | — |
| 36 | 경보 검증 화면: ‘제외’ 열이 줄바꿈되어 행이 매우 길어짐, 차트 부제가 없는 라벨을 설명 | — | 제외 사유를 표본 수 아래 보조 줄로, 표본 수를 축 라벨(R-C02 · n8)로 |
화면 캡처 |
| 37 | Trivy: app 이미지의 내장 Tomcat 11.0.24 에 고칠 수 있는 CRITICAL 3건 (CVE-2026-65182 · 65905 · 68525) | Boot 4.1.1 BOM 의 Tomcat 버전 | 이 앱은 Tomcat 자체 인증기(DIGEST·FORM)·컨테이너 보안 제약을 쓰지 않지만(인증은 Spring Security) 11.0.25 로 올림 — BOM 이 Gradle platform 이라 해석 규칙으로 | CI images 잡의 Trivy(고칠 수 있는 CRITICAL 이면 실패) · 로컬 재검사 0건 · 테스트 139 통과 |
| 38 | CodeQL js/client-side-unvalidated-url-redirection: 로그인 후 next 로 이동 |
’/’ 로 시작하고 ‘//’ 가 아니면 허용하던 문자열 검사 — /\evil.example 은 브라우저가 프로토콜 상대 주소로 해석해 외부로 이동(Node URL 로 재현) |
new URL(next, origin) 으로 해석해 출처가 같을 때만 경로 부분 사용 |
수정 전후 5개 입력 비교 |
| 39 | CodeQL java/tainted-arithmetic: GET /alerts?page=2147483647 이 500 |
page * size int 오버플로 → 음수 OFFSET |
page ≤ 100,000 검증(400) + long 으로 계산 |
ApiIT (수정 전 실제 스택에서 500 확인 → 400) |
| 40 | CodeQL 나머지 4건 — 로그 위조 · 예측 가능한 임시 경로 · URL 쿼리의 임의 키를 객체에 펼침 · 화면 경로 파라미터를 API 경로에 그대로 씀 | — | 감사 실패 로그의 줄바꿈 제거 · Files.createTempDirectory(소유자 전용) · 알려진 키만 URL 상태로 · 기업(8자리)·지역(5자리)·경보 ID 형식 검사 + API 클라이언트가 .. // \ 경로 거부 |
CodeQL 재분석 |
| 41 | (재검증) 33번 수정 후에도 web 을 거친 위조 XFF 가 감사 로그에 그대로 기록되고, XFF 가 없으면 모든 사용자가 web 컨테이너 IP 하나로 보임 → 모든 사용자가 레이트리밋 버킷 하나를 공유(한 사용자가 전체를 429 로 막을 수 있음) | Next.js rewrites 프록시는 받은 XFF 를 전달만 하고 실제 접속 주소를 붙이지 않음 + 신뢰 대역에 게이트웨이 포함 | edge(nginx) 를 유일한 진입점으로 — XFF 를 실제 접속 주소로 덮어씀, api 는 고정 IP(10.213.0.10)의 edge 만 신뢰(server.tomcat.remoteip.internal-proxies), /api/v1 은 Next 를 거치지 않고 api 로 |
위조 요청 3가지(호스트 · 내부 컨테이너 · web 경유)의 기록 IP 비교, E2E X-Forwarded-For 위조는 감사 로그 IP 를 바꾸지 못함 · k6 레이트리밋(컨테이너 IP 별 버킷) · 부하 처리량 445 → 766 req/s |
| 규칙 | 사용 표본 / 경보 | 평균 초과수익률 | 중앙값 | 업종보다 약했던 비율 | t |
|---|---|---|---|---|---|
| R-C01 부채비율 급등 | 6 / 22 | −0.9% | −5.6% | 50% | −0.05 |
| R-C02 이자보상배율 < 1 연속 | 8 / 18 | −17.2% | −8.7% | 75% | −2.52 |
| R-C03 영업현금흐름 적자 연속 | 5 / 12 | −3.0% | −8.8% | 60% | −0.28 |
| R-C05 채무보증 잔액 과다 | 3 / 15 | −8.7% | −9.2% | 67% | −0.61 |
| 비교 기준 (신호 없는 모든 창) | 480 | −0.0% | −3.2% | 61% | −0.02 |
R-C02 만 비교 기준과 뚜렷이 다르다(표본 8건이라 확정할 수 없음). 제외 사유는 대부분 ‘앞 사건과 겹침’(같은 규칙·종목이 여러 분기 연속 참)과 ‘기간 미경과’. 원천 종가가 수정주가가 아니므로 창 안에서 상장주식수가 2% 넘게 바뀐 경우를 제외했다(대우건설처럼 자사주 소각으로 1.1% 바뀐 경우는 포함 — 일별 등락률로 실제 가격 변동임을 확인).
| 확인 | 결과 |
|---|---|
커넥션 계정 (pg_stat_activity) |
api · worker 모두 buildrisk_app (마이그레이션만 소유자 계정) |
| 호스트에서 api 직접 접근 | 연결 불가 (포트 미공개) · edge 경유만 |
X-Forwarded-For 위조 |
호스트 · 내부 컨테이너 모두 실제 접속 주소로 기록 (41번) |
| 응답 헤더 | web: CSP(script-src ‘self’ + 카카오) · X-Frame-Options DENY · nosniff · Referrer-Policy · Permissions-Policy / api: CSP default-src ‘none’ |
| CSRF · 세션 쿠키 | XSRF-TOKEN SameSite=Strict · BR_SESSION HttpOnly · SameSite=Strict (E2E 로 확인) |
| 익명 변경 요청 | 401 · 감사 로그에 남음 |
| 카카오 지도 + CSP | 정상 표시, 콘솔 위반 0 |
| Grafana 익명 API | 401 |
방법 API 1,051개 경로 전수 호출(모두 2xx) + 경계값 23개(22개는 형식을 갖춘 4xx, 알 수 없는 출처 필터 1개는 빈 목록 200) · 18개 화면을 390 px 폭으로 열어 가로 넘침 측정 · 역할별 쓰기 흐름 16단계 · 경보 근거 문장을 실데이터로 읽어 보기 · k6 부하를 여러 번 연속으로 걸며 api 메모리(RSS · 힙 · 힙 밖)를 3초마다 기록.
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 42 | /mapping/unmapped 2.1초 — 느린 쿼리 로그에서 유일하게 500 ms 초과 |
fs_std.period_key 는 char(6) 인데 계산한 키는 text → 비교할 때 PK 인덱스를 못 씀 |
계산한 키를 char(6) 로 cast |
쿼리 1.3초 → 0.1초 |
| 43 | 지도에서 지표를 바꿀 때마다 1.95 MB(gzip 546 KB) | 연 1회 바뀌는 경계와 매월 바뀌는 값을 한 응답에 묶음 | 경계(버전 URL · immutable)와 값 목록(약 5 KB) 분리 (ADR-020) | 지표 전환 546 KB → 5 KB · 재방문 304 |
| 44 | 경계 응답이 압축되지 않음 | Tomcat 은 강한 ETag 응답을 압축하지 않음 | 약한 ETag (이후 49번에서 미리 압축한 바이트로) | gzip 적용 확인 |
| 45 | web 컨테이너를 다시 만들면 edge 가 502 | nginx 가 upstream 이름을 시작할 때 한 번만 해석 → 옛 IP | resolver 127.0.0.11 + zone + resolve (10초마다 재해석) |
web 재생성 뒤 자동 복구 |
| 46 | (검토) web · edge 에 healthcheck 가 없어 edge 가 web 준비 전에 트래픽을 받을 수 있음 | — | web(/api/config) · edge(/healthz) healthcheck, edge 는 web 이 healthy 일 때 시작 |
docker compose ps |
| 47 | 부하 중 api 가 죽음 — 처리량 16 req/s · p99 30초 이상 뒤 connection refused | 컨테이너 메모리 한도가 없어 JVM 최대 힙이 도커 VM 전체(7.7 GB, 다른 스택과 공유)의 75% = 6.2 GB. 부하 15초 만에 RSS 424 MB → 1.66 GB, VM 메모리 부족으로 도커가 43초 멈추고 Redis 응답이 끊긴 뒤 커널 OOM 킬(exit 137) |
모든 서비스에 mem_limit (ADR-021) |
3초 간격 RSS 기록 · 컨테이너 로그의 exit 137 |
| 48 | 한도(1 GB)를 걸어도 반복 부하 세 번째에 다시 OOM 킬 | 힙 768 MB(75%) + 힙 밖 약 300 MB > 1 GB. 게다가 1.8 GB 미만이면 JVM 이 GC 를 Serial 로 바꿈(힙 풀 이름 Tenured Gen 으로 확인), 이미지 ENTRYPOINT 의 명령줄 플래그가 compose 설정을 덮어씀 |
이미지 한 곳에 -XX:+UseG1GC -XX:MaxRAMPercentage=60 -XX:+ExitOnOutOfMemoryError |
3회 연속 부하: 최대 RSS 625 MiB / 1 GiB · 힙 밖 137 MB · 재시작 0 |
| 49 | 큰 응답이 요청마다 메모리를 크게 만듦 | Redis 캐시가 문자열 응답을 JSON 문자열로 한 번 더 감싸 저장 → 요청마다 1.9 MB 를 읽고 이스케이프 해제 · 재인코딩 · 압축. 옛 /regions/geojson 은 화면이 쓰지 않는데 요청 하나로 1.9 MB 를 만듦. simplify 에 아무 값(0~5000)이나 받아 값마다 PostGIS 단순화 + 캐시 항목 |
경계는 프로세스 안에 UTF-8 · gzip 바이트로 버전당 한 번(PayloadCache), 옛 경로 삭제, simplify 는 100 · 500 · 1000 만 |
ApiIT (gzip · 원본 동일 · 304 · immutable · 400) · 최악 조건(50 VU 가 경계 전체 반복) 205 req/s · 111 MB/s · 오류 0 |
| 50 | 메모리를 고친 뒤에도 부하 중 db CPU 152%, 목록 API p95 300~600 ms | 부하의 대부분인 목록 5종(기업 · 경보 · 대시보드 · 지역 · 지역 시계열)이 캐시 없이 매번 DB 조회. 기업 목록 CTE 가 인라인돼 기업마다(43회) 집계 | 목록을 Redis read-through 캐시로(기본 조합만), 무효화를 모든 Job 이 끝나는 한 곳으로 옮김(전에는 Job 5개만 — 공시 · 미분양 · 가격지수 뒤에는 무효화 없음), CTE MATERIALIZED (ADR-022) |
13.8 → 2.1 ms · RuleEvalJobIT (캐시된 목록이 Job 직후 새 경보를 보여 줌) · 504 → 약 3,400 req/s, API p95 307 → 30 ms |
| 51 | 지도가 탭 전환 · 화면 크기 변경 뒤 전국에 맞춰지지 않음 | 처음 그릴 때만 범위를 맞춤 | 크기 변화 감지(ResizeObserver)로 다시 맞춤(사용자가 움직였으면 유지) + ‘전국 보기’ 버튼 | 화면 |
| 52 | 390 px 폭에서 표 · 차트가 있는 화면이 가로로 넘침 | CSS grid 항목의 기본 min-width: auto — 넓은 표가 칸을 밀어냄 |
.grid > * { min-width: 0 } + 좁은 화면에서 보조 열 숨김 |
18개 화면 가로 넘침 0 (Playwright 측정) |
| 53 | 좁은 화면에서 경보를 눌러도 반응이 없어 보임 | 상세가 목록 아래에 있음 | 고르면 상세로 스크롤 | 화면 |
| 54 | 대시보드의 Job 이름이 화면 코드에 따로 있어 Job 을 추가하면 어긋남 | 목록 중복 | 서버의 Job 목록(JobCatalog)에서 이름을 내려줌 |
— |
| 55 | 거래량 · ㎡당 가격 전년 대비(부호 있는 지표)가 한 방향 색으로 칠해져 감소가 ‘위험 낮음’처럼 보임 | 부호 있는 지표 목록에서 빠짐 | 발산형 색(0 = 중립) | 화면 |
| 56 | worker 를 재시작(배포)하면 돌던 Job 이 강제 종료될 수 있음 | 종료 처리 없음 + 도커 기본 유예 10초 | 종료 신호에 청크 경계에서 STOPPED(최대 45초), 다시 요청하면 이어감 · stop_grace_period: 60s (ADR-012) |
AptTradeJobIT — 멈춤 → 재요청 → COMPLETED, 호출 72회 · 재호출 0 |
| 57 | 이자보상배율 오경보 2건 — 영업이익이 양수인데 R-C02 | 분기 이자비용이 음수: 1분기 보고서엔 ‘이자지급’, 반기엔 ‘이자지급(영업)’만 잡혀 누적이 줄어듦(6.2억 → 4.8억) → 누적 차분 음수 → 부호가 뒤집힌 배율. 16개사 28개 값 | 음수일 수 없는 분모가 음수면 값 대신 INCONSISTENT (두 경보는 이미 RESOLVED 로 닫혀 백테스트에는 들어가지 않았음을 확인) |
MetricCalculatorTest |
| 58 | 경보 문장 “2025Q2 -6,049,063,302배” | 분기 이자비용 5원(누적 247,361,153 → 247,361,158원), 영업손실 302억 — 계산은 맞지만 영업손실이면 배율 크기는 뜻이 없음 | 영업손실 분기는 ‘영업손실’로 쓰고 숫자는 관측값에 그대로. 기업 화면 차트 축은 −10~20배로 자르고 툴팁은 실제 값 | RuleEvaluatorTest |
| 59 | 근거 문장의 숫자를 읽기 어려움: 천 단위 구분 없음, “21,250억원”, 미분양 “20,950% 늘었고”(4호 → 842호), “-566억원로” | — | 천 단위 구분 · 조/억/만원(2조 1,250억원) · 기저가 작으면 늘어난 호수를 함께(838호(20,950%)) · 조사 | RuleEvaluatorTest (금액 6사례 · R-R01 문장) |
| 60 | 감사 로그: 거부된 요청(401 · 403)이 실제 경로(/api/v1/rules/R-C01)로 남아 행위별로 묶이지 않음 |
인가 단계에서 거부되면 핸들러가 정해지지 않아 경로 템플릿이 없음 | MVC 매핑에서 템플릿을 찾아 기록 | SecurityIT |
| 61 | k6 부하 스크립트가 화면이 더 이상 쓰지 않는 GeoJSON 을 7% 섞어 측정 | — | 실제 화면 호출(지표 목록 5% · 경계 재검증 304 2%)로 | — |
| 시점 | 처리량 | API p95 | 오류 | api 메모리 |
|---|---|---|---|---|
| 점검 시작 (한도 없음 · 목록 캐시 없음) | 504 req/s | 307 ms | 0 — 이어서 돌리면 OOM 킬 | 최대 힙 6.2 GB |
| 한도 1 GB + 힙 75% | 566 req/s | 256 ms | 0 → 세 번째에 OOM 킬 | RSS 한도 도달 |
| 최종 (힙 60% · G1 · 경계 프로세스 캐시 · 목록 캐시) | 약 3,400 req/s (3회 3,467 · 3,400 · 3,353) | 30 ms | 0 / 약 92만 건 | 최대 RSS 625 MiB · 재시작 0 |
| 최악 조건: 50 VU 가 캐시 없이 경계 전체(gzip 530 KB) 반복 | 205 req/s · 111 MB/s | — | 0 | 한도 안 |
같은 기계에서 다른 프로젝트의 스택이 함께 돌고 있어 절대값은 흔들린다(부하 평균 6~15). 비교는 같은 날 연달아 잰 값끼리.
방법 Playwright 스크립트로 19개 화면을 데스크톱(라이트) · 모바일(다크)에서 열어 콘솔 오류 · 실패한 요청 · 이름 없는 버튼 · 라벨 없는 입력 · h1 개수 · 가로 넘침을 수집 ·
다크 모드 화면을 눈으로 확인 · 실제 스택에서 Job 실행 중 docker compose stop worker → 재요청 · pg_stat_user_tables 로 반복 순차 스캔 확인 · next build 로 화면별 첫 로드 JS 측정.
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 62 | 배포로 worker 를 멈추면 Job 은 청크 경계에서 잘 멈추지만, 로그에 “Redis 캐시를 쓸 수 없어” — 멈춘 Job 이 이미 커밋한 데이터가 캐시에 최대 10분 반영되지 않음 | drain 을 @PreDestroy 에서 기다림 → Spring 이 Redis 연결을 먼저 닫은 뒤라 Job 종료 후 캐시 무효화(56 · 50번)가 실패 |
빈을 멈추기 전에 발행되는 ContextClosedEvent 에서 drain (관리 포트 자식 컨텍스트 이벤트는 한 번만) |
실제 스택: 실행 중 stop → STOPPED · 캐시 세대 +1 · 경고 0 → 재요청 시 같은 JobInstance 가 이어받아 COMPLETED, 멈춘 실행 2건은 ‘해결됨’ |
| 63 | 경보 화면에서 201번째 이후 경보를 볼 방법이 없음 — 200건만 받고 아래에 “522건” | 목록 크기를 200 으로 고정, 페이지 넘김 없음 | 공용 Pager(100건씩, 페이지를 URL p 에) — 필터를 바꾸면 첫 페이지로 |
E2E 경보: 한 페이지를 넘는 목록은 페이지를 넘겨 끝까지 볼 수 있다 |
| 64 | 기업 목록 정렬이 URL 쿼리의 임의 키를 그대로 펼쳐 다시 씀(예전에 CodeQL 이 경보 화면에서 잡은 패턴), 200곳이 넘으면 잘림 | — | 알려진 키(sort · p)만, 정렬 값 허용 목록, Pager |
화면 |
| 65 | 경보 검증의 비교 기준 평균이 빨간 “-0%” | -0.03% 를 반올림하면 음수 0 → Intl 이 “-0” 으로 표시, 색은 반올림 전 값으로 결정 | signDisplay(negative · exceptZero), 색도 반올림한 값 기준 |
화면 |
| 66 | 표 열의 소수 자릿수가 들쭉날쭉 (45% · 45.7%, -3% · -17.2%) | 끝자리 0 을 지우는 서식 | 표 열은 자릿수 고정(45.0% · 45.7%) | 화면 |
| 67 | 차트 눈금이 0·7·14·21·28, 0·-0.65·-1.3 — 가격지수(95~116)는 0~120 축에 눌려 변화가 안 보임 | 차트 라이브러리 기본 눈금 · 모든 차트에 0 기준선 | 모든 추이 · 막대 차트에 1·2·5 단위 눈금(niceAxis), 지수 · 가격처럼 수준을 보는 값은 0 기준선 없이 |
화면 (95·100·…·115) |
| 68 | 기업 목록의 음수 이자보상배율(-17.7배)에 설명이 없음 — 상세 화면과 다름 | — | ‘영업손실’ 보조 줄 | 화면 |
| 69 | 배치 화면이 실행 상태는 COMPLETED, 요청 큐는 ‘완료’ — 같은 화면에 두 말 | — | 한국어(완료 · 실행 중 · 멈춤 · 실패), 원래 코드는 툴팁 | 화면 |
| 70 | 로그인 · 지역 상세에 h1 이 없음 (화면 읽기 프로그램의 페이지 제목) | 카드 제목이 h2/h3 | Card as="h1" · RegionPanel headingAs="h1" |
자동 점검 스크립트 |
| 71 | 기업 상세 · 지도의 첫 로드 JS 224 KB — 첫 화면(개요 탭 · 지도)에는 차트가 없는데 차트 라이브러리를 함께 받음 | 정적 import | 차트를 쓰는 곳에서 지연 로드(next/dynamic, 같은 높이 자리표시자) |
224 → 104 KB · 224 → 102 KB (next build) |
| 72 | 대시보드 ‘최신 공시일 · 최신 재무 기간’, 기업 목록 ‘기업별 최신 기간’, 지역 상세 ‘출처 코드 매핑’이 매번 표를 끝까지 읽음 (부하 시험 동안 company_metric · disclosure 각 5,780회) | 조건에 맞는 인덱스 없음 (PK 선두 열이 다름) | V15 인덱스 3개 — 롤백되는 트랜잭션에서 먼저 재 보고 효과가 있는 것만 |
EXPLAIN ANALYZE 1.34 → 0.04 ms · 1.21 → 0.28 ms · 0.38 → 0.03 ms |
| 73 | 캐시가 실제로 효과가 있는지 볼 수 없음 | 지표 없음 | buildrisk_cache_requests_total{result=hit·miss·error} + Grafana 적중률 패널 |
ApiIT (miss 1 → hit 1) |
| 74 | 감사 로그 화면이 최근 200건만 보여 주는데 그렇게 적혀 있지 않음 | — | ‘최근 N건 (최대 200건 — 좁혀 보세요)’ | 화면 |
| 75 | (코드) 38개 파일에 패키지 이름을 통째로 쓴 클래스 참조 — BatchLauncher 21곳 · DartClient 15곳 | 기능을 덧붙이며 import 없이 씀 | import 로 정리 (이름이 겹치거나 같은 패키지에 같은 이름이 있으면 그대로 둠) | 컴파일 + 테스트 148개 |
자동 점검에서 콘솔 오류 · 실패한 요청 · 이름 없는 버튼 · 라벨 없는 입력 · 가로 넘침은 0건이었다(404 화면의 의도된 404 제외).
경보 성적표(ADR-023)를 만들며 공시 5년치를 백필하고, 부실 사건을 정답으로 쓰려고 공시 제목을 하나씩 확인하다 찾은 문제(76~80). PF 우발채무(ADR-024)는 보관한 정기보고서 구간 468건을 파서 버전마다 전수 파싱해 이전 버전과 비교하고, 사업장 합 · 본문 서술 합계와 대조해 어긋난 보고서를 원문으로 하나씩 확인했다(81~92). 알림(ADR-025)을 붙이며 찾은 것(93), push 뒤 CI 보안 게이트 · 보안 경고가 잡은 것(94~97), 사업장 위치(ADR-026)를 만들며 찾은 것(98~99), 근거 계보(ADR-027)로 경보 10종을 원천까지 거슬러 오르다 찾은 것(100~101), 테스트가 늘며 간헐 실패로 드러난 정상 종료의 경쟁(102), 화면 테스트의 실패 기록을 남기게 하자마자 잡힌 성적표 간헐 실패(103), 그 기록으로 찾은 E2E 첫 테스트 간헐 실패(104).
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 76 | 공시가 2025-11 이후 1년치뿐 — 재무는 2021년부터라 2023~2024년 부실 사건(태영건설 워크아웃 등)과 그 전 재무 악화를 같은 기간에서 비교할 수 없음 | 처음 수집하는 기업을 ‘최근 365일’로만 받음 | 처음 수집은 FS_FROM_YEAR 1월 1일부터, disclosureSyncJob 에 from 백필 파라미터(형식 · 범위 검증, 잘못된 날짜는 400). OpenDART 공시검색은 corp_code 를 주면 3개월 제한이 없음(가이드 확인) |
백필 3,514 → 18,781건(2021-01-04~) · 잘못된 from → 400 |
| 77 | 태영건설 워크아웃이 R-C04 경보에서 빠짐 — 2023-12-28 ‘채권은행등의관리절차개시신청’이 OTHER | 기업구조조정촉진법상 워크아웃의 공시 이름이 사전에 없음(‘워크아웃’ · ‘기업구조개선’만) | 사전에 채권은행등의관리절차 추가. 기존 단위 테스트는 테스트 안에서 만든 사전을 써서 사전 파일의 누락을 못 잡았으므로 저장소의 실제 seed 파일로 검증하는 테스트 추가 |
SeedDictionaryTest (실제 공시 제목 20건) |
| 78 | 위 수정 뒤 ‘채권은행등의관리절차해제‘(워크아웃 졸업) · ‘회생절차종결‘이 부실(REHAB)로 분류될 뻔함 | 같은 키워드를 포함 | 중립 키워드에 관리절차해제 · 회생절차종결, 성적표 정의에서도 제외 |
같은 테스트 · 재분류 결과 확인 |
| 79 | 성적표 판정 기준을 고쳐 배포했는데 12개월 결과만 옛 값 | 조회 캐시 키에 코드 버전이 없어, 계산 방식이 바뀐 배포 뒤에도 옛 코드가 만든 결과를 TTL(30분) 동안 줌 | 애플리케이션 기동(ApplicationReadyEvent) 때 캐시 세대를 올림 |
배포 후 재확인 |
| 80 | 좁은 화면에서 카드 제목 옆 선택 버튼이 눌려 글자가 세로로 한 자씩 쌓임 · 그 수정으로 기업 목록이 390 px 에서 55 px 넘침 | 카드 머리가 줄바꿈하지 않음 · 검색창 + 4개 선택지가 한 줄에 안 들어감 | 카드 머리 flex-wrap, 선택 버튼은 줄바꿈 대신 그 안에서 가로 스크롤, 기업 목록 머리 줄바꿈 |
자동 점검에 ‘눌린 컨트롤’ 검사 추가 — 19개 화면 × 2 보기 0건 |
| 81 | PF 지표 단계가 실제 DB 에서 실패(column reference "corp_code" is ambiguous) — 컴파일 · 단위 테스트는 통과 |
CTE 에서 j.* 와 같은 열을 한 번 더 뽑음 |
열 정리 + 실제 Postgres 에서 PF 지표 SQL 까지 돌리는 통합 테스트 | PfExposureJobIT (Zip → 구간 → 지표 → R-C06 → API) |
| 82 | 기업 지표를 다시 계산하지 않은 회사의 PF 지표가 옛 값으로 남을 수 있음 | 기업 지표는 회사 단위로 지우고 쓰는데 PF 지표는 ON CONFLICT DO NOTHING |
PF 지표만 통째로 지우고 같은 트랜잭션에서 다시 씀 | 같은 IT — 자본총계를 바꿔 다시 계산하면 갱신 |
| 83 | 12개월 내 만기 PF 를 적게 셈 — 서희건설 2026.06 49억(원문 249억) 등 30건 | 만기 구간 열을 ‘6개월초과12개월’ 한 표기로만 찾음 — 468개 구간에 ‘6개월초과1년이내’ · ‘6월~12월이내’ · ‘1년~2년’ · ‘1~2년’ 등이 섞임 | 표기 변형을 정규식으로. 12개월 안 세 구간을 모두 알아보지 못하면 적게 보이지 않게 비움(사유 표시) | PfExposureParserTest — 서희 · HL D&I · 계룡 원문 값 |
| 84 | 요약표가 없는 보고서의 ‘12개월 내 만기 / 자기자본’이 0% · OK 로 계산될 뻔함 | 지표 SQL 이 빈 구간을 coalesce(…, 0) |
구간이 모두 비면 없음(MISSING), 하나라도 있으면 ‘-‘ 는 0 — 파서와 같은 규칙 | 단위 테스트. 실데이터에서 구간 합 = 대출잔액인 표 193건으로 ‘-‘ = 0 확인 |
| 85 | PF 신용보강이 없다고 적은 회사(까뮤이앤씨 11건)가 사업장 1곳으로 구조화 | 별도 항목인 ‘책임준공 약정 세부 내역’ 표를 PF 보증 표로 읽음 | 책임준공 약정 표 제외, ‘PF 관련 신용보강한 사항은 없습니다’ → NONE | 실제 보고서 fixture |
| 86 | HS화성 PF 합계가 비어 비율이 없음(10건) | ‘단독사업 외 컨소시엄참여사업은 없습니다‘의 ‘컨소시엄’을 범위 꼬리표로 읽어 단독 표를 컨소시엄 전체로 분류 | 부정 문장 제거 + 표 앞 마지막 꼬리표를 따름 | scopeOf 단위 테스트 |
| 87 | 한신공영 합계가 본문(436,441백만원)보다 작음 | 회사분 표 없이 ‘컨소시엄(전체)’ 표 하나 — 그 표의 ‘보증금액(연결)’이 회사 몫인데 전체분이라 뺌 | 회사분 표가 없고 보증금액 열 이름이 회사 몫을 밝히면 회사분으로 | fixture |
| 88 | 아이에스동서 요약표 6,887억 vs 사업장 2,741억 — 처음엔 ‘원문 자체가 맞지 않음’으로 테스트에 고정했음 | 원문 주석 ‘(주1) 기타사업은 … 자체 사업 … 직접 PF 차주’를 읽지 않아 회사 자신의 차입을 보증에 더함 | 주석이 가리키는 행을 합계에서 뺌 → 2,741억 (본문 · 사업장 합 모두 일치) | 잘못 고정한 테스트를 고침 |
| 89 | 금호건설 PF 보증을 최대 70% 크게 셈(2025.06 5,883억 → 원문 3,463억), 과거 R-C06 경보 제목도 부풀려짐(526% → 366%) | 한 표 안에서 줄마다 ‘전체분 · 당사분’을 나눈 표의 두 줄을 모두 더함 | 줄 단위 범위, 그런 표는 합계 줄 대신 범위별로 더함. 함께 고치다 KCC건설(단독 · 컨소시엄 · 소계가 한 표)이 3.5배로 깨지는 회귀를 전수 비교에서 잡아 ‘…계’ 소계 판정으로 고침 | fixture 2개 · v11 ↔ v12 전수 비교(바뀐 보고서가 금호건설 11건뿐) |
| 90 | 금호건설의 만기 구간(‘3개월이내 · 6개월이내 · 1년이내 …’)을 비워 둠 | 이름만으로는 누적인지 구간인지 알 수 없음 | 모든 줄에서 구간 합 = 대출잔액이고 ‘만기경과’ 금액이 없을 때만 구간으로 인정 | fixture — 7개 분기의 12개월 내 만기 확보 |
| 91 | 요약표 하나만 믿으면 81~90 같은 오류가 드러나지 않음 | 대조가 사업장 합 하나뿐 | 본문 서술 합계(‘…PF와 관련하여 총 N백만원의 신용보강’)를 따로 읽어 두 번째 대조로 저장 · 화면 · 경보 문장에 표시(V18). 어긋난 18건은 원문 확인 결과 대부분 원문 쪽 기준 차이 | 본문 일치 94 → 156 / 174, 사업장 합 164 → 176 / 205 |
| 92 | pf_report.corp_code 에 다른 dart 테이블과 달리 외래 키가 없음 |
V16 에서 빠뜨림 | 적용된 마이그레이션은 고치지 않고 V17 로 추가 | Flyway 기동 · 통합 테스트 |
| 93 | 로그인한 상태로 휴대폰 폭(390 px)에서 모든 화면이 8 px 가로로 넘침 — 알림(ADR-025) 종 아이콘을 머리말에 더한 뒤 | 서비스 이름 · 종 · 테마 · 메뉴 버튼이 한 줄에 안 들어감. 화면 자동 점검이 익명으로만 돌아 로그인해야 보이는 종을 못 봄 | 좁은 화면에서는 서비스 이름이 줄어듦(넓은 화면 그대로) · 머리말 간격 축소 | 로그인 상태 점검 추가 — 10개 화면 × 3폭(1440 · 390 · 360) 0건, 익명 44개 화면 0건 |
| 94 | push 뒤 CI 의 gitleaks(전체 이력) 실패 — 통합 테스트의 알림 암호화 키 더미 NOTIFY_KEY 가 generic-api-key 패턴 |
커밋 전 유출 검사는 .env 값만 대조해, 테스트에 새로 넣은 비밀처럼 생긴 문자열은 못 봄 |
실제 비밀이 아님을 확인하고 그 한 건만 지문(커밋:파일:규칙:줄)으로 .gitleaksignore 에 등록, CI 가 그 파일을 명시적으로 읽게 함(이력 재작성 없음) |
로컬 gitleaks 31커밋 no leaks found · CI |
| 95 | push 뒤 CI 의 Trivy 실패 — web 이미지에 고칠 수 있는 CRITICAL 3건(perl-base · CVE-2026-13221 · CVE-2026-42496 · CVE-2026-8376) | 상위 이미지 node:22-bookworm-slim 이 Debian 보안 수정(deb12u4)을 아직 반영하지 않음 |
실행 단계에서 apt-get upgrade 후 목록 삭제 — 상위 이미지가 따라잡기 전의 틈을 메움 |
다시 빌드한 이미지 perl-base 5.36.0-7+deb12u4 · CI |
| 96 | CodeQL 경고 2건 — 알림함 페이지 OFFSET(page × size) · 암호문 이어 붙이기의 길이 덧셈 |
범위 검증이 컨트롤러에만 있어 서비스 안 곱셈이 넘칠 수 있는 것으로 보임 · 무작위 IV 길이를 산술에 씀 | 서비스에서도 범위를 막고 long 으로 계산(예전 페이지 오버플로 수정과 같은 방식) · 산술 없이 ByteArrayOutputStream 으로 이어 붙임 |
알림 테스트 · CodeQL |
| 97 | Dependabot 경고 3건(sharp · source-map-js high, postcss-selector-parser medium) — 그중 postcss-selector-parser 를 고치는 PR 은 Tailwind 3 → 4 메이저 판이라 빌드 실패 | 취약 패키지가 Tailwind 3 이 끌어오는 빌드 시점 의존성 | sharp · source-map-js PR 은 CI 확인 후 하나씩 병합, postcss-selector-parser 는 Tailwind 3 을 유지한 채 overrides 로 7.1.6 고정 — 생성 CSS 가 바이트 단위로 동일함을 확인(해시 비교). npm audit 의 braces(high, 수정판 없음)는 빌드 시점에 고정 패턴만 처리해 런타임 경로가 없음 — Tailwind 4 이전 때 해소 |
lock 변경 3줄 · npm ci · 빌드 CSS 해시 |
| 98 | 사업장 위치(ADR-026) 표본 확인에서 구간 공사가 한쪽 동으로 해석됨 — ‘수신면 신풍리 ~ 북면 매송리’ → 북면, ‘장사동 ~ 고성군 죽왕면’ → 장사동 | 거래가 없는 쪽 이름이 사전에 없어 빠지고 남은 한쪽만 맞음 | ~ , · 및 이 있고 위치 낱말이 둘 이상이면 고르지 않음(MULTI), 한 읍 · 면 아래 리들의 구간만 그 읍 · 면 전체. ‘하이테크벨리’ · ‘국가’ 를 리 · …가 이름으로 읽던 것도 막음(낱말 경계 · 리 길이 · 숫자+가) |
SiteAddressResolverTest 실제 문장 · 전수 재해석 비교(바뀐 건이 의도한 127 + 5 + 14건뿐) |
| 99 | 위 수정 직후 전수 비교에서 ‘성수동1가 685-260’ 등 6건이 ‘동 거래 없음’으로 바뀐 회귀 | 정규식 선택지 순서 — ‘동’ 선택지가 먼저 맞아 ‘성수동1가’ 가 ‘성수동’ 에서 끊김 | ‘숫자+가’ 선택지를 먼저 시도 | 같은 테스트에 성수동1가 · 2가 고정 |
| 100 | 근거 계보로 R-C04 · R-C05 경보를 따라가니 공시 1만 8,781행 모두 수집 실행 ID 가 비어 있음 — 어느 배치가 받았는지 알 수 없음 (재무제표 · PF · 통계 · 계산 실행은 모두 채워져 있음) | disclosureFetchStep 의 writer 가 upsert(all, null) — 실행 ID 를 넘기지 않음 |
Step 범위 writer 가 stepExecution 의 실행 ID 를 넘김. 이미 쌓인 행은 누가 받았는지 알 수 없어 채우지 않고(추정 금지) 계보 화면에 ‘수집 실행 기록 없음’으로 표시 |
DisclosureSyncJobIT (수정 전 null 로 실패 확인) |
| 101 | 관리자로 로그인한 1280 px 화면에서 주 메뉴가 59 px 잘림 — ‘API’ 메뉴를 더하기 전에도 15 px 잘려 있었음 | 로그인하면 알림 종 · 계정 이름 · 로그아웃이 머리말에 붙음. 로그인 점검(93)은 1440 · 390 · 360 px 만 봐서 놓침 | 로그인한 경우 1280~1439 px 에서 서비스 이름 글자만 시각적으로 숨김(로고 · 화면 낭독기용 이름은 유지). 이후 머리말 로고 아이콘을 없애면서 이름은 항상 보이게 하고, 메뉴 좌우 여백을 10 → 8 px 로 줄여 공간을 확보(1280 px 관리자: 잘림 0) | 화면 점검에 1280 px 와 ‘메뉴 잘림’(scrollWidth > clientWidth) 검사 추가 — 익명 26화면 × 3 · 로그인 12화면 × 4 모두 0건 |
| 102 | 전체 테스트에서 ‘worker 정상 종료 → 다시 요청’ 테스트가 간헐 실패(외부 호출 52회 기대에 64회 · 32회) — 테스트를 더한 뒤 4번 중 2번, 단독 실행은 통과 | Spring Batch 6 의 JobOperator.stop 이 Job · Step 실행에 종료 시각을 적어 곧바로 STOPPED 로 바꿈(6.0.5 공식 소스 확인). Step 스레드는 청크 경계까지 계속 도는데 drain · 완료 대기는 상태만 보고 끝났다고 판단 → 재시작이 이전 실행과 겹쳐 같은 대상을 두 실행이 받음. 로그: 멈춤 36.13 s → drain 반환 36.78 s → 재시작 36.93 s, 이전 Step 은 36.99 s 에 끝남. 운영의 배포 재시작(SIGTERM) 뒤 재요청에서도 같은 경쟁 |
멈춤은 Job 실행을 종료 시각 없이 STOPPING 으로만 표시 — Step 이 다음 청크 커밋 때 읽어 경계에서 멈추고 Job 스레드가 끝날 때 STOPPED. 그동안은 실행 중이라 완료 대기 · 재시작 거절(409)이 맞음 (ADR-012 덧붙임) | AptTradeJobIT 새 테스트(멈춤 직후 STOPPING · 재시작 409 · 끝난 뒤 추가 쓰기 없음 — 수정 전 실패 확인) · 전체 테스트 3회 연속 245/245 |
| 103 | 화면 테스트 ‘경보 검증 · 성적표’가 전체 실행 약 16번 중 2번 실패 — 기록이 다음 실행에 지워져 원인을 못 봄 | ① 테스트: 24개월을 고른 뒤 찾는 ‘경보 뒤 24개월 안에’가 표 설명 줄과 방법 설명 목록 두 곳에 있어, 응답이 빨리 오면 두 곳이 걸려 즉시 실패(strict mode). ② 화면: 기간 이름은 선택값, 숫자는 이전 응답이라 새 기간을 불러오는 동안 12개월 숫자에 ‘24개월’ 이름이 붙음 | 기간 이름을 지금 보이는 데이터의 기간에서 가져오고, 새 기간을 불러오는 동안 흐리게(aria-busy). 테스트는 표 설명 줄만 찾음. make e2e 가 실패 시 트레이스 · 스크린샷 · HTML 보고서를 web/e2e-failures/시각/ 로 옮겨 둠 — 이번 원인도 그 기록으로 찾음 |
E2E 에 성적표 응답을 1.5초 늦추는 단계 추가 — 그동안 ‘12개월’ 이름이 유지되는지(수정 전 실패 확인) · 수정 후 반복 통과 |
| 104 | 백엔드 전체 테스트(make test) 바로 뒤의 E2E 에서 첫 테스트(대시보드)가 가끔 실패 — 화면이 5초 넘게 ‘불러오는 중’ |
실패 기록(103)의 트레이스: 그 순간 /auth/me 1.9초(평소 수 ms), /dashboard 는 테스트가 끝날 때까지 응답 없음. 같은 Docker VM 에서 Testcontainers 를 띄우고 정리한 직후라 로컬 스택 api 가 잠시 느려짐 — 지금 다시 재면 0.12~0.18초. 앱 결함이 아니라 실행 순서의 경합 |
make e2e 가 api 가 1초 안에 답할 때까지 최대 60초 기다린 뒤 시작 |
make test → make e2e 를 연달아 반복해 통과 |
보안 · 데이터 손실 · 구조 · 화면 네 관점으로 전체 코드를 읽고, 찾은 문제를 심각도 순으로 재현 → 수정 → 회귀 테스트로 고정했다. 목록과 우선순위는 개선 계획의 ID(SEC · DL · FE)를 따른다. 구조를 옮기며 드러난 문제(139 등)도 같은 순서로 고쳤고, ‘(독립 검토)’ 항목(152~164)은 마친 뒤 전체 변경을 처음 보는 리뷰어 입장에서 별도 에이전트가 다시 읽어 찾은 것이고, ‘(독립 검토 2차)’(166 · 167)는 그 수정들을 다시 검토해 찾은 것이다.
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 105 | (SEC-1) 익명으로 /companies?sort=flood1…1000 을 부르니 조회 캐시 키 1,000개 · Redis 15.8 MB 가 늘어남 — 같은 Redis(128 MB, allkeys-lru)의 로그인 세션 · 로그인 잠금 카운터가 밀려날 수 있음. 정상 값만 모두 불러도 한 세대에 키 7,081개 · 58 MB 였고, 무효화된 지난 세대 키가 TTL 동안 남아 측정을 되풀이하자 128 MB 상한까지 차 실제로 키가 밀려남 |
캐시 키에 검증하지 않은 입력이 그대로 들어감(경보 목록의 대상 · 심각도 · 상태, 기업 정렬, 쪽 0~100,000 × 크기 1~200, 없는 기간의 지역 목록, 노출 · 주가의 임의 기간 · 기준). 지역 목록은 모든 기간 × 시도를 저장(4,992개 · 26.7 MB). 세대를 올려도 지난 세대 키를 지우지 않음 | 열거 값은 허용 목록(모르면 400), 상태는 순서 · 중복 정규화. 화면이 쓰는 조합만 캐시 — 쪽 크기 50 · 100 의 앞 5쪽, 지역 목록은 최신 기간(지난 기간은 캐시 12 ms · DB 12~24 ms 로 이득이 작음), 노출 6개월 · 1년 · 2년 × 3 · 5 · 10호, 주가 365 · 730 · 1095일. 빈 쪽은 저장하지 않음. 무효화하면 지난 세대 키를 SCAN · UNLINK | CacheKeyIT 6개(수정 전 5개 실패). 실제 스택: 같은 요청 1,000개 → 모두 400, 키 · 메모리 증가 0. 정상 조합 전부(7,218요청)를 불렀을 때 한 세대 31.4 MB(전 58 MB) |
| 106 | (SEC-2) 감사 로그에 웹훅 서명 비밀 · 주소가 평문으로 남음 — 실제 DB 의 웹훅 등록 기록 33건(모두 거부된 400 시도, 화면 테스트가 만든 것) 중 29건에 secret, 33건에 url (V21 을 적용할 때는 화면 테스트로 1건이 늘어 34건). 저장 암호화(AES-GCM)한 값이 감사 로그로 새고, 감사 로그는 추가 전용이라 앱이 지울 수도 없음 |
AuditFilter 가 /auth/ 를 뺀 변경 요청 본문을 그대로 저장. 로그인 비밀번호만 경로로 피했음 |
저장 전에 키 이름으로 가림 — secret · password · token · apikey · authorization · credential 은 ***, *url 은 출처(scheme://host:port)만. query string 도 같은 규칙, JSON 이 아닌 본문은 크기만. 이미 남은 행은 사용자 승인을 받아 마이그레이션 V21 이 두 키의 값만 가림(행은 남김) |
SecurityIT 2개(수정 전 실패) — 새 기록 가림 · V21 이 다른 행은 건드리지 않음. 실제 DB 에서 V21 적용 뒤 평문 0건 |
| 107 | (SEC-5) 같은 계정에 틀린 비밀번호 16개를 동시에 보내니 16개 모두 비밀번호 확인(BCrypt)까지 감 — 상한 5회를 넘겨 맞춰 볼 수 있음. 또 실패 카운터에 만료가 빠지면 그 사용자 · IP 가 영구히 잠김 | 잠금 확인(GET) → BCrypt → 실패 기록(INCR) 사이가 비어 있음. INCR 과 EXPIRE 를 따로 보내 EXPIRE 만 실패하면(타임아웃) 만료 없는 키가 남음 | 비밀번호 확인 전에 시도를 먼저 셈(성공하면 되돌림). 증가 · 만료를 Lua 한 번으로, 만료가 빠진 키는 다시 붙임. 되돌림은 0 보다 클 때만(만료된 키에 DECR 하면 만료 없는 -1) | SecurityIT 2개(수정 전 실패 — 16개 모두 401, TTL -1). 기존 잠금 테스트(5회 뒤 429 · 다른 IP 영향 없음) 그대로 통과 |
| 108 | (SEC-3) DB 소유자 계정(스키마 · 권한 변경 가능)의 비밀번호가 api 뿐 아니라 외부 API 를 부르는 worker 에도 전달됨. .env.example 에 이름도 없음 |
compose 공통 환경(x-app-env)에 Flyway 소유자 계정을 넣어 모든 앱 컨테이너가 받음 |
소유자 계정을 별도 묶음으로 빼 마이그레이션을 하는 api 와 단독 실행 batch 에만. worker 는 api 가 마이그레이션을 끝낸 뒤 뜨므로 Flyway 를 끔. 이름은 .env.example · README 에(값은 사용자 결정에 따라 그대로 — make env 자동 생성 목록에 넣으면 이미 만든 DB 와 어긋나 넣지 않음) |
docker compose config: worker 에 FLYWAY_PASSWORD 없음. 실제 스택에서 worker 재생성 → healthy, 규칙 평가 요청 #36 COMPLETED |
| 109 | (SEC-4) 익명으로 부를 수 있는 /mapping/unmapped 가 미매핑 행마다 후보 계정을 따로 조회 — 실제 데이터 69행이면 70번, 최대 301번. 반복 호출하면 커넥션 풀(12)을 오래 붙잡음 |
후보 조회가 행 단위 반복(N+1) | 행마다의 (보고서, 재무제표, 힌트)를 배열로 넘겨 unnest … CROSS JOIN LATERAL 한 번으로. char(n) 열은 같은 타입으로 cast 해 보고서 인덱스 사용 |
MappingIT(먼저 현재 동작 고정) 통과. 실제 스택: 응답이 바이트 단위로 같음, 후보 조회 69번 → 1번, 응답 125 → 110 ms(5회 중앙값) |
| 110 | (SEC-6) 익명 공개 조회 응답에 사람 · 서버를 알아볼 값 — 경보를 확인한 사람(ackedBy), 규칙을 바꾼 사람(createdBy), 배치를 요청한 사람(requestedBy), worker 이름(pid@컨테이너 ID), 실패 메시지의 스택 트레이스(내부 클래스 · 경로) |
운영 화면(배치 · 규칙)을 포트폴리오로 공개하면서 응답 필드는 관리자 화면 그대로 | 사용자 결정(D2)대로 공개 조회는 유지하고 익명 응답만 가림: 사람 이름은 빼고, worker 는 해시 별칭(worker-3fa2c1 — 같은 worker 는 같은 별칭), 실패 메시지는 첫 줄 200자. 로그인 · 서비스 토큰이면 그대로 |
PublicViewIT(수정 전 실패) · ViewerTest |
| 111 | (SEC-7) /API/v1/dashboard 처럼 대소문자만 바꾼 경로가 200 — edge 의 /api/v1/ 규칙을 비껴 web 으로 갔다가 Next rewrite(대소문자 무시)가 api 로 넘김. api 가 보는 클라이언트 IP 가 web 컨테이너라 IP 레이트리밋 · 로그인 잠금 · 감사 로그의 IP 가 실제 사용자와 달라짐 |
edge 의 경로 규칙이 대소문자를 구분, Next rewrite 는 구분하지 않음 | edge 에서 /api/v1 · /swagger-ui · /v3/api-docs 를 대소문자 무시(~*)로 api 에 보냄 — api(Spring)는 경로를 구분하므로 대문자 경로는 거부(401) |
E2E security.spec 1개(수정 전 200). 실제 스택: 세 경로 401, 정상 경로 200 |
| 112 | (SEC-13) 서비스 토큰(X-Admin-Token, ADMIN 권한) 길이를 검사하지 않음 — ADMIN_TOKEN=abc 처럼 짧게 넣어도 그대로 기동 |
토큰을 비교만 하고 강도를 보지 않음 | 비우지 않았다면 32자 이상이어야 기동(짧으면 분명한 메시지로 실패). make env 는 48자로 생성하므로 기본 사용에는 영향 없음 |
AppPropertiesTest(수정 전 실패). 테스트 토큰도 32자 이상으로 |
| 113 | (SEC-8) 웹훅 SSRF 검사가 IPv4 를 품은 IPv6 주소를 공인 주소로 봄 — ::127.0.0.1 · 64:ff9b::a9fe:a9fe(NAT64 로 169.254.169.254 메타데이터) · 2002:c0a8:0101::1(6to4 로 192.168.1.1) 통과 |
IPv6 는 ULA(fc00::/7)만 검사. 자바는 IPv4 사상(::ffff:)만 Inet4Address 로 바꿔 줌 |
NAT64(64:ff9b::/96) · 6to4(2002::/16) · IPv4 호환(::/96)은 안의 IPv4 로 같은 규칙을 적용, 로컬 NAT64(64:ff9b:1::/48) · Teredo(2001::/32) · 문서용(2001:db8::/32)은 거부. 공인 IPv4 를 품은 NAT64 주소(64:ff9b::808:808)는 허용 | NotifySecurityTest(수정 전 실패). DNS 재바인딩 고정은 보류한 알림 고도화 때 |
| 114 | (SEC-9) 기업 목록 검색어 q=% 가 모든 기업을 돌려줌(_ 도 아무 한 글자) — 검색어 하나로 매번 전체를 훑게 할 수 있고, 길이 제한도 없음 |
목록 쿼리가 검색어를 ILIKE '%' \|\| :q \|\| '%' 에 그대로 넣음(이름 찾기 API 는 이미 이스케이프) |
% _ \ 를 글자 그대로 찾도록 이스케이프(ESCAPE '\'), 검색어 100자 제한(넘으면 400) |
ApiIT 1개(수정 전 2건이 걸림) |
| 115 | (SEC-11) 감사 로그에 변경 요청의 query string 이 길이 제한 없이 저장 — 8,000자 주소가 그대로 남음(본문은 4 KB 로 묶여 있었음) | 본문만 상한을 두고 query string 은 그대로 | 가린 뒤 2 KB 에서 자르고 ‘…’ 표시. 보존 기간(오래된 행 삭제)은 데이터 삭제라 제안만 | SecurityIT 1개(수정 전 실패) |
| 116 | (SEC-10) DART corpCode Zip 을 풀 때 크기 상한이 없음 — 공시 원문(5 MB 상한)과 달리 압축 폭탄이면 메모리 기반 /tmp(tmpfs)가 worker 메모리 한도(1.5 GB)를 채움 |
Files.copy 로 끝까지 풂 |
64 KB 씩 쓰며 256 MB 를 넘으면 멈추고 반쯤 쓴 파일을 지움(지금 목록은 약 11.9만 개 회사 · DB 13 MB) | BoundedCopyTest 2개 |
| 117 | (SEC-12) 저장된 웹훅 암호문이 깨졌을 때(형식 · Base64) 웹훅 시험 API 가 400 ‘알 수 없는 암호문 형식’ — 요청이 잘못된 것처럼 보여 관리자가 요청을 고치려 헤매고, 서버 오류 경보(5xx)에도 잡히지 않음 | 복호화가 서버 상태 문제를 IllegalArgumentException 으로 던지고, 전역 처리기는 이를 입력 오류(400)로 바꿈 |
저장 값 문제는 IllegalStateException(500, 로그). 숫자 · 정규식 같은 입력 파싱 오류의 400 은 그대로 둠(전역 매핑을 바꾸면 입력 오류까지 500 이 됨) |
NotifySecurityTest(수정 전 실패) |
| 118 | (DL-1) 위험이 커진 순간 경보가 ‘해소’로 닫힘 — 00122694 는 2025Q2 부터 자본잠식(부채비율 · PF 비율 NEG_EQUITY)인데 R-C01 · R-C06 이 RESOLVED, 영업손실이 이어지는데 분기 이자비용 0(ZERO_DENOM) 두 분기 뒤 R-C02 6건이 모두 RESOLVED. 화면 · 성적표에는 ‘해소’로 보임 | 기간 규칙이 가장 최근 기간을 기준으로 ‘참이 아니면 해소’ — 값이 없거나 판단할 수 없는 점(MISSING · ZERO_DENOM · NEG_EQUITY)을 ‘거짓’과 구분하지 않음 | 사용자 결정(D1)대로 조건을 세 값(참 · 거짓 · 모름)으로 판정하고, 기준 시점은 판단할 수 있는 가장 최근 기간. 판단할 수 있는 기간이 없으면 열지도 닫지도 않음(지표를 다시 계산하는 사이 통째로 비어도 닫히지 않음 — DL-6 의 증상도 막음). R-C05 는 최신 보증 공시의 잔액 · 자기자본을 모르면 결정하지 않음 | RuleEvaluatorTest · RuleEvalJobIT(수정 전 실패). 실데이터 재평가: 3건 다시 열림(R-C01 2025Q1 · R-C02 2025Q4 · R-C06 2025Q1, 모두 00122694 — REOPENED 알림 3건), 나머지 경보 상태는 그대로 |
| 119 | (DL-2) worker 가 OOM · kill 로 죽어 실행이 STARTED 로 남으면, 그 Job 은 API · 스케줄러 요청이 모두 409 ‘이미 실행 중’ — 관리자가 실행 번호로 직접 정리할 때까지 매일 건너뜀. VERIFICATION 2 의 수정이 큐 경로에서는 닿지 않음 | 하트비트로 죽은 실행을 정리하는 코드가 worker 의 launch() 안에만 있음. 큐에 넣는 단계가 먼저 ‘실행 중’을 검사해 409 → launch 에 도달하지 못함. StaleExecutionIT 는 launch 를 직접 불러 이 경로를 검사하지 않았음 |
큐에 넣기 전에 같은 advisory lock 아래에서 하트비트가 멈춘 실행을 정리(다른 프로세스가 시작 중이면 건너뜀) | JobRequestQueueIT 1개(수정 전 409) — 좀비는 FAILED, 새 요청은 DONE |
| 120 | (DL-3) worker 컨테이너가 재시작되면, 이전 기동이 실행 중이던 요청이 RUNNING 으로 영구히 남고 그 Job 은 다시 요청할 수 없음(409) — 실제 worker ID 가 worker-1@컨테이너ID 로 재시작 전후 같았음(DB 확인) |
worker ID = pid@hostname — 컨테이너 안 pid 는 늘 1. 새 프로세스의 하트비트가 옛 ID 를 ‘살아 있음’으로 만들어, 하트비트가 끊긴 worker 의 요청을 정리하는 기능이 그 요청을 남겨 둠 | ID 에 기동마다 다른 꼬리(#8자리)를 붙이고, 기동할 때 같은 자리(pid@hostname)의 이전 기동이 잡고 있던 요청을 바로 정리(같은 호스트의 다른 프로세스는 그대로) |
JobRequestQueueIT 1개(수정 전 정리 0건) |
| 121 | (DL-4) 일일 호출 한도 근처에서 시작한 수집이 이후 매일 같은 건수만 받고 멈춤 — 남은 호출이 7건인 날 멈춘 실거래 수집을 다음 날 이어도 7건 뒤 STOPPED(남은 호출이 0 이면 영구히 0건) | 계획 단계가 ‘오늘 남은 호출’을 maxCalls 파라미터로 넣었고, Spring Batch restart 는 첫 실행의 파라미터를 그대로 씀. 기존 테스트는 두 번째 실행에 restart:false 를 줘 이 경로를 보지 않았음 |
남은 호출로 상한을 자동으로 넣지 않음(경고만) — 일일 상한은 호출할 때 지키고 넘으면 STOPPED. 사용자가 준 maxCalls 만 파라미터로. 재무제표 · 실거래 · 공시 원문 · PF 4개 Job | AptTradeJobIT 1개(수정 전 다음 날도 STOPPED) — 두 실행을 합쳐 39건을 한 번씩만 받고 COMPLETED |
| 122 | (DL-5) 첫 수집(2026-09-24) 뒤에 제출된 기재정정 반기보고서 3건(2026-09-30~10-07)의 재무제표를 다시 받지 않음 — ADR-003 의 ‘정정은 새 접수번호로 쌓고 최신을 씀’이 동작하지 않음 | 수집 대상 쿼리가 한 번 OK 가 된 (기업, 연도, 보고서)를 영구히 뺌 | 받은 날 뒤에 같은 기간 정기보고서가 다시 제출되면 그 기간만 다시 받음(보고서명의 기간, 12월 결산 기준 — PF 와 같은 한계). 다시 받으면 받은 시각이 늦어져 반복하지 않음 | FinancialStatementJobIT 1개(수정 전 요청 0건). 실데이터: 계획 3건 → DART 가 정정본 접수번호로 응답(3건 모두), 113~167줄 금액은 그대로(문구 정정) — 지표 영향 없음. 첨부정정은 DART 가 원래 접수번호를 줌(첫 수집 기록으로 확인) |
| 123 | (FE-1) 알림 구독 A 를 고치던 중 B 의 ‘바꾸기’를 누르면 폼에 A 의 값이 남고, 저장하면 B 가 A 의 설정으로 덮어써짐 | 고치는 폼이 같은 자리에 그대로 있어 useState(initial) 이 처음 값만 씀 — 대상(initial)만 B 로 바뀜 |
폼에 고치는 구독별 key 를 줘 대상이 바뀌면 새로 만듦 |
E2E notify.spec 1개(수정 전 실패: 이름 칸에 A) |
| 124 | (FE-2) 매핑 화면에서 규칙을 더한 뒤 목록이 바뀌면, 앞 줄에 쓰던 패턴이 다음 줄(다른 기업 · 계정)의 입력 칸에 남아 다른 계정의 매핑 규칙으로 저장 | 줄 key 가 순번(key={i})이라 줄이 빠지면 다음 계정이 같은 컴포넌트 상태(입력값)를 이어받음 |
key 를 (기업, 표준계정)으로 | E2E security.spec 1개(응답을 가로채 목록 변화를 만듦 — 수정 전 ‘나건설’ 줄에 ‘가건설’ 패턴) |
| 125 | web 을 다시 만든 직후 E2E 5개가 한꺼번에 실패 — edge 가 502. 같은 조작을 재 보니 컨테이너가 healthy 가 된 뒤에도 502 가 약 4.5초(0.5초 간격 9번) | 컨테이너가 없는 사이 Docker DNS 가 이름을 호스트 DNS 로 넘겨 web 이 127.0.53.53(ICANN 이름 충돌 주소)으로 풀림(직접 확인). edge(nginx)가 그 사이 이름을 다시 풀면 그 값을 10초 들고 있음. 기본 max_fails=1 도 내려가 있던 동안의 실패로 서버를 잠시 ‘죽음’으로 표시 |
api · web 에 compose 고정 IP(.11 · .12, 자동 배정은 .128 부터)를 주고 edge 는 IP 로 부름(이름 해석 없음), max_fails=0. make e2e 는 화면도 답할 때까지 기다림 |
고친 뒤 ‘다시 만드는 동안 0.25초마다 요청’ 3회: healthy 뒤 실패 0건. 수정 전 설정은 이름 해석 시점에 따라 나므로 같은 측정에서 매번 재현되지는 않음 — 원인은 이름 해석 결과(127.0.53.53)로 확인 |
| 126 | (DL-6) 지표 계산 Job 이 기업 재무 지표를 다시 쓰는 동안(그리고 다음 Step 이 실패 · 멈추면 다음 실행까지) PF 지표가 비어, 화면의 PF 비율과 R-C06 평가가 그 사이 PF 없이 동작 — 그 사이 규칙 평가가 돌면 R-C06 이 ‘해소’됐다가 다음 평가에서 다시 열리며 알림이 나가고 ACK 가 사라짐(118 로 ‘해소’는 막힘) | 기업 지표 Step 의 기업 단위 DELETE 가 PF 지표까지 지우고, PF 는 다음 Step 에서야 다시 넣음. VERIFICATION 82 는 PF Step 안만 원자적으로 고쳤음 | 기업 지표 Step 은 PF 코드를 빼고 지움(PF 는 PF Step 이 한 트랜잭션으로). 체인 다음 Job 을 이어 넣지 못하면 경고 로그 | PfExposureJobIT(수정 전 PF 지표 0개) |
| 127 | (DL-7) 규칙을 끄거나 기업이 유니버스에서 빠지면(상장폐지 등) · 지역이 사라지면 그 열린 경보가 영구히 OPEN — 대시보드 · 알림 수에 계속 잡힘. ADR-007 은 버전이 바뀐 경우만 다룸 | 평가 Step 이 ‘켜진 규칙 × 지금의 대상’만 읽어, 빠진 쪽의 경보는 다시 평가되지도 닫히지도 않음 | 평가 뒤 Step 에서 대상 밖 열린 경보를 RULE_DISABLED · OUT_OF_SCOPE 로 닫음(열거 값 추가 — v1 호환 범위). 대상 목록이 비면 그 종류는 건너뜀 |
RuleEvalJobIT 1개(수정 전 OPEN 유지). 실데이터: 해당 경보 0건 — 지금은 영향 없는 잠재 결함 |
| 128 | (DL-8) 실거래 API 가 중간 쪽을 빈 정상 응답으로 주면 받은 쪽만으로 그 달을 통째 교체 — 그 달은 ‘받음’으로 남아(최근 3개월 밖이면 다시 받지 않음) 영구히 적게 집계되고 거래 급감 경보(R-R03)가 틀릴 수 있음. 다시 받은 최근 달이 0건으로 오면 그대로 0건. 주가도 같은 구조라 쪽이 빠지면 ‘마지막 저장일 다음 날부터’에 공백이 영구히 남음 | 쪽 넘김이 ‘빈 쪽이면 끝’이라 totalCount 를 못 채운 채 끝나도 성공으로 봄. 교체 전에 이전 건수와 맞춰 보지 않음 | totalCount 를 못 채우면 원천 오류로 건너뜀(기존 행 · ‘받음’ 기록 그대로, 다음 실행에서 다시). 받았던 달이 다시 받으니 0건이면(신고 거래는 해제돼도 표시만 바뀜) 원천 이상으로 보고 바꾸지 않음 | AptTradeJobIT 1개(수정 전 39 → 36건) · StockAndInsightIT 1개(수정 전 6건 저장) |
| 129 | (DL-9) 경계 적재 Job 을 출처(source) 없이 실행했는데 그 worker 에 V-World 키가 없으면 SGIS 로 바꾸며 지역 경보(+알림) · 통계 · 지표 · 관리자가 고친 수동 매핑을 지우는 정리를 자동으로 실행. 실제 DB 에서는 계약 981건이 지역을 가리켜 FK 로 실패 → 매번 FAILED, 계약이 없는 DB 에서는 첫 청크와 함께 지워짐 | Reader 를 만들 때 ‘키가 있으면 V-World, 없으면 SGIS’로 정하고 바로 정리 — 처음 적재와 ‘이미 있는 출처를 바꾸기’를 구분하지 않음 | 출처 결정을 순수 함수로: 처음이면 키로 고르고, 이미 있으면 지금 출처를 유지(키가 없으면 지우지 않고 실패), 바꾸기는 source + confirmPurge=true 일 때만. 지우기는 별도 Step 의 한 트랜잭션에서 계약 지역 연결을 끊고 수행 |
BoundaryLoadJobIT 2개(수정 전 SGIS 로 넘어감) · BoundarySourceTest 2개 |
| 130 | (DL-10) 이름 매칭이 출처 코드 X 를 지역 A 에 잘못 붙였다가 관리자가 B 로 고치고 다시 수집해도, A 에 X 의 수치(다른 지역 통계)가 계속 남아 A 의 지표 · R-R* 경보가 틀린 채 유지. 같은 출처의 두 코드가 한 지역에 매핑되면 나중 값이 조용히 덮어씀 | 통계는 (시리즈, 지역, 기간)으로 덮어쓰기만 하고, 매핑을 바꿀 때 옛 지역에 남은 그 코드의 행을 지우지 않음 | 매핑을 바꾸면 같은 트랜잭션에서 옛 지역에 남은 그 출처 · 코드의 통계를 지우고(지운 건수를 응답 · 화면에), 같은 지역에 다른 코드가 이미 매핑돼 있으면 경고 | MappingIT 1개(수정 전 1행 남음) |
| 131 | (DL-11) 웹훅을 다시 보낼 때마다 X-BuildRisk-Delivery 가 새 UUID — 응답 시간 초과(상대는 이미 처리했을 수 있음)로 다시 보낸 것을 받는 쪽이 걸러낼 수 없음(주석의 ‘중복 제거용’과 반대). 한 주기에 웹훅 여럿 · 묶음 여럿을 보내다 2분 임대를 넘기면 다른 worker 가 같은 행을 다시 보내고, 늦게 끝난 쪽이 결과를 덮어씀 |
헤더를 보낼 때마다 만들고, 결과 기록이 임대 소유를 보지 않음, 한 주기 전송량이 임대 시간과 무관 | 전송 행 집합에서 정한 UUID v3, 결과는 status = 'SENDING' AND lease_until = 내 임대인 행에만, 90초 안에 못 보낸 묶음은 시도 횟수를 되돌려 돌려놓음 |
NotificationIT 1개(수정 전 두 요청의 Delivery 가 다름, 늦은 쪽이 결과를 덮어씀) |
| 132 | (DL-12) 원문 표에 COLSPAN="2147483647" 같은 값이 하나 있으면 PF 파서가 격자를 펼치다 OutOfMemoryError(1.5초) — worker 는 -XX:+ExitOnOutOfMemoryError 라 프로세스가 죽고 실행이 좀비로 남음. 공시 원문은 회사가 쓴 HTML 이라 믿을 수 없는 입력 |
병합 칸 수를 상한 없이 그대로 펼침 | HTML 표준 상한(colspan 1000 · rowspan 65534)으로 자름. 예외로 Job 이 실패하는 다른 입력은 시험해 본 범위(빈 표 · 음수 · 큰 숫자 · 깨진 숫자)에서 찾지 못해 넣지 않음 | PfTableGridTest(수정 전 OOM). 보관 구간 468건의 최대 colspan 20 · rowspan 130 — 상한 안이라 전수 재파싱 결과가 같음이 보장됨 |
| 133 | (DL-14) 규칙 평가가 경보를 닫는 트랜잭션과 사람의 ACK 가 겹치면 닫힌 경보가 ‘ACK + 닫힌 사유 · 시각’을 함께 가짐(다음 평가 전까지 화면 · 알림 수가 어긋남) | 닫혔는지 먼저 읽고, 상태 조건 없이 UPDATE — 읽은 뒤 커밋된 닫힘을 덮어씀 | UPDATE 에 status <> 'CLOSED' 조건(행 잠금 뒤 PostgreSQL 이 조건을 다시 봄), 0건이면 400 ‘이미 닫힌 경보’ |
ApiIT 1개(다른 커넥션이 행을 잠근 채 닫고 커밋 — 수정 전 200) |
| 134 | (DL-15) 표준계정 값(dart.fs_std) 13,815행 모두 계산 실행 ID 가 비어 있음 — 근거 계보에서 어느 계산이 만든 값인지 알 수 없음(VERIFICATION 100 과 같은 유형) |
표준화 Step 의 writer 가 replaceStd(…, null) |
Step 이 계산 실행 ID 를 넘김. 이미 쌓인 행은 다음 표준화 때 채워짐(추정으로 채우지 않음) | FinancialStatementJobIT 골든 테스트에 단언(수정 전 360행 비어 있음) |
| 135 | (DL-18) 규칙 정수 파라미터에 consecutive: 4294967298 을 넣으면 범위 검사(1~8)를 통과 — 규칙 이력에는 큰 값, 평가는 2 로 돎 |
BigDecimal.intValue() 가 넘치면 아래 비트만 남김 |
범위를 BigDecimal 로 먼저 보고 intValueExact() |
RuleEvaluatorTest 1개(수정 전 예외 없음) |
| 136 | (DL-13) 실거래를 기본(25개월)보다 길게 받아도(months=37) 그 앞 달이 월 집계 · 지표에 들어가지 않고, 다음 기본 실행이 이미 들어간 집계도 지움 |
받기 Step 은 months 파라미터를 쓰는데 집계 Step 은 기본 기간만 다시 넣으면서 RTMS 시리즈를 통째로 지움 | 집계는 받은 모든 달(신고 기한이 지난 달까지)을 통째로 — 지우는 범위와 넣는 범위를 같게 | AptTradeJobIT 1개(수정 전 37개월 전 달 집계 없음) |
| 137 | (DL-16) 고유번호 Job 이 끝나도 임시 CORPCODE.xml(약 11.9만 개 회사)과 디렉터리가 남음 — worker 의 /tmp 는 메모리(tmpfs)라 매주 쌓여 worker 메모리 한도(1.5 GB)를 먹고, 넘치면 OOM 종료 → 좀비 실행 |
내려받기 Step 이 만든 임시 파일을 아무도 지우지 않음(restart 때 재사용하려고 남긴 경로) | Job 이 COMPLETED 면 Job 리스너가 파일과 디렉터리를 지움. 실패 · 멈춤이면 restart 를 위해 남김 | CorpCodeSyncJobIT(Zip 응답을 흉내 — 수정 전 파일이 남음) |
| 138 | (DL-17) Job 이 끝나는 순간 Redis 가 잠깐 끊기면 세대를 못 올린 채 넘어가 목록 10분 · 성적표 30분 동안 옛 값이 나감. 지역 코드를 수동으로 다시 매핑해도 지역 상세(출처 코드 매핑 · 통계)가 최대 10분 옛 값 | 무효화 실패를 로그만 남기고 다시 하지 않음. 매핑 변경 API 에 무효화가 없음 | 실패하면 표시해 두고 Redis 가 돌아온 뒤 첫 조회 전에 다시 올림. 지역 매핑을 바꾸면 무효화 | JsonCacheTest(Redis 를 흉내 — 수정 전 다시 올리지 않음) · MappingIT 단언(수정 전 세대 그대로) |
| 139 | 기간 규칙의 최신 시점에서 조건이 풀렸는데 과거 시점의 발견이 남아 있으면, 열려 있던 경보가 ‘조건 해소’가 아닌 ‘최신 시점으로 대체’로 닫힘 — 대체한 경보가 없는데도 (실데이터: R-R03 202607 경보, 202608 거래 감소율이 기준 밖) | 열린 경보를 닫는 단계가 ‘최신 시점에 열 경보가 없음’을 구분하지 않고 과거 시점이면 모두 SUPERSEDED 로 적음. 뒤의 저장 단계는 RESOLVED 를 뜻했지만 처음 닫힌 사유를 지키므로 앞 값이 남음 (경보 전이를 순수 규칙으로 옮기다 발견) | 대체는 최신 시점에 열 경보가 있을 때만, 없으면 해소 | AlertLifecycleTest 1개(수정 전 SUPERSEDED). 이미 잘못 닫힌 1건은 기존 데이터 변경이라 그대로 둠 |
| 140 | (FE-3) 조건을 바꾸면(주가 반응 기간 · 지역 지표 · 연결/별도) 새 응답이 오기 전까지 이전 조건의 숫자가 새 조건의 이름표 옆에 보임 | 조회 Hook 이 경로가 바뀌어도 이전 응답을 그대로 돌려줌(늦게 온 이전 응답도 그대로 반영). 성적표만 화면에서 따로 막고 있었음(103) | 응답을 요청한 경로에 묶어 같은 경로일 때만 data 로, 이전 조건 값은 stale 로 따로(성적표처럼 흐리게 남길 곳만). 경로가 바뀌거나 화면을 떠나면 요청 취소(AbortController) | E2E frontend 1개(20거래일 응답을 붙잡아 두고 — 수정 전 60거래일 숫자가 ‘20거래일’ 제목 아래 보임) · 상태 전이 단위 테스트 3개 |
| 141 | (FE-4) 조회가 실패하면 지역 상세 · 지역 패널 · 소개 · 기업 지표/재무/공시 · 배치 이력 · 매핑 규칙이 ‘불러오는 중’에 머물거나 ‘데이터 없음’으로 보임. 재무 · 공시 탭은 불러오는 동안에도 ‘없습니다’. 잘못된 주소(/regions/abcde · /companies/12ab)는 끝없이 불러오는 중 | 화면이 Hook 의 error 를 그리지 않고, 값이 없음(null)과 빈 목록을 구분하지 않음. 경로 파라미터가 형식에 안 맞으면 요청을 안 보내면서 상태도 안 알림 | 오류는 ErrorBox, 값이 오기 전은 불러오는 중, 빈 목록만 ‘없음’. 형식이 틀린 주소는 바로 안내 | E2E frontend 3개(수정 전 7개 화면 모두 오류 없음 · 잘못된 주소 무한 로딩 · 불러오는 중에 ‘공시 없음’) |
| 142 | (FE-10) ① 로그인 화면을 열 때 보낸 로그인 확인(익명)이 늦게 오면 로그인한 뒤 머리말이 비었다가 로그아웃 상태로 덮임 ② 읽지 않은 알림 수를 여러 번 물으면 먼저 보낸 요청의 늦은 응답이 새 숫자를 덮음 ③ 로그아웃 요청이 실패하면 처리되지 않은 Promise 거부, 세션이 남았는데 로그아웃된 것처럼 보임 ④ 변경 요청(경보 확인 · 매핑 · 규칙 · 배치 실행)이 실패한 뒤 다시 해서 성공해도 옛 오류가 남고, 진행 중에 다시 누를 수 있음 | ①② 응답이 어느 요청의 것인지 보지 않음 ③ 로그아웃이 try/finally 로 오류를 다시 던짐 ④ 화면마다 오류 상태를 따로 두며 시작할 때 지우지 않은 곳이 있음 | ①② 판 번호 · 요청 순번으로 마지막 요청의 응답만 반영 ③ 실패하면 서버의 실제 상태를 다시 확인 ④ 변경 요청 Hook(useAction): 시작할 때 오류를 지우고 실패는 오류로 담음, 진행 중 버튼 잠금 | E2E frontend 4개(응답을 붙잡거나 실패시켜 — 수정 전 모두 실패) |
| 143 | (FE-9) 0 으로 반올림된 음수가 ‘-0억’(−300만 원) · ‘-0’(−0.3)으로 보임. 시각이 브라우저 시간대로 보여 해외에서는 실행 · 수집 시각이 9시간 어긋남 | 억 원 · 정수 표기가 음수 0 을 따로 다루지 않음(다른 표기는 이미 다룸). 시각 표기에 시간대를 지정하지 않음 | 음수 0 은 0 으로, 시각은 Asia/Seoul 로 | format.test 2개(수정 전 ‘-0억’ · UTC 기준 시각 — 단위 테스트를 TZ=UTC 로 돌림) |
| 144 | (SEC-14, 방어) 지도 툴팁 HTML 을 이스케이프 없이 조립 — 지표 이름이 없으면 주소의 ?metric= 값을 그대로 넣음. 지금은 api 가 모르는 지표를 400 으로 막아 툴팁이 그려지지 않아 재현되지 않음(CSP script-src 'self' 도 있음) |
카카오 지도 오버레이가 HTML 문자열을 받는데, 지역 이름만 이스케이프하고 지표 이름 · 값은 그대로 이어 붙임(대체 SVG 지도는 ‘<’ 만) | 툴팁 전체를 모든 값을 이스케이프하는 순수 함수(regionTooltipHtml)로 조립, 두 지도가 같은 함수를 씀 | html.test 2개 |
| 145 | (FE-7) 규칙 편집에서 숫자 파라미터를 비우고 저장하면 0 으로 새 버전이 생김(임계값 0 — 경보가 모두 사라지거나 모두 울림) | 폼 값을 Number(v) 로 읽는데 Number("") 는 0. 숫자가 아닌 글자는 그대로 보내 서버 오류로만 알 수 있었음 |
숫자 파라미터의 빈 칸 · 숫자 아닌 값은 보내지 않고 입력 오류로 표시(어느 파라미터인지 이름과 함께) | domain.test 1개(수정 전 0 으로 읽힘) |
| 146 | (FE-8) 실행 중인 Job 이 있으면 배치 화면이 탭을 숨겨도 3초마다 세 API 를 계속 부름(레이트리밋 · 서버 부하), 타이머가 렌더마다 지워지고 다시 만들어짐 | 폴링 효과의 의존성이 렌더마다 새로 만들어지는 조회 객체이고, 탭 표시 상태를 보지 않음 | 주기 호출 Hook(usePolling): 탭이 보일 때만, 다시 보이면 곧바로 한 번, 콜백은 늘 최신 것을 불러 타이머를 다시 만들지 않음. 알림 종도 같은 Hook | E2E frontend 1개(숨긴 7초 동안 — 수정 전 2번 부름) |
| 147 | (FE-5) 배치 실행 이력의 행은 마우스로만 열 수 있고(키보드 · 화면 읽기 프로그램 불가), 규칙 ‘변경 사유’ · 매핑 추가의 방식 선택 · 패턴 입력에 접근 가능한 이름이 없음(자리 표시자만) | 클릭 처리를 표 행(tr)에만 붙임. 자리 표시자는 이름이 아님 | 행마다 실행 번호 버튼(선택 상태 aria-pressed), 입력에 aria-label | E2E frontend 1개(수정 전 버튼 · 이름 없음) |
| 148 | (FE-6) 검색 · 링크로 기업 A → B(지역도 같음)로 옮기면 A 에서 고른 연결/별도 · 공시 필터가 B 에 그대로 남음 | Next 는 경로 값만 바뀌면 같은 페이지 컴포넌트를 재사용해 useState 가 이어짐 | 동적 경로 값으로 페이지 key 를 정해(pageKey) 값이 바뀌면 새로 그림 — 탭 같은 쿼리만 바뀌면 그대로 | E2E frontend 1개(수정 전 B 에서도 ‘별도’) · 단위 테스트 1개 |
| 149 | (FE-11) ① 기업 검색 중에는 쪽이 0 으로 고정돼 ‘다음’이 듣지 않음(100곳이 넘을 때) ② 꺼진 웹훅으로 보내는 구독을 고치면 선택 상자에 그 웹훅이 없어 비어 보이고, 그대로 저장하면 꺼진 웹훅으로 남음 ③ 카카오 지도 SDK 를 못 받으면 지도를 다시 열 때마다 스크립트가 겹쳐 쌓임 ④ 쓰이지 않는 테마 코드(주석은 ‘인라인’이라 했지만 정적 파일을 씀) | ① 검색어가 있으면 쪽을 0 으로 덮어씀 ② 선택지에서 꺼진 웹훅을 모두 뺌 ③ 실패한 script 요소를 남긴 채 새로 넣음 | ① 검색 중에도 주소의 쪽을 쓰고 검색어를 바꾸면 첫 쪽으로 ② 켜진 웹훅 + 지금 쓰는 웹훅(꺼짐 표시) ③ 같은 id 의 스크립트를 지우고 넣고, 실패하면 지움 ④ 지움 | E2E frontend 3개(응답 · SDK 를 흉내 — 수정 전 모두 실패) |
| 150 | (성능) 지역 상세 화면이 모바일에서 크게 출렁임 — CLS 0.643 (375×812 · CPU 4배 감속 · 3회 중앙값, 좋음 기준 0.1) | 지역 카드가 페이지가 이미 받은 통계를 다시 요청하고, 수주 목록도 따로 받아 ‘불러오는 중’ → 긴 내용으로 바뀌며 아래 차트 열을 밀어냄 | 페이지가 통계 · 수주를 함께 받아 카드에 넘기고 한 번에 그림(지도 옆 작은 카드는 그대로 스스로 받음) — 같은 요청 하나도 줄어듦 | 측정 스크립트(PerformanceObserver layout-shift)로 0.643 → 0.000 (지역 3곳) |
| 151 | 탭 주소(/backtest?tab=price)로 바로 열면 성적표 탭이 잠깐 보이고, 무거운 성적표 요청을 보냈다가 취소함(로그인 상태 화면 점검에서 ERR_ABORTED 로 발견) |
정적 화면은 첫 렌더에 쿼리가 비어 있어 기본 탭을 그림 | 주소를 읽기 전에는 탭 내용을 그리지 않음 | E2E frontend 1개(수정 전 성적표 요청 1건) |
| 152 | (독립 검토) 경계 출처 교체 확인(confirmPurge)을 준 실행이 키가 없어 실패한 뒤, 나중에 파라미터 없이 boundaryLoadJob 을 요청하면 그 실행을 이어 해(restart) 확인 없이 지역 경보 · 통계 · 수동 매핑을 지울 수 있음. 반대로 확인 없이 실패한 뒤 안내대로 확인을 붙여 다시 요청해도 옛 파라미터로 이어 해 같은 오류가 반복됨 |
멈춘 · 실패한 실행이 있으면 새 요청의 파라미터를 보지 않고 그 실행의 옛 파라미터로 이어 함 | 요청에 적은 값이 이전 실행과 다르면 새로 시작, 지우기 확인을 담은 실행은 이어 하지 않음(확인은 그 요청에만) | BoundaryLoadJobIT 2개(수정 전 확인이 이어짐 · 새 확인이 무시됨) |
| 153 | (독립 검토) 웹훅으로 한 번 알린 경보가 풀렸다가 다시 열리면 첫 전송과 같은 X-BuildRisk-Delivery 로 보내, 이 헤더로 중복을 거르는 받는 쪽이 재발 알림을 버림 |
다시 열림은 같은 (웹훅, 경보) 전송 행을 다시 쓰는데, Delivery ID 를 전송 행 번호만으로 만들었음(131) | 다시 열 때 전송 행의 시작 시각(created_at)을 새로 적고, Delivery ID 에 (행 번호, 시작 시각)을 넣음 — 같은 주기의 재시도는 같은 값 | NotificationIT 1개(수정 전 두 전송의 ID 가 같음) |
| 154 | (독립 검토) 138 의 수정이 실제 배치 구성에서는 듣지 않음 — Job 이 끝날 때의 캐시 무효화는 worker 에서 일어나는데, 실패 표시를 다시 처리하는 곳이 조회(api)뿐이라 worker 에서 실패하면 아무도 다시 하지 않아 api 가 옛 세대 값을 TTL 동안 냄 | 재시도를 ‘다음 조회’에만 걸었고, worker 는 캐시를 조회하지 않음(테스트는 인스턴스 하나로만 봤음) | 실패하면 그 프로세스 안에서 뒤에서 다시 올림(간격 두 배씩 30초까지, 가장 긴 TTL 30분이 지나면 멈춤). 조회 전 재시도는 그대로 | JsonCacheTest 1개(조회 없이 — 수정 전 다시 올리지 않음) |
| 155 | (독립 검토) 132 뒤에도 PF 파서가 메모리를 다 쓸 수 있음 — colspan=1000 칸 10만 개(약 2.2 MB, 원문 상한 안)면 한 줄 1억 칸, 큰 rowspan 에 빈 줄을 이어 붙여도 줄 수 × 너비로 같은 일 | 칸 하나의 병합 수만 자르고 줄 너비 · 표 전체 칸 수는 제한하지 않음 | 한 줄 2,000열 · 표 하나 20만 칸을 넘으면 펼치지 않고 그 표를 건너뜀(PF 표가 아님) | PfTableGridTest 1개(수정 전 300만 칸 줄을 펼침). 보관 구간 468건의 표는 최대 20열 · 약 1,410칸이라 결과가 바뀌지 않음(재파싱 불필요) |
| 156 | (독립 검토) 판단할 수 없는 대상(자본잠식처럼 창 전체가 판단 불가)은 규칙 버전이 바뀌어도 옛 버전 경보가 계속 열려 있음 | 판단 불가면 아무것도 하지 않는 조기 반환(118)이 버전 교체 처리보다 앞에 있었음 | 판단 불가여도 버전이 다른 열린 경보는 RULE_CHANGED 로 닫고, 같은 버전 경보는 ‘모름’이므로 그대로 — 전이 규칙(AlertLifecycle) 안에서 | AlertLifecycleTest 1개(수정 전 같은 버전 경보까지 RESOLVED 로 계획 — 서비스의 조기 반환이 가리고 있었음) |
| 157 | (독립 검토) 재무제표를 받은 날과 같은 날 제출된 정정 정기보고서는 다시 받지 않아 정정 전 숫자가 계속 쓰임 | 정정 여부를 ‘제출일 > 받은 날’로만 판단 — 공시 목록에는 날짜만 있어 새벽에 받고 오후에 낸 정정이 영원히 ‘뒤’가 아님 | 같은 날 제출분도 보되 받은 접수번호보다 새 접수번호만(접수번호는 날짜+일련번호라 순서가 맞음). 다시 받아도 원래 번호로 응답하면(첨부정정 · 반영 지연) 그날 실행마다 · 다음 날 한 번 더 받고 멈춤 | FinancialStatementJobIT 1개(수정 전 0회 요청, 수정 후 1회 · 같은 날 다시 실행하면 0회) |
| 158 | (독립 검토) 웹훅 주소에 user:pass@ 를 넣으면 감사 로그에 그 비밀번호가 평문으로 남음(주소 가림 106 의 빈틈) |
출처를 ‘:// 뒤 첫 / 전까지’로 잘라 사용자 정보까지 출처로 봄. 역슬래시로 시작하는 경로도 출처에 붙음 |
권한부에서 마지막 @ 앞(사용자 정보)을 버리고, 역슬래시 · 공백도 경로의 시작으로 봄. 저장된 감사 로그 1,059건은 읽기 전용으로 대조해 해당 0건 — 데이터는 고치지 않음 |
AuditFilterTest 1개(수정 전 https://hook-user:…@hooks.example.com:8443/***) |
| 159 | (독립 검토) /regions/{없는 코드}/contracts 가 200 빈 목록 — 다섯 자리 아무 코드로 부를 때마다 10분짜리 캐시 키가 생김(캐시 부풀리기 105 와 같은 길) |
같은 지역 하위 자원 중 계약 목록만 지역이 있는지 확인하지 않음 | series · sites 처럼 ref.region 에 없으면 404 REGION_NOT_FOUND — 로더가 예외를 던지면 캐시에 넣지 않음 |
ApiIT 1개(세 하위 자원 모두 404, 수정 전 계약 목록 200) |
| 160 | (독립 검토) 로그인 뒤 돌아갈 주소 safeNext('/.//evil.example/x') 가 //evil.example/x(프로토콜 상대 주소)를 돌려줌 — ‘같은 출처의 경로만’이라는 약속 위반. 지금 화면은 Next 라우터가 반복 슬래시를 정리해 같은 출처의 404 로 가는 것을 내장 브라우저로 확인(실제 다른 출처 이동은 없음), location 에 직접 쓰는 곳이 생기면 열린 리다이렉트 |
출처만 비교하고 정규화된 경로 자체가 // 로 시작하는지는 보지 않음 |
정규화한 경로가 // · /\ 로 시작하면 / |
단위 테스트 1개(/.// · /%2e// · /a/..// · /./\\, 수정 전 //evil.example/x) |
| 161 | (독립 검토) 다른 탭에서 테마를 고르면 이 탭은 선택 버튼만 바뀌고 화면 색은 그대로 | 다른 탭의 저장(storage 이벤트)을 구독해 선택 상태만 다시 읽고, 화면 색을 정하는 data-theme 은 고른 탭에서만 바꿈 |
storage 이벤트에서 저장된 테마로 data-theme 도 맞춤(테마 키 · 저장소 비움만) |
E2E 1개(두 탭, 수정 전 data-theme 빈 값) |
| 162 | (독립 검토) 지역 지도에서 지표를 바꾸면 새 값이 오기 전까지 툴팁에 새 지표 이름 · 단위와 옛 지표의 숫자가 함께 보임(재현: 미분양 주택: 0 호 — 0 은 ‘천 가구당 미분양’ 값). 140 과 같은 종류 |
지도는 폴리곤을 다시 만들지 않으려 값(props)을 폴리곤에 들고 있는데, 새 값을 기다리는 동안(geo 없음)에도 그 옛 값으로 툴팁을 그림 | 값이 지금 geo 의 것일 때만 툴팁을 띄우고, geo 가 비면 떠 있던 툴팁도 닫음 | E2E 1개 — 카카오 SDK 대역(외부 망 · 키 없이 폴리곤 이벤트 · 툴팁 상태를 기록)으로 새 목록 응답을 붙잡아 재현, 5회 반복 통과 |
| 163 | (독립 검토) 141 이 다루지 않은 목록 세 화면: 조회가 실패하면 기업 목록은 오류 아래에 ‘대상 기업이 없습니다(배치를 실행하세요)’를, 경보 · 노출 화면은 끝나지 않는 ‘불러오는 중’을 함께 보임 | 141 에서 상세 화면만 오류 → 없음 → 불러오는 중 → 내용 순서로 고치고 목록 화면은 data 유무만 봄 |
같은 순서로 — 실패하면 오류만(기업 목록은 주소가 정해지기 전의 ‘없음’ 깜박임도 사라짐) | E2E 1개(세 화면, 수정 전 세 화면 모두 실패를 따로 확인) |
| 164 | (독립 검토) 끝 슬래시가 없는 /api/v1 · /API/v1 은 edge 가 web 으로 보내고 Next rewrite 가 api 로 넘겨, api 가 보는 클라이언트 IP 가 web 컨테이너가 됨(111 과 같은 길 — 응답은 404 뿐이라 영향은 레이트리밋 버킷 · 감사 IP) |
edge 의 api 위치를 ^/api/v1/ 로 잡아 경로 끝을 빠뜨림 |
^/api/v1(/\|$) — 끝 슬래시 없는 경로도 api 로 |
E2E 기존 edge 시험에 2개 경로 추가(수정 전 /API/v1 이 web 을 거쳐 404, 수정 후 api 가 받아 401) |
| 165 | 기업 목록에서 맞는 이름이 없는 검색어를 넣으면 ‘대상 기업이 없습니다 — 배치 모니터에서 corpCodeSyncJob 을 실행하세요’가 보임(데이터가 비었다는 잘못된 안내) | ‘없음’ 안내가 하나라 검색 결과 없음과 유니버스가 빈 경우를 구분하지 않음 | 검색어가 있으면 ‘‘검색어’에 맞는 기업이 없습니다.’ | E2E 1개(수정 전 안내 문구 없음 · 배치 안내가 보임) |
| 166 | (독립 검토 2차) 158 뒤에도 비밀번호에 / · ? · # · \ · 공백이 있으면(https://user:pa/ss@hooks…) 감사 로그에 https://user:pa/*** 처럼 앞부분이 남음 — 사용자 정보가 든 주소는 웹훅 등록이 400 으로 거부하지만 거부된 요청의 본문도 감사함 |
권한부를 첫 구분 문자에서 자른 뒤 그 안에서만 @ 를 찾음 |
권한부 밖에도 @ 가 있으면 사용자 정보의 끝을 가릴 수 없으므로 출처도 남기지 않음(경로 · 쿼리의 @ 도 같은 처리 — 출처가 빠질 뿐 값은 남지 않음). 저장된 주소 69건은 읽기 전용으로 대조해 모두 scheme://호스트[:포트]/*** — 데이터는 고치지 않음 |
AuditFilterTest 1개(구분 문자 5종 · 경로의 @, 수정 전 https://user:pa/***) |
| 167 | (독립 검토 2차) 163 이 만든 회귀: 경보를 확인(ACK)한 뒤 목록을 다시 부르다 실패하면(429 · 500 · 네트워크) 보이던 74행이 사라지고 쪽 넘김만 남음 | 같은 조건을 다시 부르다 실패하면 조회 Hook 은 일부러 이전 값을 남기는데, 163 의 조건이 오류만 보고 목록을 숨김 | 값이 없을 때만 오류로 숨김(!data ? (error ? null : 불러오는 중) : … — 매핑 화면과 같은 순서). 같은 모양을 쓴 기업 목록 · 노출 화면도 맞춤 |
E2E 1개(역할 · 확인 요청을 대역으로, 두 번째 목록 조회만 실패 — 수정 전 74 → 0행) |
| 168 | (push 뒤 CodeQL) java/log-injection — 멈춘 실행 정리 로그에 요청 경로의 Job 이름(/batch/jobs/{jobName}/launch)이 그대로 들어감(119 에서 정리를 큐에 넣기 전으로 옮기며 API 요청이 이 로그까지 닿게 됨) |
로그에 요청값을 씀. 실제로는 앞의 plan() 이 등록된 Job 이름이 아니면 400 으로 막아 위조할 수 없지만 분석기는 이 검사를 따라가지 못함 |
로그에는 저장된 실행의 Job 이름을 씀 — 동작은 같음 | CodeQL 경고 1건(main 2041b6e) — 수정 뒤 다음 분석에서 닫히는지 확인 |
RDS 처럼 소유자가 superuser 가 아닌 DB(CREATEROLE 만, PostGIS 는 마스터가 설치)에 붙여 api 를 두 번 이상 다시 기동했다. 배포 구성은 ADR-031.
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 169 | RDS(소유자가 superuser 아님)에서 api 가 첫 기동은 되고 두 번째 기동부터 뜨지 않음 — Flyway afterMigrate 가 permission denied to alter role — Only roles with the SUPERUSER attribute may change the SUPERUSER attribute |
런타임 계정이 이미 있으면 기동마다 ALTER ROLE … NOSUPERUSER NOCREATEDB NOCREATEROLE NOREPLICATION 을 실행. PG16+ 는 superuser 가 아니면 이 속성을 값과 상관없이 적는 것만으로 거절(REPLICATION · CREATEDB 도 각각 그 속성이 있어야 함). 로컬 · 테스트는 소유자가 컨테이너 superuser 라 드러나지 않음 |
이미 있으면 LOGIN PASSWORD 만 맞추고, 속성이 올라가 있을 때만 NO… 로 내림(superuser 가 아니면 그 문장에서 실패해 기동이 멈춤 — 최소 권한이 깨진 채 뜨지 않게) |
NonSuperuserOwnerMigrationIT 1개(CREATEROLE 소유자로 Flyway 두 번 — 수정 전 두 번째에서 위 오류). 리허설: 같은 구성에서 api 재기동 2회 모두 healthy, 런타임 계정 속성 모두 false |
| 170 | 저장소를 비공개로 돌린 뒤 push 마다 codeql 워크플로가 실패(빨간 CI) — 분석은 끝나고 결과 업로드에서 Resource not accessible by integration |
비공개 저장소의 code scanning 은 GitHub Code Security 라이선스가 있어야 하는데 개인 계정은 살 수 없음(Team · Enterprise 전용). 업로드만 끄는 우회는 CodeQL 라이선스(오픈소스 코드베이스 · 학술 연구만, 그 밖의 CI 분석은 유료 Advanced Security)에 어긋남 | codeql.yml 을 지움. 남는 자동 검사는 gitleaks · Trivy · Dependabot — ADR-013 덧붙임 |
push 뒤 CI 전부 통과 확인(아래 커밋의 Actions). 다시 공개하면 git 이력에서 워크플로를 되살림 |
| 171 | AWS 공개 주소에서 머리말에 [로그인] 링크, 관리 화면에 ‘관리자 로그인 후 변경할 수 있습니다 · 로그인’이 그대로 보임 — 누르면 로그인 화면까지는 가지만 앞단 프록시가 GET · HEAD 가 아닌 /api/ 요청을 막아 로그인 POST 는 403 |
공개 데모의 쓰기 차단(ADR-031 결정 4)은 앞단에만 있고, 화면은 그 사실을 몰라 로컬과 같은 안내를 그림 | web 런타임 환경변수 BUILDRISK_PUBLIC_DEMO(정확히 "true" 일 때만)를 compose.aws.yml 에서만 켜고 /api/config 의 demo 로 내려 줌 → 데모면 익명에게 로그인 링크를 그리지 않고 역할 안내는 ‘데모에서는 조회만 가능합니다’(머리말 · 역할 안내 · 알림 화면). 설정은 페이지당 한 번만 받아 지도 SDK 키와 나눠 씀. /login 은 남김(SSM 으로 붙는 관리자용) — ADR-031 덧붙임 |
웹 단위 5개(환경변수 · 응답 해석 · 링크 · 안내 문구 · 설정 한 번만 받기) · E2E 1개(설정 응답을 데모로 바꿔 규칙 · 알림 화면 — 수정 전 코드에서 실패 확인). next dev 로 확인: 수정 전에는 BUILDRISK_PUBLIC_DEMO=true 여도 로그인 링크가 규칙 · 알림 · 대시보드에 2 · 2 · 1개 → 수정 후 0, 변수가 없으면 수정 전과 같은 2 · 2 · 1개. AWS 반영은 배포 뒤 확인 |
개선 계획의 ID(SEC · DL · ARC · PERF · FE · DEP)를 따른다. 버그는 실패하는 테스트로 먼저 재현했고(수정 전 실패 확인),
성능은 같은 날 같은 조건(다른 프로젝트 컨테이너 11개가 함께 돎)에서 전후를 쟀다. 브랜치 improve/review-2026-10-11.
| # | 증상 | 원인 | 해결 | 고정 · 확인 |
|---|---|---|---|---|
| 172 | (SEC-15) 경로를 퍼센트 인코딩하면(/%61pi/v1/meta) 200 이 나오는데 레이트리밋 · CSV 내려받기 한도 · 감사 로그 · API CSP 가 모두 빠짐 — 관리자 세션 · 서비스 토큰의 변경이 감사에 남지 않을 수 있었음 |
인가 · MVC 는 디코딩한 경로(PathPattern)로 /api/** 를 맞추는데, 다섯 곳(레이트리밋 2 · 감사 · CSP · 로그인 CSRF 예외)은 원래 문자열 getRequestURI() 의 startsWith 로 비교 |
ApiPaths — 인가와 같은 PathPatternRequestMatcher 로 다섯 곳의 판단을 하나로 |
SecurityIT 2건(인코딩 경로의 한도 · 감사 · CSP · 로그인 CSRF) — 수정 전 실패 |
| 173 | (DEP-1) Tomcat 11.0.25 — 11.0.26 에서 고친 Important 4건(요청 헤더 뒤섞임 회귀 CVE-2026-86350 은 11.0.22~25) | Boot 4.1.1 BOM 이 아직 11.0.25 | 해석 규칙으로 11.0.26 | DependencyFloorTest — 버전이 내려가면 실패(수정 전 11.0.25.0 으로 실패) |
| 174 | (DEP-2) Jackson 2.22.1 · 3.1.5 — High(CVSS 7.5) DoS(GHSA-7hhh-6rmp-j9qf 등) | Boot BOM | Jackson BOM 2.22.3 · 3.1.7 을 겹쳐 올림 | DependencyFloorTest(수정 전 실패). Jackson 의 Version.compareTo 는 groupId 부터 비교해 첫 검사가 잘못 통과 — 숫자 비교로 |
| 175 | (SEC-16) 대상이 아닌 기업 코드마다 기업별 조회 5종의 캐시 키가 10분씩 쌓임 — 세션 · 로그인 잠금과 같은 128 MB Redis(105 · 159 와 같은 길) | ref.company 에는 DART 의 모든 회사(119,447곳)가 있는데 존재만 보고 저장 |
대상 기업일 때만 저장(storeIf, 판단은 계산 뒤에만 — 적중 경로에 쿼리 없음) |
CacheKeyIT. 실측(비대상 200곳 × 5조회): 키 +847 · +849 KB → 키 +1(레이트리밋 카운터) · +0 KB |
| 176 | (SEC-17) IPv6 는 주소 전체(/128)로 레이트리밋 · 로그인 잠금을 셈 — /64 하나로 주소를 바꿔 가며 한도를 비껴 가고 주소마다 키를 만듦(CloudFront 는 IPv6 기본 켬) | 키가 getRemoteAddr() 그대로 |
ClientKey — IPv4 그대로, IPv6 는 /64, 표기 정규화 · IPv4 를 품은 IPv6 는 IPv4 로 |
SecurityIT(같은 /64 의 4개 주소 → 4번째 429, 로그인 잠금) · ClientKeyTest |
| 177 | (SEC-18) 실행 요청 목록의 결과 메시지가 worker 예외 원문 그대로 저장 · 공개 — 스택(내부 경로 · DB 호스트), 외부 API 주소의 인증키가 들어갈 수 있음 | 110 은 exitMessage 만 첫 줄로 줄였고 요청 message 는 빠짐, 저장할 때 KeyMasker 없음 |
저장할 때 키 가림, 익명은 첫 줄만 | JobRequestQueueIT(crtfc_key 가림) · PublicViewIT — 수정 전 실패 |
| 178 | (SEC-19) afterMigrate.sql 의 DO 블록에 런타임 DB 비밀번호가 평문 — 블록이 실패하면(의도된 기동 중단 포함) PostgreSQL 이 문장 전체를 서버 로그에 남김(RDS 로그는 세 프로젝트 공용) |
Flyway placeholder 를 그대로 SQL 에 넣음 | 앱이 계산한 SCRAM-SHA-256 검증값을 넘김(ScramVerifier · AppRolePasswordCustomizer) — PostgreSQL 은 그대로 저장 |
LeastPrivilegeIT(placeholder · pg_authid 가 검증값, 평문으로 로그인 — 수정 전 실패) · NonSuperuserOwnerMigrationIT(비-superuser 소유자) · 로컬 PG16 스택 |
| 179 | (SEC-20, 검토 뒤 되돌림) AWS 에서 make env 로 .env 를 만들면 COOKIE_SECURE=false 가 들어가 compose.aws.yml 의 :-true 기본값이 쓰이지 않음 |
— | "true" 고정(c39725f)을 되돌림(40cfd51): AWS 의 로그인은 공개 주소(앞단이 POST 차단)가 아니라 SSM 포트 포워딩의 http://localhost:3400 에서만 일어나, Secure 를 고정하면 http://localhost 의 Secure 쿠키를 받지 않는 브라우저에서 관리자 로그인이 깨질 수 있음 |
공개 로그인을 열 때 함께 바꿀 항목으로 ADR-031 덧붙임 |
| 180 | (DEP-3) GitHub 의존성 그래프에 maven 이 0개(npm 507) — JVM 의존성에 Dependabot 경고가 오지 않았고, Trivy 게이트는 CRITICAL 만이라 173 · 174 가 경고 없이 남음 | Gradle 은 GitHub 가 정적으로 읽지 못함(제출 필요) | main push 에서 gradle/actions/dependency-submission(그 잡만 contents: write), 기본 권한 읽기 전용, gitleaks · 서드파티 Action 고정, Dependabot 에 docker-compose |
push 뒤 CI · 의존성 그래프에서 확인할 일(남은 확인) |
| 181 | (DEP-4) 테스트는 PG 16.11 · GEOS 3.11, 운영은 PG 18.6 — ST_CoverageSimplify(GEOS 3.12+) 경로는 CI 에서 한 번도 돌지 않았고 -Dtest.pg.image 는 테스트 JVM 에 전달되지 않음 |
로컬 이미지를 그대로 씀, Gradle tasks.test 가 속성을 넘기지 않음 |
테스트 기본 이미지를 imresamu/postgis:18-3.6-alpine(PG 18.6 · GEOS 3.14, arm64 · amd64, 2026-10 갱신), 속성 전달 |
313/313 통과(PG18). 로컬 개발 DB(16)는 그대로 |
| 182 | (DL-20) Job 이 도는 동안 DB 가 끊겨 결과 기록이 연달아 실패하면 요청이 RUNNING 으로 남아 그 Job 을 다시 요청할 수 없음(409, worker 재시작 전까지) | 기록 실패가 실행 실패로 잡혀 FAILED 로 다시 쓰려다 그것도 실패하면 예외가 executor 안에 갇힘(로그 없음), 고아 정리는 하트비트가 끊긴 worker 만 봄 | 결과를 먼저 정하고 기록은 재시도(약 1분), 상태 조회의 일시 오류는 끝난 것으로 보지 않음, 정리 주기에 ‘이 worker 것인데 들고 있지 않은 RUNNING 요청’을 실행 결과로 마무리(RUNNING 일 때만), executor 예외 로그. 함께: 요청 행 매퍼가 실행 번호 NULL 을 0 으로 읽던 문제(wasNull 이 앞 열을 봄) |
JobRequestRecoveryIT(기록 2번 실패 주입 — 수정 전 RUNNING) |
| 183 | (DL-21) corpCodeSyncJob 이 적재 Step 에서 실패한 뒤 컨테이너가 바뀌면(AWS 는 매일 정지) restart 가 매번 NoSuchFileException — 고유번호 동기화가 멈춤 |
tmpfs 의 CORPCODE.xml 이 사라졌는데 완료된 다운로드 Step 은 다시 돌지 않음 | 다운로드 Step 을 restart 때도 돌려 파일이 있으면 재사용 · 없으면 다시 받음, 다시 받은 파일은 순서가 다를 수 있어 읽은 위치를 저장하지 않음(멱등 upsert, 약 5초) | CorpCodeSyncJobIT(실패 → 파일 삭제 → restart) — 수정 전 실패 |
| 184 | (DL-22) 배치 화면의 ‘멈춘 실행 정리’가 하트비트가 살아 있는 실행도 FAILED 로 만듦 → ‘이어서 실행’이 같은 JobInstance 를 두 스레드에서 돌림(102 와 같은 위험) | 버튼은 실행 중이면 늘 활성, recoverExecution 이 stale 여부를 보지 않음 |
결정 D1: 살아 있는 실행은 409(멈춘 뒤 정리 가능 안내), 화면은 하트비트가 멈춘 실행에만 버튼. 관리자 API 의 의미 변경(ADR-027 덧붙임) | StaleExecutionIT — 수정 전 실패 |
| 185 | (DL-23) seed 와 같은 계정 매핑 규칙을 화면에서 추가하면 seed 행의 우선순위가 바뀌고 201 로 그 ID 를 돌려줌(지우면 400, 다음 기동에 되돌아감), sj_div 가 빈 같은 규칙은 중복으로 쌓임 |
ON CONFLICT … DO UPDATE · 유니크 제약이 NULL 을 서로 다르게 봄 |
트랜잭션 잠금 안에서 같은 키(NULL 포함)를 찾아 seed 면 400, USER 면 우선순위만. 스키마는 그대로(운영 DB 에 중복이 있으면 제약 추가가 기동을 막을 수 있어서 — 로컬 중복 0건) | MappingIT — 수정 전 실패 |
| 186 | (DL-24) 규칙 · 계정 매핑 규칙을 바꿔도 조회 캐시를 비우지 않아 성적표 · 대시보드 요약이 최대 30분 옛 규칙으로, 근거 계보 reproduced 가 10분 옛 값 | 무효화가 컨트롤러 몇 곳에 관례로만 있었음 | 커밋 뒤 무효화(ACK · 지역 매핑과 같은 방식) | CacheKeyIT(세대가 올라감) — 수정 전 실패 |
| 187 | (DL-25) 같은 기업 화면에서 위험 지역 수주 비중이 경보 R-X01 51.58% · 카드 42.75% | 카드는 화면이 직접 계산(기간 없음 · 기준 5 고정), 노출 API 기본값도 365 · 5 고정 | 결정 D2: R-X01 의 계산을 domain 순수 함수(RiskRegionShare)로 꺼내 기업 수주 응답의 riskShare(필드 추가)가 같은 함수 · 같은 데이터 · 최신 파라미터로 계산, 노출 API 기본값도 R-X01 파라미터 |
RiskRegionShareIT(기간 밖 계약 · 규칙 변경) · RiskRegionShareTest · 웹 단위. 실데이터 00150828: 카드 51.5799% = 경보 51.58% = 노출 51.58 |
| 188 | (DL-26) api 와 단독 batch 가 동시에 기동하면 afterMigrate 가 tuple concurrently updated 로 한쪽 기동을 막음(PG18 실험 120회 중 50회) |
Flyway 는 콜백을 마이그레이션 잠금 밖에서 돌림 | DO 블록 첫머리에 트랜잭션 범위 advisory lock | NonSuperuserOwnerMigrationIT(3개 동시 × 8회 — 수정 전 1회차 실패) |
| 189 | (DL-27) 자본총계가 정확히 0 이면 PF 지표는 NEG_EQUITY, 같은 분기 재무 지표는 ZERO_DENOM — 화면 · CSV · 근거 계보의 상태만 다름(경보는 둘 다 판단 불가) | PF 지표 SQL 이 분모 0 이하를 모두 자본잠식으로 | Java 계산과 같은 분류 | PfExposureJobIT — 수정 전 실패 |
| 190 | 비상장 기업(종목코드 없음)의 주가 조회가 빈 결과 대신 404 ‘기업을 찾을 수 없습니다’(175 측정 중 발견, 대상 기업 43곳은 모두 상장이라 화면에는 안 드러남) | query(String.class).optional() 은 값이 NULL 인 행도 ‘없음’으로 봄 |
행 없음(404)과 종목코드 없음(빈 결과)을 구별 | CacheKeyIT — 수정 전 실패. 같은 모양의 다른 곳(NOT NULL 열)은 문제 없음 |
| 191 | (ARC-1) ‘쓰기 SQL 은 기능 data 에만’(ADR-029) 검사가 기능 패키지만 봐서 seed 적재(adapters)가 기능 테이블 5개에 직접 씀 — 185 의 원인(같은 테이블의 쓰기가 두 곳) | 검사 범위 | 바깥 계층(adapters · batch · common)은 ops.* 에만 쓰는 검사를 더하고, SeedLoader 를 ops.app 으로 옮겨 각 기능 저장소를 부르게 함(동작 같음) |
ArchitectureTest(수정 전 7곳 검출). 로컬 재기동 전후 seed 테이블 5개 지문(md5) · 기동 로그 동일 |
| 192 | (ARC-2) 익명 배치 응답의 가림이 ‘가릴 키’ 목록(fail-open)이라 새 열을 빠뜨리면 그대로 노출(177 의 원인) | SQL 별칭 문자열로 가릴 키를 적음 | ‘보일 키’ 허용 목록(fail-closed). record 로 바꾸는 안은 시각 표기(밀리초 → 마이크로초)가 바뀌어 동작을 같게 둘 수 없어 택하지 않음 | PublicViewIT(익명 = 관리자 키 − 요청자) · BatchAnonymizeTest |
| 193 | (PERF-1) 배치 Job 목록이 Job 14개마다 Spring Batch 저장소를 따로 조회 — 호출당 테이블 스캔 약 220회, 익명 · 3초 폴링(AWS 풀 4) | Job 마다 마지막 인스턴스 · 실행 · Step · 실행 중 여부 · 대기 요청 | Spring Batch 와 같은 정의를 한 문장으로 | BatchJobsViewIT(값을 먼저 고정). 실측 100회 × 2: 중앙값 41.7/36.1 → 7.6/7.7 ms, p95 95.6/80.0 → 13.9/16.7 ms, 스캔 223/212 → 2.9/3.0 |
| 194 | (PERF-2) 캐시 무효화 직후 같은 무거운 조회(성적표)를 동시 요청이 각자 다시 계산 — 연결 풀(AWS 4)을 나눠 먹음 | 프로세스 안에서 같은 키를 동시에 계산 | JsonCache 가 계산 중인 결과를 기다려 받음(실패도 같은 예외, 남지 않음), 지표 result=shared |
JsonCacheTest(8개 동시 → 계산 1번 — 수정 전 8번). 실측(풀 4, 동시 20 × 5회, 웜업된 2~5회차): 계산 20 → 1, 중앙값 650~1,042 → 165~216 ms, p95 732~1,125 → 194~236 ms |
| 195 | (FE-12) 오류 본문에 message 가 없으면 빈 메시지 — 공개 데모 /login 실패(앞단 403 {"error":…})가 아무 안내 없이 비밀번호 칸만 비워지고, 502 · 504 는 ‘ERROR’ 만 |
client 가 res.statusText 로 대체하는데 HTTP/2(CloudFront)는 빈 문자열 |
메시지 순서 message → error → 상태 문구 → HTTP 상태, 로그인 실패 기본 문구, 데모면 ‘공개 주소는 조회만’ 안내 | 웹 단위(api.test) — 수정 전 실패 |
| 196 | (FE-13) 배치 화면의 하트비트 · worker · 마지막 실행 시각이 KST 밖의 브라우저에서 9시간 어긋남(143 의 빈틈) | 서버가 시간대 없는 LocalDateTime 을 보내고 dt() 가 브라우저 현지 시각으로 읽음 |
시간대 없는 날짜 · 시각은 KST 로 | 웹 단위(TZ=UTC) — 수정 전 21:48 |
| 197 | (FE-14) 규칙 · 매핑 · 성적표 · 주가 반응 · 감사 · 알림 8곳에서 조회가 실패하면 오류 아래 ‘불러오는 중’이 끝나지 않음(163 의 나머지) | 값이 없으면 오류와 상관없이 로딩 | 163 과 같은 규칙 | E2E(8개 조회 500) — 수정 전 실패 |
| 198 | (FE-15) 실행 중인 Job 의 상세를 열어 두면 목록만 갱신되고 상세는 처음 값에 멈춤 | 폴링이 상세(컴포넌트 안의 조회)를 부르지 않음 | 상세 조회를 페이지로 올려 고른 실행이 돌면 같은 폴링에서 | E2E(상세 조회 횟수 — 수정 전 1회) |
| 199 | (FE-16) 검색어를 바꾸고 200 ms 안에 Enter(한글 IME 확정)를 누르면 이전 검색어의 첫 결과로 이동 | 결과가 지금 입력의 것인지 보지 않음 | 지금 입력과 같을 때만 결과를 쓰고, 이른 Enter 는 바로 검색 | E2E — 수정 전 ‘가’의 결과로 이동 |
| 200 | (FE-17) 알림함 ‘안 읽음’ 2쪽의 마지막 건을 읽으면 ‘읽지 않은 알림이 없습니다’(머리글 30건) · 쪽 넘김 사라짐, 범위 밖 ?p= 의 기업 목록은 ‘배치를 실행하세요’ |
빈 쪽을 ‘데이터 없음’으로 | 순수 함수 pageOverflow 로 빈 쪽을 가려 마지막 쪽으로 돌아가는 버튼(효과 안 상태 변경 없이) |
웹 단위 · E2E — 수정 전 실패 |
| 201 | (FE-18) 경보 목록 행을 화면 읽기 프로그램이 고를 수 있는 항목으로 알지 못하고 선택 상태가 없음(147 과 같은 유형) | <tr tabIndex onClick> |
제목을 버튼(aria-pressed)으로 | E2E(키보드 Enter → 선택) — 수정 전 버튼 없음 |
| 202 | (FE-19) 규칙 편집기를 연 채 로그아웃하면 익명에게 ‘새 버전으로 저장’이 남음(누르면 401) | 같은 화면이 다시 마운트되지 않음 | 편집기는 관리자일 때만 | E2E(가짜 세션) — 수정 전 실패 |
| 203 | (FE-20) ESLint 계층 경계가 상대 경로(../api/client)와 window.fetch · globalThis.fetch 를 막지 못함, 근거 계보 JSON 주소가 화면에 하드코딩 |
규칙이 @/ 경로 · 이름 fetch 만 봄 | lib/domain 의 상위 상대 경로 금지, 세 객체의 fetch 속성 금지, alertApi.lineageJson |
lib/boundaries.test.ts(lintText 로 우회 모양 — 수정 전 2건 실패) |
| 204 | (DEP-5) redis 가 root 로 돎(AWS 도 같은 compose) | command: sh -c 가 공식 entrypoint 의 권한 내림을 건너뜀 |
user: "999:1000" |
UID 999 · healthy · 캐시 동작 |
| 205 | (DEP-6) web 이미지가 Debian 12(2026-07 정규 보안 지원 종료) | — | node:24-trixie-slim |
Debian 13.7 · E2E 54/54 · 이미지 468 → 453 MB |
| 206 | (DEP-7) k6 0.55 지원 종료(현재 · 직전 메이저만 수정) | — | 2.3.0 — 스크립트 그대로 | make load · make ratelimit 통과. 전후 성능 비교는 같은 0.55 로 먼저 잼 |
| 207 | (독립 검토) 실행 요청 상세(/batch/requests/{id})는 익명에게 결과 메시지 원문(스택)을 그대로 줌 — 177 · 192 가 목록만 가림 |
상세 핸들러가 가림을 거치지 않음 | 상세도 같은 가림, 메시지는 읽을 때도 KeyMasker(그 전에 쌓인 행) | PublicViewIT — 수정 전 실패 |
| 208 | (독립 검토) 결정 D2 의 ‘노출 화면 기본값도 R-X01’이 API 에만 — 화면은 365 · 5 를 늘 붙여 보내고 노출 CSV 는 고정 기본값이라, R-X01 을 바꾸면 경보 · 기업 카드와 다시 갈라짐 | 화면 · CSV 의 기본값 | 화면은 고르기 전에는 비워 보내 서버 기본값을 받고 그 기준으로 선택 표시, CSV 도 R-X01 기본값 | RiskRegionShareIT(규칙 730일 → CSV 730d) · E2E(첫 요청에 days 없음) — 수정 전 실패 |
| 209 | (독립 검토) ESLint 경계가 ./../hooks/… 와 화면의 상대 경로 ../lib/api/types 를 여전히 통과 · 경계 테스트가 ‘아무 오류’만 봄 |
203 의 패턴 범위 | 패턴 추가, 테스트는 경계 규칙(no-restricted-*)으로 잡혔는지 확인 |
lib/boundaries.test.ts — 수정 전 2건 실패 |
| 210 | (독립 검토) api · worker 가 동시에 기동해 seed 적재가 겹치면 sj_div 가 빈 seed 매핑 규칙이 중복으로 쌓일 수 있음(NOT NULL 이면 진 쪽이 기동 실패) — 185 는 화면 경로만 잠갔음 |
seed 경로의 ‘UPDATE 0건 → INSERT’ 에 잠금 없음 | seed 경로도 같은 키 잠금(트랜잭션 범위, 같은 순서) | MappingIT(커밋 전에 끼어든 두 번째 적재 — 수정 전 2행) |
| 211 | (독립 검토) 실행 행이 없으면 awaitFinished 가 끝나지 않음 — 182 가 일시 오류를 기다리며 ‘없음’(EmptyResultDataAccessException)까지 잡음 |
예외 범위 | ‘없음’은 null 로 끝냄 | JobRequestRecoveryIT(10초 제한 — 수정 전 시간 초과) |
| 212 | (push 뒤 Dependabot) 의존성 그래프가 JVM 을 보기 시작하자(180) 경고 8건 — jackson 3.1.5(High 6 · Medium 1) · commons-lang3 3.16.0(Medium) | 앱 classpath 는 이미 수정판(3.1.7 · 3.20.0), 경고는 빌드 도구(Spring Boot Gradle 플러그인)의 classpath — 이미지에는 들어가지 않음 | buildscript 제약으로 플러그인 classpath 도 jackson 3.1.7 · commons-lang3 3.18.0 |
gradlew buildEnvironment 로 해석 버전 확인, push 뒤 경고 닫힘 확인 |
측정 (전후 같은 날, 다른 프로젝트 컨테이너 11개 함께): k6 50 VU · 90초(0.55) — 238,071 → 253,533 요청(2,566 → 2,750 rps), p95 36.4 → 32.3 ms, p99 72.6 → 60.3 ms, 실패 0 → 0.
E2E 의 재시작 직후 흔들림(남은 위험): 스택을 막 다시 띄운 직후에는 E2E 가 몇 개(대시보드 · 성적표 · 관리자 흐름) 시간 초과로 실패했다 — 이 브랜치 4/54, main 도 1/48 · 4/48(같은 조건에서 main 이미지로 비교). 데워진 상태에서는 54/54. 독립 검토 수정 뒤 최종(55개): 호스트 부하 평균 13.9(CPU 4개, 다른 프로젝트 api 가 CPU 57%)인 동안 기본 4병렬로는 10 · 12개가 시간 초과로 실패했고, 같은 때 1병렬로는 55/55 통과(1.1분, api 경고 · 오류 0). 같은 도커 VM 의 다른 스택 · 차가운 JVM 의 부하 탓으로 보며 이번 변경의 회귀는 아님 — E2E 의 병렬 수 · 기다림 시간을 부하에 맞추는 일은 다음 단계.
커밋 순서의 흠: f415d55(값 고정 테스트) · c0f0f22(PERF-1)은 전체 빌드를 확인하지 않고 커밋돼 그 시점에 다른 테스트 하나(FinancialStatementJobIT)가 순서 의존으로 실패했다 — e0e9421 에서 고침(정리 단계를 ABANDONED 로). 이력을 다시 쓰지 않고 기록으로 남김.