2026-09-08 19:17 KST — • 기타 verify failed: 실패한 검증: ./gradlew --quiet test (exit 1)
최근 릴리즈
skipped — skipped
사유
릴리즈 이력이 전혀 없는 저장소. git tag 0개(전체 이력 23커밋), GitHub Release 0개(gh 인증 정상, release list 비어 있음), CHANGELOG/RELEASE/VERSION 파일 및 릴리즈 노트 양식 없음, docs/ 25개 문서에도 릴리즈 절차 없음. 유일한 버전 문자열인 build.gradle:9 version='0.0.1-SNAPSHOT' 은 Spring Initializr 기본값으로 first commit 이후 한 번도 변경된 적 없음. .github/workflows 부재이며 .gitlab-ci.yml 은 전적으로 브랜치 트리거(main/develop -> gradlew clean build -> app.war 복사 -> k8s rollout/podman restart)로 태그에 반응하는 워크플로나 버전별 산출물이 없음. deploy/offline/change-package.sh 는 폐쇄망 GitLab clone 으로 변경분을 옮기는 base->target diff 전송 도구로 릴리즈 패키징이 아님. 태그 형식/버전 체계/노트 위치를 새로 정하는 것은 사람의 판단이라 아무것도 만들지 않음. 저장소 변경 없음(커밋·태그 없음, worktree clean).
컨트롤러가 @RequestParam user_id 를 인증 사용자와 대조 없이 신뢰 (IDOR)
5/4/L
대기
AppController·ToolsController·WidgetController 등 10여 곳. ChatController 는 2026-09-02 세션에서 principal 로 덮어쓰도록 고쳐졌다. ToolsController.preview 는 @PathVariable id 만 받고 selectOcrOneInfo 매퍼에도 user_id 필터가 없어 id 를 증가시키며 남의 OCR 원본 파일을 내려받을 수 있다. 프론트 영향 범위가 넓어 엔드포인트를 나눠 여러 세션에 걸쳐 진행할 것
2026-09-08
인증 필터의 요청 헤더 전량 INFO 로깅 축소
3/2/S
대기
JwtAuthenticationFilter 가 요청마다 모든 헤더와 profile 을 INFO 로 남긴다. LogMaskUtil 로 마스킹은 하지만 요청량 대비 로그량이 크고 운영 로그에서 실제 오류를 가린다. DEBUG 로 낮추거나 필요한 헤더만 남기는 방안. 미병합 커밋 acc1904 이 같은 파일을 건드렸으므로 병합 후 진행할 것
2026-09-08
남은 외부 응답 무검증 접근에 JsonNodeUtil 확대 적용
3/2/M
대기
2026-09-06 세션(커밋 602dfc3, 아직 미병합)에서 JsonNodeUtil 을 만들었으므로 getAthenaCallApi 반환값의 (JsonNode) 무검증 캐스팅, PythonApiCallUtil/RerankModelCallUtil 응답 파싱 등 나머지 경로에도 같은 방식을 적용할 수 있다. 해당 커밋이 main 에 병합된 뒤 진행할 것
2026-09-08
MenuAccessAuthorityServiceImpl.update 가 setModifyAuditFields 대신 setCreationAuditFields 호출
2/1/S
대기
137행. 다른 모든 수정 경로는 setModifyAuditFields 를 쓴다. UPDATE SQL 이 creator_id 를 바인딩하지 않아 지금은 무해하지만 modifier_id 가 설정되지 않는다. 이 파일은 미병합 커밋 a2bcb2b 이 건드렸으므로 병합 후 진행할 것
2026-09-08
ImgCreate 목록 총건수가 검색어를 무시
2/1/S
대기
ToolsServiceImpl 1293행 countPageImgHistoryList(userId) 는 사용자 id 만 넘기는데 selectPageImgHistoryList 는 key_word 필터를 적용한다(ImgCreateMapper.xml 59-65 vs 75-81). 검색 시 총건수와 실제 목록 건수가 어긋나 페이징이 잘못된다
2026-09-08
ValidUtil, ApiCallUtil 단위 테스트 공백 보강
2/1/S
대기
common/util 15개 중 7개에 테스트 존재(2026-09-08 세션에서 SystemPolicyUtilTest 추가). ValidUtil 은 isValidUUID 하나뿐이라 값이 낮고, ApiCallUtil 은 RestClient 를 필드에서 직접 만들어(RestClient.create()) 주입이 불가능해 테스트하려면 생성자 주입으로 바꿔야 한다 — 그 리팩터가 실제 이득
2026-09-08
ToolsServiceImpl.decFileDrm 이 DrmUtil 을 new 로 직접 생성해 테스트 불가
2/2/S
대기
ToolsServiceImpl 1063·1109행과 SampleDrmServiceImpl 34행이 매 호출마다 new DrmUtil(drmConfig) 를 만든다. DrmConfig 만 의존하는 무상태 객체이므로 @Bean 하나로 만들어 주입하면 decFileDrm 의 실패 처리를 단위 테스트할 수 있다. 2026-09-07 세션에서는 DrmUtil 자체에 테스트 시임을 넣어 우회했다
2026-09-08
CORS 허용 메서드·헤더도 CorsProperties 로 옮기고 필터 오류 응답에 Vary: Origin 추가
2/2/S
대기
2026-09-06 세션(커밋 acc1904, 아직 미병합)에서 Origin 만 설정화했다. WebConfig 의 allowedMethods/allowedHeaders/exposedHeaders 는 아직 하드코딩이고, 필터가 직접 쓰는 오류 응답에는 Vary: Origin 이 없어 캐시가 잘못된 CORS 헤더를 재사용할 여지가 있다. ToolsController.preview 의 @CrossOrigin 하드코딩 Origin 목록(83-107행)도 같은 대상
2026-09-08
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr 설정화 또는 제거
2/2/S
대기
호출부가 없는 사실상 죽은 코드. 제거 쪽이 깔끔하나 외부 호출 여부 확인 필요
2026-09-08
PolicyController 의 정책 저장 본문 구조 검증 부재
2/2/S
대기
POST /policy/extension·/policy/note 가 List<Map<String,Object>> 를 그대로 받아 키 이름·타입을 전혀 검증하지 않는다. 오타난 키('groupp', 'limits')로 저장하면 API 는 success 를 돌려주지만 appCreateCheck 는 그 항목을 무시한다(이번 세션에서 NPE 대신 무제한으로 처리하게 됐으므로 조용히 제한이 풀린다). 전용 DTO + @Valid 로 바꾸면 잘못된 정책 저장을 400 으로 막을 수 있다
2026-09-08
decFileDrm 의 도달 불가능한 폴백 분기 제거
1/1/S
대기
ToolsServiceImpl 1081-1083·1127-1129행의 catch(IOException) 안 'if (resultFile != null && resultFile.exists()) return resultFile.getAbsolutePath();' 는 도달할 수 없다. 실패 경로를 읽는 사람을 헷갈리게 하는 죽은 코드
2026-09-08
SystemPolicyUtil 의 빈 배열 저장 무시 및 fileLimit 키 불일치
3/2/S
완료
2026-09-08 세션에서 구현(커밋 71c6872). saveExtension/saveNote 를 공통 writePolicyList 로 모아 빈 목록도 저장하고 null 은 BusinessException 으로 거부, 직렬화 실패를 예외로 올림. deleteExtension 은 실제 삭제된 경우에만 저장. getFileLimit()/saveFileLimit() 키 불일치는 심플봇 제한에 위임해 해소. 함께 드러나는 AthenaServiceImpl.appCreateCheck 의 빈 정책 NPE 도 toAppCreateLimits 분리로 수정
2026-09-08
원장 (에이전트가 남긴 기록)
2026-09-02
선택: 인증정보 로그 마스킹 (SEC-002) (가치 5 / 위험 1 / 작업량 M)
결과: 성공
요약: JwtAuthenticationFilter가 모든 요청 헤더와 access_token을 INFO로 평문 기록하고 있었고, AuthServiceImpl(SSO), 배치 job의 adminToken, 토큰 발급 API 응답 본문에도 같은 누출이 있어 LogMaskUtil(민감 헤더 판별 / 값 전체 마스킹 / JSON token·password 필드 마스킹)을 추가하고 7개 파일의 로그 호출에 적용했습니다. 로드맵 단계 0의 P0 항목이며 위험이 낮고 한 세션에 끝낼 수 있어 선택했습니다. 검증은 LogMaskUtilTest(5) + logback ListAppender 기반 JwtAuthenticationFilterLoggingTest(1)를 추가한 뒤 ./gradlew check build 실행으로 했고 28개 테스트 전부 통과했습니다. 커밋 36950f8.
보류 아이디어:
SEC-004: /sample, /batch/sample 공개 경로를 운영 프로필에서 비활성화하거나 관리자 권한으로 제한 (가치 4 / 위험 3 / 작업량 M) — 운영 배포 영향 확인 필요
ControllerLogAspect.logControllerCud가 getAuthentication() null 일 때 NPE 발생 가능 (가치 3 / 위험 1 / 작업량 S)
JwtAuthenticationFilter의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
getDataMap이 null 반환 시 호출부에서 dataMap.get("active") NPE — 명시적 예외 처리로 정리 (가치 3 / 위험 2 / 작업량 S)
공통 util(ValidUtil, PagingUtil, ApiCallUtil) 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
환경 메모
이 머신에는 JRE만 설치되어 있어(/usr/lib/jvm/java-21-openjdk-amd64에 javac 없음) Gradle toolchain이 실패합니다. ~/.gradle/jdks/jdk-21.0.12.1+1(Temurin 21)을 내려받아 JAVA_HOME으로 지정해야 빌드가 됩니다.
./gradlew에 실행 권한이 없어 sh gradlew ... 로 실행해야 합니다.
2026-09-02
선택: 샘플/수동 batch API 익명 공개 차단 (SEC-004) (가치 4 / 위험 2 / 작업량 M)
결과: 성공
요약: /sample/**, /batch/sample/** 가 SecurityConfig permitAll 과 JwtAuthenticationFilter.shouldNotFilter 양쪽에서 열려 있어 배치 수동 실행(/batch/sample/daily), Athena 프로젝트 삭제, 사용자 초대 같은 상태 변경 기능을 익명으로 호출할 수 있었습니다. SampleApiProperties(security.sample-api.enabled, 기본 false, env SAMPLE_API_ENABLED)를 추가해 두 지점의 경로 판정을 한 곳으로 모으고, 상태 변경 sample 컨트롤러 4종에 @ConditionalOnProperty 를 붙여 기본적으로 빈 등록 자체를 막았습니다. SSO 검증에 쓰이는 읽기 전용 /sample/jwks 는 설정과 무관하게 열어 두어 운영 영향을 없앴습니다. 검증은 SampleApiAccessTest(7) 추가 후 sh gradlew check build 실행으로 했고 13개 테스트 클래스 전부 통과했습니다. 커밋 1b39744.
보류 아이디어:
ControllerLogAspect.logControllerCud가 getAuthentication() null 일 때 NPE 발생 가능 (가치 3 / 위험 1 / 작업량 S)
JwtAuthenticationFilter의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
getDataMap이 null 반환 시 dataMap.get("active") NPE — 명시적 예외 처리로 정리 (가치 3 / 위험 2 / 작업량 S)
공통 util(ValidUtil, PagingUtil, ApiCallUtil) 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
SEC-003: SSO 토큰 교환의 is_skip_jwt=true 운영 우회 제거 (가치 5 / 위험 4 / 작업량 M) — IAM 담당자 계약 확인 필요라 자율 진행 부적합
2026-09-03
선택: 페이징 파라미터 하한 미보정으로 인한 목록 조회 오류 수정 (가치 4 / 위험 1 / 작업량 S)
결과: 성공
요약: PageVo.pageNum 기본값이 0 이라 pageNum 을 생략한 요청에서 getOffset() 이 -pageSize 를 반환해 OFFSET 을 쓰는 20여 개 매퍼 쿼리가 PostgreSQL “OFFSET must not be negative” 로 실패하고, PagingUtil.paginate() 는 음수 fromIndex 로 subList IndexOutOfBoundsException 을 던졌습니다(예: GET /app/chat/list, /tools OCR·STT 목록). 이미 QuestionLogServiceImpl, StorageBoxServiceImpl, ToolsServiceImpl, AppStatisticsServiceImpl 4곳에 같은 보정이 개별 복사돼 있어 중앙화가 맞다고 판단했습니다. getOffset() 과 paginate() 에서 pageNum/pageSize 를 1 이상으로 보정하고 long 연산으로 오버플로를 막았으며, ToolsMapper 의 인라인 ((#{pageNum} - 1) * #{pageSize}) 2건을 다른 매퍼와 동일하게 #{offset} 로 통일했습니다. AthenaServiceImpl 의 getPageNum() != 0 센티널 의미를 깨지 않으려고 필드 기본값과 getter 는 건드리지 않았습니다. 검증은 PageVoTest(5) + PagingUtilTest(7) 추가 후 sh gradlew check build 실행으로 했고 47개 테스트 전부 통과했습니다. 커밋 44052f5.
보류 아이디어:
ControllerLogAspect.logControllerCud가 getAuthentication() null 일 때 NPE — @AfterReturning 이라 성공 응답이 500 으로 바뀜 (가치 3 / 위험 1 / 작업량 S)
getDataMap이 null 반환 시 dataMap.get("active") NPE — 명시적 예외 처리로 정리 (가치 3 / 위험 2 / 작업량 S)
JwtAuthenticationFilter의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
LIMIT #{pageSize} 에 음수/0 pageSize 가 그대로 전달되는 경로 보정 (가치 2 / 위험 2 / 작업량 S) — setter 보정 시 pageSize 복사 경로 영향 확인 필요
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
2026-09-03
선택: 감사 로그 실패가 성공한 CUD 요청을 500 으로 뒤집는 문제 수정 (가치 4 / 위험 1 / 작업량 M)
결과: 성공
요약: ControllerLogAspect.logControllerCud 는 @AfterReturning 이라 컨트롤러가 성공하고 트랜잭션이 커밋된 뒤 실행되는데 본문 전체에 예외 처리가 없어, activity_log insert 실패·관리자 계정 조회 실패·인증정보 부재 NPE 가 그대로 전파되면 GlobalExceptionHandler 가 500 을 반환해 클라이언트가 이미 성공한 생성/수정/삭제를 실패로 인식하고 재시도하게 됩니다. 감사 로그 기록 전체를 try/catch 로 감싸고, currentRequestAttributes() 를 getRequestAttributes() + null 체크로 바꿨습니다. 또 getAuthentication().getPrincipal().equals("anonymousUser") 후 캐스팅하는 동일 패턴이 ControllerLogAspect, GlobalExceptionHandler, AuditUtil 3곳에 복사돼 있어 CurrentUserUtil 로 모으고 authentication null / principal null / CustomUserDetails 아닌 principal 을 모두 null 로 처리해 각 호출부의 기존 fallback(관리자 계정 조회 또는 BusinessException)을 타게 했습니다. AuditUtil 이 principal 객체 전체를 INFO 로 남기던 로그는 userId 만 DEBUG 로 축소했습니다. 검증은 CurrentUserUtilTest(5) + AuditUtilTest(4) + ControllerLogAspectTest(5) 추가 후 sh gradlew check build 실행으로 했고 17개 테스트 클래스 61개 테스트 전부 통과했습니다. 커밋 c115e68.
보류 아이디어:
JwtAuthenticationFilter 80행 (boolean) dataMap.get("active") — Athena 장애로 getDataMap 이 null 을 반환하면 NPE, 명시적 인증 실패로 정리 (가치 3 / 위험 2 / 작업량 S)
JwtAuthenticationFilter 의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
LIMIT #{pageSize} 에 음수/0 pageSize 가 그대로 전달되는 경로 보정 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
AuditUtil.getUserInfo() 외 TransferManagementServiceImpl 63행도 CurrentUserUtil 로 통일 (가치 2 / 위험 1 / 작업량 S)
2026-09-03
선택: IAM introspect 응답 형식 미검증으로 인한 필터 500 오류 수정 (가치 4 / 위험 1 / 작업량 S)
결과: 성공
요약: JwtAuthenticationFilter 80행이 (boolean) dataMap.get("active") 로 IAM introspect 응답을 검증 없이 꺼내고 있어, getDataMap 이 null 을 반환하는 경로(jsonToVo 파싱 실패 → null, 또는 code 200 인데 data 없음)에서 NPE 가, active/ext/email 타입이 다르면 ClassCastException 이 필터 밖으로 전파됐습니다. 필터에서 새어 나간 예외는 @ControllerAdvice 인 GlobalExceptionHandler 가 잡지 못해 CORS 헤더도 에러 본문도 없는 컨테이너 500 이 나가고, 브라우저는 재로그인 안내 대신 CORS 오류만 보게 됩니다. 네 가지 형식 오류를 모두 AthenaJwtException(ATHENA_JWT_PARSING) 으로 바꿔 필터의 기존 인증 실패 응답 경로(상태 400 + JSON 본문 + CORS 헤더)를 타게 했고, ext 는 있는데 email 이 없어 인증 없이 체인을 통과시키던 경로도 명시적 실패로 정리했습니다. ext 가 null 이면 M2M 으로 취급하는 기존 동작은 유지했습니다. 검증은 JwtAuthenticationFilterIntrospectTest(8: 형식 오류 6 + 정상 사용자 인증 / M2M 인증 회귀 가드) 추가 후 sh gradlew check build 실행으로 했고 18개 테스트 클래스 69개 테스트 전부 통과했습니다. 커밋 e373af9.
보류 아이디어:
JwtAuthenticationFilter 의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M) — catch 블록 안에서 요청마다 Set.of 를 새로 만드는 문제도 함께 해소됨
필터 내 BusinessException("사용자 정보가 없습니다.") 도 catch 되지 않아 500 + CORS 헤더 누락 (가치 3 / 위험 2 / 작업량 S) — 상태코드 변경이라 프론트 영향 확인 필요
LIMIT #{pageSize} 에 음수/0 pageSize 가 그대로 전달되는 경로 보정 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
TransferManagementServiceImpl 63행도 CurrentUserUtil 로 통일 (가치 2 / 위험 1 / 작업량 S)
2026-09-03
선택: 업로드 파일명 경로 조작 및 확장자 없는 파일명 500 오류 수정 (가치 5 / 위험 2 / 작업량 M)
결과: 성공
요약: MultipartFile#getOriginalFilename() 은 클라이언트가 보낸 값을 그대로 돌려주는데 6개 업로드 경로(FileServiceImpl.upload/ocrUpload/uploadDrm, ToolsServiceImpl.parse/parse2/decFileDrm, AthenaServiceImpl.decFileDrm, SampleDrmServiceImpl.decFileDrm)가 이 값을 Path#resolve / File#createTempFile 에 검증 없이 넘겨 ../../ 이 포함된 파일명으로 업로드 기준 디렉토리 밖에 파일을 쓸 수 있었고, FileServiceImpl 의 두 업로드 메소드는 substring(lastIndexOf(".")) 로 확장자를 뽑아 확장자 없는 파일명(예: README)에 StringIndexOutOfBoundsException → 500 이 났습니다. 이미 ToolsServiceImpl 에 private 으로 safeOriginalFilename/fileExtension 이 있었지만 STT 경로에서만 쓰여, 이를 FileNameUtil(safeFileName / extension / storedFileName)로 옮겨 모든 업로드 경로가 같은 규칙을 쓰도록 통일했습니다. 덤으로 ocrUpload 가 저장은 원본 파일명으로 하면서 realName 에는 savedFilename 을 기록해 이후 다운로드·삭제가 항상 실패하던 문제도 고쳤습니다. 검증은 FileNameUtilTest(10) + FileServiceImplTest(3, @TempDir 로 실제 저장 위치 확인) 추가 후 sh gradlew check build 실행으로 했고 20개 테스트 클래스 82개 테스트 전부 통과했습니다. 커밋 eba85d7.
보류 아이디어:
JwtAuthenticationFilter 의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
FileController.down 이 fileService.get(fileId) null 일 때 NPE, uploadDrm 에 System.out.println 디버그 잔존 (가치 2 / 위험 1 / 작업량 S)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
LIMIT #{pageSize} 에 음수/0 pageSize 가 그대로 전달되는 경로 보정 (가치 2 / 위험 2 / 작업량 S)
필터 내 BusinessException("사용자 정보가 없습니다.") 도 catch 되지 않아 500 + CORS 헤더 누락 (가치 3 / 위험 2 / 작업량 S)
2026-09-03
선택: 파일 다운로드 500 오류 및 삭제 후 잔존 파일 정보 수정 (가치 4 / 위험 1 / 작업량 M)
결과: 성공
요약: ResponseHandController.downloadFile 이 MediaTypeFactory.getMediaType(realName).orElseThrow(IllegalArgumentException) 로 되어 있어 확장자를 모르는 저장 파일(hwp/hwpx, 그리고 직전 세션에서 허용하게 된 확장자 없는 업로드 → UUID 만 남는 저장명)의 다운로드가 404 대신 500 으로 실패했고, FileController.down 은 fileService.get(fileId) 가 null 을 반환하는 삭제된 파일 아이디에 NPE 로 500 을 냈습니다. 같은 클래스의 downFile 이 이미 CUSTOM_MEDIA_TYPES(hwp) → MediaTypeFactory → octet-stream 폴백을 갖고 있어 이를 resolveMediaType 으로 추출해 두 경로가 같은 규칙을 쓰도록 통일하고, MIME 결정을 Files.newInputStream 이전으로 옮겨 예외 시 열린 스트림이 남지 않게 했습니다. Content-Length 는 DB 값 대신 실제 파일 크기를 쓰고, fileName 이 null 이면 저장명으로 대체합니다. 추가로 FileServiceImpl.delete 가 Files.deleteIfExists 가 false 일 때 DB 행을 남겨 목록에 계속 노출되고 재삭제가 영원히 실패하던 문제(게시판/자료실 첨부 교체 경로에서 발생)와 ToolsServiceImpl.deleteImgHistory 의 이력/파일 정보 null NPE 를 고쳤고, FileController.uploadDrm 의 디버그 System.out.println 을 제거했습니다. 검증은 ResponseHandControllerDownloadTest(8) + FileServiceImplTest 삭제 케이스(4) 추가 후 sh gradlew check build 실행으로 했고 21개 테스트 클래스 94개 테스트 전부 통과했습니다. 커밋 cfa5c48.
보류 아이디어:
JwtAuthenticationFilter 의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
필터 내 BusinessException("사용자 정보가 없습니다.") 도 catch 되지 않아 500 + CORS 헤더 누락 (가치 3 / 위험 2 / 작업량 S)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
LIMIT #{pageSize} 에 음수/0 pageSize 가 그대로 전달되는 경로 보정 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
2026-09-04
선택: 페이지 크기 하한 미보정으로 인한 목록 조회 500 오류 수정 (가치 4 / 위험 1 / 작업량 M)
결과: 성공
요약: 이전 세션에서 getOffset() 만 보정했고 LIMIT #{pageSize} 는 그대로 남아 있어, 20개 매퍼 쿼리(게시판·자료실·권한검색·통계·앱관리·로그·툴 목록 등)에서 ?pageSize=-1 이 PostgreSQL “LIMIT must not be negative” 로 500 을 냈고 pageSize=0 은 totalCount 가 있는데도 항상 빈 목록을 돌려주었습니다. PageVo.getLimit() 을 추가해 1 이상으로 보정하고 매퍼 20곳을 #{limit} 으로 통일했으며, DeptStatisticsController 등 4개 컨트롤러가 전체 내려받기 용도로 setPageSize(Integer.MAX_VALUE) 를 쓰고 있어 기존 세 서비스의 100 상한을 전역으로 올리지는 않고 하한만 보정했습니다. 별도 패턴이던 AppDirectMapper 의 LIMIT #{params.size} OFFSET #{params.page} * #{params.size} 도 getLimit()/getOffset() 으로 옮겨 null(빈 문자열 바인딩)·음수 page/size 를 함께 막았습니다. 검증은 PageVoTest 4건 추가 + AppDirectSearchParamsTest(6) + 매퍼 XML 의 LIMIT/OFFSET 바인딩이 보정된 프로퍼티만 쓰는지 확인하는 MapperLimitBindingTest(3) 추가 후 sh gradlew check build 실행으로 했고 23개 테스트 클래스 107개 테스트 전부 통과했습니다. 커밋 60ac379.
보류 아이디어:
JwtAuthenticationFilter 의 CORS 허용 Origin 30여 개 하드코딩을 설정(yaml)으로 외부화 (가치 3 / 위험 3 / 작업량 M)
필터 내 BusinessException("사용자 정보가 없습니다.") 도 catch 되지 않아 500 + CORS 헤더 누락 (가치 3 / 위험 2 / 작업량 S)
AthenaServiceImpl 1629·2294행 등 Athena 응답의 get(0) / getEmail().split("@")[0] 무검증 접근 — 빈 배열·null 이메일에 NPE (가치 3 / 위험 2 / 작업량 S)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
2026-09-05
선택: 인증 필터 예외가 컨테이너 500 으로 새어 나가는 문제 수정 (가치 4 / 위험 2 / 작업량 M)
결과: 성공
요약: JwtAuthenticationFilter 는 AthenaJwtException 만 catch 하고 있어, IAM introspect 가 active=true 로 응답했지만 포털 DB 에 사용자 행이 없을 때 던지는 BusinessException("사용자 정보가 없습니다.") 과 userInfoService.getUserInfoByEmail 이 실패할 때의 예외가 필터 밖으로 전파됐습니다. 필터는 @ControllerAdvice 인 GlobalExceptionHandler 의 처리 대상이 아니라 이 경우 클라이언트가 약속된 ResponseDto{code,message} 대신 컨테이너 기본 오류 응답을 받아, IAM 계정은 있지만 포털에 미등록된 사용자가 원인을 알 수 없는 500 을 보게 됩니다. 인증 로직을 authenticate() 로 분리하고 filterChain.doFilter 를 try 밖으로 옮겨 인증 단계 예외만 응답으로 변환하도록 했습니다(컨트롤러에서 올라온 예외는 삼키지 않음). BusinessException 은 GlobalExceptionHandler 와 동일한 형식으로, 그 외 예외는 INTERNAL_SERVER_ERROR 형식으로 내려줍니다. 덤으로 catch 블록 안에서 요청마다 새로 만들던 30여 개 Origin Set.of 와 ObjectMapper 를 static 상수로 올리고 응답 기록을 writeErrorResponse() 로 추출했으며, Origin 헤더는 항상 스킴을 포함해 절대 매칭될 수 없던 "sso.kcb4u.com" 항목을 WebConfig 와 같은 "https://sso.kcb4u.com" 으로 고쳤습니다. 검증은 JwtAuthenticationFilterErrorResponseTest(6: 사용자 미등록 / 조회 실패 / CORS 허용·비허용 Origin / sso Origin / 다운스트림 예외 비삼킴 회귀 가드) 추가 후 sh gradlew check build 실행으로 했고 24개 테스트 클래스 113개 테스트 전부 통과했습니다. 커밋 ff2d4a6.
보류 아이디어:
JwtAuthenticationFilter 와 WebConfig 의 허용 Origin 목록 이중 관리를 설정(yaml)으로 일원화 — 현재 두 목록이 서로 다르게 드리프트 중 (가치 3 / 위험 3 / 작업량 M)
AthenaServiceImpl 1629·2294행, UserInfoAthenaServiceImpl 215·362행의 JsonNode .get(0) 무검증 접근 — 빈 items 배열에 NPE (가치 3 / 위험 2 / 작업량 S)
AthenaServiceImpl 1790행 userInfoVo.getEmail().split("@")[0] — email null 에 NPE (가치 3 / 위험 1 / 작업량 S)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 (가치 2 / 위험 1 / 작업량 S)
2026-09-06
선택: 잘못된 요청 파라미터가 400 대신 500 으로 응답되는 문제 수정 (가치 4 / 위험 2 / 작업량 M)
결과: 성공
요약: GlobalExceptionHandler 의 @ExceptionHandler(Exception.class) 는 Spring 의 DefaultHandlerExceptionResolver 보다 먼저 동작하기 때문에, Spring 이 원래 400 으로 내려 주던 클라이언트 입력 오류(?limit=abc 같은 파라미터 타입 불일치, 필수 파라미터 누락, 깨진 JSON 본문, 필수 업로드 항목 누락)까지 전부 삼켜서 C002 서버 내부 오류가 발생했습니다(500) 로 응답했습니다. 호출자는 자기 요청이 잘못된 것인지 서버가 죽은 것인지 구분할 수 없고, 이 요청들이 activity_log 에 서버 오류로 쌓여 실제 장애를 가립니다(컨트롤러 45개 @RequestParam, 44개 @RequestBody 가 모두 해당). 핸들러 인자 처리 단계에서 발생하는 MethodArgumentTypeMismatchException·MissingServletRequestParameterException·MissingServletRequestPartException·HttpMessageNotReadableException·BindException 을 400(C001) 로, HttpMediaTypeNotSupportedException 을 415(신규 ErrorCode.UNSUPPORTED_MEDIA_TYPE, C003) 로 매핑하고 어떤 파라미터가 문제인지 알려 주는 메시지를 붙였습니다(예외 원문에는 내부 타입·패키지명이 섞여 있어 그대로 노출하지 않음). 매핑 단계에서 던져지는 예외(405·404 등)는 handler 가 null 이라 basePackages 로 제한된 이 advice 의 대상이 아니어서 범위에서 제외했고, 기존 MethodArgumentNotValidException 핸들러는 BindException 보다 구체적이라 그대로 우선합니다. 검증은 GlobalExceptionHandlerInvalidRequestTest(8: 타입 불일치 / 필수 파라미터 누락 / 깨진 JSON / 필수 업로드 항목 누락 / 415 / 정상 요청 / 서버 예외 500 유지 / BusinessException 400 유지 회귀 가드) 를 standalone MockMvc 로 추가한 뒤 sh gradlew check build 실행으로 했고 25개 테스트 클래스 121개 테스트 전부 통과했습니다. 커밋 5cb50b3. (참고: 이 세션 환경에는 JDK 가 없고 JRE 만 있어 Temurin 21 을 ~/jdks 에 내려받아 JAVA_HOME 을 지정해 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다.)
보류 아이디어:
JwtAuthenticationFilter 와 WebConfig 의 허용 Origin 목록 이중 관리를 설정(yaml)으로 일원화 — 현재 두 목록이 서로 다르게 드리프트 중 (가치 3 / 위험 3 / 작업량 M)
AthenaServiceImpl 2294행 root.get("data").get("data").get("documents").get(0) 등 무검증 JsonNode 체인 — 응답 형식이 다르면 NPE (가치 3 / 위험 2 / 작업량 S)
AthenaServiceImpl 1790행 userInfoVo.getEmail().split("@")[0] — email null 에 NPE (가치 3 / 위험 1 / 작업량 S)
AppController·ToolsController·WidgetController 등이 @RequestParam user_id 를 그대로 신뢰 — 인증 사용자와 대조하지 않아 타인 데이터 접근 가능(IDOR). 프론트 영향 범위가 넓어 별도 세션 필요 (가치 5 / 위험 4 / 작업량 L)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
2026-09-06
선택: 허용 Origin 목록 이중 관리로 인한 인증 실패 응답 CORS 헤더 누락 수정 (가치 3 / 위험 2 / 작업량 M)
결과: 성공
요약: CORS 헤더는 두 단계에서 붙는다 — 정상 응답은 MVC 의 WebConfig, 인증 실패처럼 필터가 직접 기록하는 응답은 JwtAuthenticationFilter 다. 두 곳이 30여 개 Origin 을 각각 하드코딩하고 있어 목록이 서로 다르게 드리프트했고, 필터 쪽에만 https://ai-portal-front-local.kubagents.koreacb.com:8080 과 https://daiportal-java.kubagents-ofc.koreacb.com 이 빠져 있어 그 두 프론트는 인증 실패 시 401/500 본문 대신 상태코드도 메시지도 읽을 수 없는 CORS 오류를 보게 됩니다(직전 세션의 필터 오류 응답 개선이 이 두 Origin 에서는 무효). 목록을 application.yaml 의 security.cors.allowed-origins 한 곳으로 옮기고 새 CorsProperties(@ConfigurationProperties, 병합 결과 캐시)를 두 곳이 함께 읽도록 했습니다. 실제 CORS 정책인 WebConfig 목록을 기준으로 삼았고(중복 1건 제거), 필터에만 있던 http://localhost:8080 은 정상 요청에서는 어차피 차단되던 값이라 운영 정책을 넓히지 않기 위해 제외하는 대신 재빌드 없이 덧붙일 수 있는 CORS_ADDITIONAL_ALLOWED_ORIGINS 환경변수를 추가하고 ENVIRONMENT_VARIABLES_GUIDE.md 에 문서화했습니다. 검증은 CorsPropertiesTest(6: yaml 이 기존 두 목록의 34개 Origin 을 모두 포함하는지 / 모든 항목이 스킴 포함·경로 없음 / 매칭·null 안전 / 환경변수 병합·공백 무시 / 캐시 갱신 / WebConfig 등록 내용) 와 JwtAuthenticationFilterErrorResponseTest 에 드리프트 회귀 테스트 1건을 추가하고, 기존 필터 테스트 4곳이 실제 application.yaml 목록을 바인딩해 쓰도록 CorsPropertiesFixture 를 만든 뒤 sh gradlew check build 를 실행해 했고 25개 테스트 클래스 120개 테스트 전부 통과했습니다. 커밋 acc1904. (참고: 이 세션 환경에도 JDK 가 없어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다.)
보류 아이디어:
AthenaServiceImpl 2294행 root.get("data").get("data").get("documents").get(0) 등 무검증 JsonNode 체인 — 응답 형식이 다르면 NPE (가치 3 / 위험 2 / 작업량 S)
AthenaServiceImpl 1790행 userInfoVo.getEmail().split("@")[0] — email null 에 NPE (가치 3 / 위험 1 / 작업량 S)
AppController·ToolsController·WidgetController 등이 @RequestParam user_id 를 그대로 신뢰 — 인증 사용자와 대조하지 않아 타인 데이터 접근 가능(IDOR). 엔드포인트를 나눠 여러 세션에 걸쳐 진행 필요 (가치 5 / 위험 4 / 작업량 L)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
ValidUtil, ApiCallUtil 단위 테스트 공백 보강 — common/util 15개 중 5개만 테스트 존재 (가치 2 / 위험 1 / 작업량 S)
2026-09-06
선택: Athena/IAM 응답 무검증 JsonNode 체인 접근 및 email null NPE 수정 (가치 3 / 위험 2 / 작업량 M)
결과: 성공
요약: 외부 Athena/IAM 응답을 root.get("data").get("data").get("documents").get(0).get("id") 처럼 무검증으로 체인 접근하던 곳들이, 응답 형식이 조금만 달라도 NPE 로 끝나 클라이언트에는 “서버 내부 오류”(500) 로만 보이고 로그에도 어떤 값이 비어 있었는지 남지 않았습니다. 특히 getDirectoryId(1629행)와 UserInfoAthenaServiceImpl.createProject(215행)는 total > 0 인데 items 가 빈 응답에서, detailApp(2963~2966행)은 config 에 knowledge_id 등이 없을 때 터졌는데 — 후자는 바로 아래에 validateOrThrow(param.get("knowledge_id").toString(), "지식저장소 ID") 라는 검증 의도가 이미 있었지만 그 전에 NPE 가 나서 의도한 메시지가 한 번도 나올 수 없었습니다. 공통 유틸 JsonNodeUtil(경로 탐색 / 필수값 검증 / 배열 첫 요소, 없으면 기존 validateOrThrow 와 같은 "%s(이)가 없습니다." 형식의 IllegalArgumentException)을 추가해 7개 호출부에 적용했고, 복구 가능한 곳(getDirectoryId → 디렉토리 생성 경로, checkMyProject 의 email null → false)은 예외 대신 기존 폴백 경로를 타게 했습니다. (ObjectNode) node.path("data").path("application") 의 ClassCastException 가능성과 UserInfoAthenaServiceImpl 의 예외 메시지에 원인이 문자열 리터럴로 박혀 있던 문제("... {}, e.getMessage()")도 함께 고쳤습니다. 검증은 JsonNodeUtilTest(9) + AthenaServiceImplResponseGuardTest(8: items 불일치 / items 누락 / 정상 / email null / 사용자 없음 / data 없는 배포 이력 / 정상 배포 이력) 추가 후 sh gradlew check build 실행으로 했고 26개 테스트 클래스 130개 테스트 전부 통과했습니다. 커밋 602dfc3. (참고: 이 세션 환경에도 JDK 가 없어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다.)
보류 아이디어:
AppController·ToolsController·WidgetController 등이 @RequestParam user_id 를 그대로 신뢰 — 인증 사용자와 대조하지 않아 타인 데이터 접근 가능(IDOR). 엔드포인트를 나눠 여러 세션에 걸쳐 진행 필요 (가치 5 / 위험 4 / 작업량 L)
인증 필터의 요청 헤더 전량 INFO 로깅 축소 — 요청마다 모든 헤더를 남겨 운영 로그에서 실제 오류를 가림 (가치 3 / 위험 2 / 작업량 S)
AthenaServiceImpl 에 남은 무검증 외부 응답 접근 확대 적용 — 이번에 유틸을 만들었으므로 getAthenaCallApi 반환값 (JsonNode) 캐스팅 등 나머지 경로에도 적용 (가치 3 / 위험 2 / 작업량 M)
CORS 허용 메서드·헤더도 CorsProperties 로 옮기고 필터 오류 응답에 Vary: Origin 추가 (가치 2 / 위험 2 / 작업량 S)
FileServiceImpl.ocrUpload 의 하드코딩 경로 E:\KCB\doc\ocr — 호출부가 없는 사실상 죽은 코드라 설정화 또는 제거 판단 필요 (가치 2 / 위험 2 / 작업량 S)
2026-09-06
선택: 조회 결과 없음·필수 파라미터 누락이 500 오류로 응답되는 문제 수정 (가치 4 / 위험 2 / 작업량 M)
결과: 성공
요약: 정상 범위의 요청이 NPE·SQL 문법 오류로 500 이 되는 경로 네 곳을 고쳤습니다. ToolsServiceImpl.sttDetail 은 selectSttDetail 결과를 무검증으로 역참조해, 존재하지 않는 id(SttParamDto.id 가 primitive long 이라 id 를 생략하면 0 으로 바인딩됨)나 매퍼의 updated_at >= NOW() - INTERVAL '2 weeks' 필터 밖의 id 에서 NPE 가 났고, 더 흔하게는 변환이 아직 끝나지 않아 stt_data 가 null 인 작업을 폴링할 때마다 NPE 가 났습니다 — 진행 중 조회가 사실상 항상 500 이었습니다. ToolsServiceImpl.preview 도 같은 모양으로, 없는 id 와 DRM 해제 실패 시 의도적으로 null 로 저장되는 file_path(ToolsServiceImpl 118행 filePaths.add(null))에서 Paths.get(null) 로 터졌는데, 컨트롤러에는 이미 targetFile == null 을 “생성 파일이 없습니다.” 로 응답하는 분기가 있어서 그 분기를 타도록 null 을 돌려주게 했습니다. MenuAccessAuthorityServiceImpl.deleteByUserOrDept 는 put()/update() 에 있는 “둘 중 하나는 필수” 검증이 빠져 있어 DELETE /auth/menu-access/by-user/{menuCode} 를 쿼리 파라미터 없이 부르면 매퍼의 <choose> 가 어느 분기도 고르지 않아 AND ( ) 가 렌더링되고 PostgreSQL 문법 오류로 500 이 났습니다(같은 모양인 selectByMenuCodeAndUserOrDept 는 put() 의 검증 덕에만 살아 있었음). 서비스에 같은 문구의 검증을 넣고, 다른 호출자가 생겨도 SQL 이 깨지지 않도록 매퍼 XML 두 곳에 1 = 0 폴백 분기를 추가했습니다. 마지막으로 DashboardServiceImpl 은 athenaAppMap.get("deploySum") 처럼 값의 null 만 확인하고 Map 자체는 확인하지 않았는데, 두 쿼리 모두 순수 집계라 대상 테이블이 비면 모든 컬럼이 NULL 이 되어 MyBatis 가 빈 Map 이 아니라 null 을 돌려주므로 신규 환경의 대시보드가 NPE 로 열리지 않습니다 — 공통 toIntOrZero 로 정리했습니다. 검증은 ToolsServiceImplDetailGuardTest(6: 행 없음 / 진행 중 stt_data null / 정상 Base64 인코딩 / preview 행 없음 / preview file_path null / preview 정상), MenuAccessAuthorityServiceImplTest(4: 둘 다 null 거부·매퍼 미호출 / userId 삭제 / deptId 삭제 / 매퍼 XML 폴백 분기 회귀 가드), DashboardServiceImplTest(3: Map 자체 null / 컬럼 null / 두 소스 합산) 을 추가한 뒤 sh gradlew check build 실행으로 했고 27개 테스트 클래스 126개 테스트 전부 통과했습니다. 커밋 a2bcb2b. (참고: 이 세션 환경에도 JDK 가 없어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다. 또한 이 워크트리는 3ffa5ca 기준이라 직전 세 세션의 커밋 5cb50b3·acc1904·602dfc3 이 아직 병합되어 있지 않아, 해당 세션들이 건드린 파일은 충돌을 피하려고 이번 범위에서 제외했습니다.)
보류 아이디어:
AppController·ToolsController·WidgetController 등이 @RequestParam user_id 를 그대로 신뢰 — 인증 사용자와 대조하지 않아 타인 데이터 접근 가능(IDOR). ToolsController.preview 는 @PathVariable id 만 받고 매퍼에도 user_id 필터가 없어 id 증가만으로 남의 OCR 원본 파일을 받을 수 있다 (가치 5 / 위험 4 / 작업량 L)
DrmUtil.decrypt 가 복호화 실패 시 암호화된 원본을 그대로 반환해 호출부가 성공으로 오인 — OCR 파이프라인이 암호문을 업스트림에 보내고 작업을 “완료” 로 기록한다. generatePath 도 유니크 임시 이름을 버려 동명 파일 동시 업로드가 서로 덮어쓴다 (가치 4 / 위험 3 / 작업량 M)
BoardServiceImpl.get()/LibraryServiceImpl.get() 이 조회수 증가를 겸해 수정(PUT 당 +2)·삭제·등록 경로에서도 view_cnt 가 오른다 (가치 3 / 위험 2 / 작업량 S)
SystemPolicyUtil.saveExtension/saveNote 의 !isEmpty() 가드로 빈 배열 저장이 조용히 무시되는데 API 는 success 를 반환 — 정책을 비웠다고 믿는 관리자에게 옛 화이트리스트가 계속 적용된다. getFileLimit() 이 읽는 키(fileLimit)와 saveFileLimit() 이 쓰는 키(simpleBotFileLimit)도 서로 다르다 (가치 3 / 위험 2 / 작업량 S)
인증 필터의 요청 헤더 전량 INFO 로깅 축소 — 요청마다 모든 헤더를 남겨 운영 로그에서 실제 오류를 가림 (가치 3 / 위험 2 / 작업량 S)
2026-09-07
선택: DRM 복호화 실패가 성공으로 위장되어 암호문이 그대로 처리되는 문제 수정 (가치 4 / 위험 3 / 작업량 M)
결과: 성공
요약: DrmUtil.decrypt 는 CreateDecryptFileDAC 가 실패 코드를 돌려주면 log.error 만 남기고 암호화된 원본 파일 encFile 을 그대로 반환했습니다(52-58행, throw new Exception() 이 주석 처리된 채). 호출부 ToolsServiceImpl.decFileDrm 은 resultFile == null || !resultFile.exists() 만 확인하는데 encFile 은 당연히 존재하므로 실패가 전혀 보이지 않았고, OCR 파이프라인이 암호문을 업스트림 파싱 API 로 보낸 뒤 completeOcrAttempt 로 작업을 “완료” 로 기록했습니다 — 사용자는 원인을 알 수 없는 깨진 추출 결과를 받습니다. 실패 시 코드와 파일명을 담은 IOException 을 던지고 복호화되지 않은 채 남는 복사본을 지우도록 고쳤습니다. 호출부에는 이미 실패 경로(parse() 의 filePaths.add(null) → status=400, FILE_PREPARE_FAILED)가 준비되어 있어 그대로 흘러갑니다. AthenaServiceImpl 2005행은 decFileDrm 호출만 try 밖에 있어 파일 하나가 실패하면 요청 전체가 실패하므로, 같은 메소드 1931행과 동일하게 try 안으로 옮겨 해당 파일만 건너뛰게 했습니다. 함께, generatePath(62-79행)가 File.createTempFile 로 만든 유니크 이름을 버리고 원본 파일명을 그대로 쓰던 문제도 고쳤습니다 — 동명 파일이 동시에 처리되면 decrypt 의 REPLACE_EXISTING 복사가 서로 덮어썼습니다. 다운로드(preview → downFile) 시 쓰이는 원본 파일명은 유지해야 하므로 파일명을 바꾸는 대신 요청마다 유니크한 하위 디렉토리를 만들어 그 안에 두도록 했고, 쓰이지 않던 ext 지역 변수를 제거했습니다. 네이티브 DRM 라이브러리(SCSL.SLDsFile) 호출을 패키지 전용 createDecryptFile(File, File) 로 분리해 테스트에서 대체할 수 있게 한 뒤 DrmUtilTest(6: 성공 코드 0 / 비암호화 파일 코드 -36 / 실패 시 예외(암호문 반환 회귀 가드) / 실패 시 암호문 복사본 삭제 / 동명 파일 격리 / 루트 디렉토리 자동 생성)를 추가했습니다. 검증은 sh gradlew check build 실행으로 했고 25개 테스트 클래스 119개 테스트 전부 통과했습니다. 커밋 12cfb27. (참고: 이 세션 환경에도 JDK 가 없고 JRE 만 있어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다. 또한 이 워크트리는 3ffa5ca 기준이라 직전 네 세션의 커밋이 아직 병합되지 않았습니다.)
보류 아이디어:
AppController·ToolsController·WidgetController 등이 @RequestParam user_id 를 그대로 신뢰 — 인증 사용자와 대조하지 않아 타인 데이터 접근 가능(IDOR). ToolsController.preview 는 @PathVariable id 만 받고 매퍼에도 user_id 필터가 없어 id 증가만으로 남의 OCR 원본 파일을 받을 수 있다 (가치 5 / 위험 4 / 작업량 L)
BoardServiceImpl.get()/LibraryServiceImpl.get() 이 조회수 증가를 겸해 수정(PUT 당 +2)·삭제·등록 경로에서도 view_cnt 가 오른다 (가치 3 / 위험 2 / 작업량 S)
SystemPolicyUtil.saveExtension/saveNote 의 !isEmpty() 가드로 빈 배열 저장이 조용히 무시되는데 API 는 success 를 반환 — deleteExtension 의 마지막 항목 삭제도 반영되지 않는다. 별개로 쓰이지 않는 getFileLimit() 이 읽는 키(fileLimit)와 saveFileLimit() 이 쓰는 키(simpleBotFileLimit)가 서로 다르다 (가치 3 / 위험 2 / 작업량 S)
인증 필터의 요청 헤더 전량 INFO 로깅 축소 — 요청마다 모든 헤더를 남겨 운영 로그에서 실제 오류를 가림 (가치 3 / 위험 2 / 작업량 S)
남은 외부 응답 무검증 접근에 JsonNodeUtil 확대 적용 — getAthenaCallApi 반환값의 (JsonNode) 캐스팅, PythonApiCallUtil/RerankModelCallUtil 응답 파싱 등. 유틸을 만든 커밋 602dfc3 이 main 에 병합된 뒤 진행할 것 (가치 3 / 위험 2 / 작업량 M)
2026-09-08
선택: 게시판·자료실 등록·수정·삭제에서도 조회수가 오르는 문제 수정 (가치 3 / 위험 2 / 작업량 S)
결과: 성공
요약: BoardServiceImpl.get()(62행)과 LibraryServiceImpl.get()(61행)이 상세 조회와 view_cnt 증가를 한 메소드에서 겸하고 있어서, 내부적으로 상세를 다시 읽는 put()·modify()·delete() 경로에서도 조회수가 올랐습니다 — modify() 는 수정 전후로 상세를 두 번 읽으므로 PUT 한 번에 +2, 등록과 삭제도 각각 +1 이라, 아무도 읽지 않은 글이 작성·수정만으로 조회수 4 를 갖게 됩니다(게시판 조회수 통계가 편집이 잦은 글에서 계속 부풀려짐). 조회수 증가 여부를 인자로 받는 private find(id, countView) 로 분리해 컨트롤러의 상세 조회 엔드포인트(get())에서만 증가시키고 나머지 내부 조회는 증가시키지 않도록 했으며, 없는 글에 대한 BOARD_NOT_FOUND 동작은 그대로 유지했습니다. 검증은 BoardServiceImplViewCntTest·LibraryServiceImplViewCntTest(각 5: 상세 조회 시 정확히 1회 증가 / 없는 글은 예외 + 증가 없음 / 등록 증가 없음 / 수정 증가 없음(예전 +2 회귀 가드) / 삭제 증가 없음) 를 추가한 뒤 sh gradlew check build 실행으로 했고 26개 테스트 클래스 123개 테스트 전부 통과했습니다. 커밋 e10459a. (참고: 이 세션 환경에도 JDK 가 없고 JRE 만 있어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다. 또한 이 워크트리는 여전히 3ffa5ca 기준이라 직전 다섯 세션의 커밋이 아직 병합되지 않아, 해당 세션들이 건드린 파일은 충돌을 피하려고 이번 범위에서 제외했습니다.)
보류 아이디어:
컨트롤러가 @RequestParam user_id 를 인증 사용자와 대조 없이 신뢰 (IDOR) — ToolsController.preview 는 매퍼에도 user_id 필터가 없어 id 증가만으로 남의 OCR 원본을 받을 수 있다 (가치 5 / 위험 4 / 작업량 L)
SystemPolicyUtil.saveExtension/saveNote 의 !isEmpty() 가드로 빈 배열 저장이 조용히 무시되는데 API 는 success 를 반환 — deleteExtension 의 마지막 항목 삭제도 반영되지 않는다 (가치 3 / 위험 2 / 작업량 S)
인증 필터의 요청 헤더 전량 INFO 로깅 축소 — 요청마다 모든 헤더를 남겨 운영 로그에서 실제 오류를 가린다 (가치 3 / 위험 2 / 작업량 S)
남은 외부 응답 무검증 접근에 JsonNodeUtil 확대 적용 — 유틸을 만든 커밋 602dfc3 이 main 에 병합된 뒤 진행할 것 (가치 3 / 위험 2 / 작업량 M)
ToolsServiceImpl.decFileDrm 이 DrmUtil 을 new 로 직접 생성해 테스트 불가 — 무상태 객체이므로 @Bean 으로 주입하면 실패 처리를 단위 테스트할 수 있다 (가치 2 / 위험 2 / 작업량 S)
2026-09-08
선택: 빈 정책 저장이 조용히 무시되는데 API 는 success 를 반환하는 문제 수정 (가치 3 / 위험 2 / 작업량 S)
결과: 성공
요약: SystemPolicyUtil.saveExtension(107행)·saveNote(231행)의 !isEmpty() 가드 때문에 빈 목록 저장이 아무 일도 하지 않았는데 PolicyController 는 success() 를 돌려줬습니다 — 파일 업로드 확장자 정책이나 노트북 생성 제한을 비운 관리자는 저장됐다고 믿지만 옛 화이트리스트가 계속 적용되고, 마지막 확장자를 지우는 deleteExtension 도 같은 이유로 반영되지 않았습니다(내부적으로 saveExtension(빈 목록) 을 부름). 두 메소드를 공통 writePolicyList 로 모아 빈 목록도 그대로 저장하고, null 은 BusinessException 으로 거부하며, 지금까지 log.error 만 남기고 삼키던 직렬화 실패를 예외로 올려 실패가 success 로 보이지 않게 했습니다. deleteExtension 은 실제로 지운 경우에만 저장하도록 했고, 호출부가 없어 무해했지만 saveFileLimit()(simpleBotFileLimit 키 쓰기)과 다른 키(fileLimit)를 읽던 getFileLimit() 의 불일치도 위임으로 없앴습니다. 빈 정책 저장이 가능해지면서 드러나는 후속 NPE 도 함께 고쳤습니다 — AthenaServiceImpl.appCreateCheck(3203행)는 getNote() 결과로 만든 맵을 manage.get("admin") != -1 로 언박싱해서, 정책이 비어 있으면(관리자가 비웠거나 아직 한 번도 저장하지 않은 신규 환경) 앱 생성이 매번 500 이 났습니다. 제한 값 해석을 package-private toAppCreateLimits 로 분리해 값이 없거나 숫자가 아니면 해당 그룹을 무제한(-1)으로 보고, JSON 에서 문자열/Long 으로 올라오는 limit 도 받아들이며, 앱 갯수 조회가 실패해 빈 Map 이 남을 때 (int) param.get("total") 이 터지던 것도 막았습니다. 검증은 SystemPolicyUtilTest(8: 빈 확장자 목록 저장 회귀 가드 / 빈 노트 목록 저장 / null 거부·매퍼 미호출 / 마지막 항목 삭제 반영 / 없는 코드 삭제 시 미저장 / add·update 왕복 / 깨진 JSON 은 빈 목록 / fileLimit 키 왕복)와 AthenaServiceImplAppLimitTest(6: 빈 정책 무제한 회귀 가드 / null 정책 / admin·user 파싱 / 문자열·Long 파싱 / 읽을 수 없는 항목 무시 / 알 수 없는 그룹은 user)를 추가한 뒤 sh gradlew check build 실행으로 했고 26개 테스트 클래스 127개 테스트 전부 통과했습니다. 커밋 71c6872. (참고: 이 세션 환경에도 JDK 가 없고 JRE 만 있어 Temurin 21 을 $HOME/jdks 에 내려받아 빌드했습니다. 저장소에는 아무 변경도 하지 않았습니다. 또한 이 워크트리는 fafe97a 기준이라 직전 여섯 세션의 커밋이 아직 병합되지 않아, 해당 세션들이 건드린 파일은 충돌을 피하려고 이번 범위에서 제외했습니다.)
보류 아이디어:
컨트롤러가 @RequestParam user_id 를 인증 사용자와 대조 없이 신뢰 (IDOR) — ToolsController.preview 는 매퍼에도 user_id 필터가 없어 id 증가만으로 남의 OCR 원본을 받을 수 있다 (가치 5 / 위험 4 / 작업량 L)
인증 필터의 요청 헤더 전량 INFO 로깅 축소 — 요청마다 모든 헤더를 남겨 운영 로그에서 실제 오류를 가린다. 미병합 커밋 acc1904 이 같은 파일을 건드렸으므로 병합 후 진행 (가치 3 / 위험 2 / 작업량 S)
남은 외부 응답 무검증 접근에 JsonNodeUtil 확대 적용 — 유틸을 만든 커밋 602dfc3 이 main 에 병합된 뒤 진행할 것 (가치 3 / 위험 2 / 작업량 M)
ImgCreate 목록 총건수가 검색어를 무시 — countPageImgHistoryList(userId) 가 key_word 를 넘기지 않아 검색 시 페이징이 어긋난다 (가치 2 / 위험 1 / 작업량 S)
ToolsServiceImpl.decFileDrm 이 DrmUtil 을 new 로 직접 생성해 테스트 불가 — 무상태 객체이므로 @Bean 으로 주입하면 실패 처리를 단위 테스트할 수 있다 (가치 2 / 위험 2 / 작업량 S)