이호영 이호영 07-28
refactor: 쿼리와 자바에 박혀 있던 처리완료 문자열 제거
SQL 5곳(ProcessItemMapper 4 + DashboardMapper 1)과 자바 화이트리스트 1곳에 '처리완료'가
남아 있었다. 쿼리는 코드표를 참조할 방법이 없어 서비스가 값을 읽어 파라미터로 넘긴다.

처리상태 검증도 코드표를 따른다. 상태를 하나 더 만들어도 자바를 안 고쳐도 된다.

지정이 없을 때는 빈 문자열이 아니라 매칭되지 않는 값을 넘긴다. 빈 문자열을 주면
처리상태가 비어 있는 행이 전부 완료로 잡힌다.

파라미터화가 형식만 바뀐 게 아님을 확인하는 테스트를 넣었다. 코드표에서 done 속성을
'미처리' 쪽으로 옮기면 진행률과 상태 필터가 실제로 뒤집힌다 - 예전 구조에서는 코드표를
바꿔도 아무 일도 일어나지 않았다.

이로써 조사에서 세었던 13곳이 모두 정리됐다.

백엔드 266건 통과.

Co-Authored-By: Claude Opus 5 (1M context) 
@0f508acacd8ecd5a7d5ebc936e9082cf4098e191
src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardMapper.java
@@ -1,12 +1,18 @@
 package kr.itn.itnhub.dashboard;
 
 import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
 
 import java.util.List;
 
 @Mapper
 public interface DashboardMapper {
 
-    /** 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다. */
-    List<DashboardOrgRow> findOrgRows();
+    /**
+     * 기관 전체를 연번순으로, 권리확인/권리처리 집계와 단계·최근변동 시각까지 붙여 한 번에 읽는다.
+     *
+     * @param doneStatus 권리처리 완료로 셀 상태값. 코드표에서 와야 하는데 SQL이 코드표를
+     *                   참조할 방법이 없어 서비스가 읽어 넘긴다.
+     */
+    List<DashboardOrgRow> findOrgRows(@Param("doneStatus") String doneStatus);
 }
src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
--- src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
+++ src/main/java/kr/itn/itnhub/dashboard/DashboardService.java
@@ -42,10 +42,22 @@
     }
 
     public DashboardResponse dashboard() {
-        List<DashboardOrgRow> rows = mapper.findOrgRows();
+        List<DashboardOrgRow> rows = mapper.findOrgRows(doneStatus());
         return new DashboardResponse(summarize(rows), rows);
     }
 
+    /**
+     * 권리처리 완료로 셀 상태값. 지정이 없으면 어떤 행도 완료로 세지 않도록 매칭되지 않는
+     * 값을 준다 - 빈 문자열을 주면 처리상태가 비어 있는 행이 전부 완료로 잡힌다.
+     */
+    private String doneStatus() {
+        return codeService.group(kr.itn.itnhub.code.Codes.PROCESS_STATUS).stream()
+                .filter(code -> code.flag(kr.itn.itnhub.code.Codes.ATTR_DONE))
+                .map(kr.itn.itnhub.code.Code::code)
+                .findFirst()
+                .orElse(" ");
+    }
+
     DashboardSummary summarize(List<DashboardOrgRow> rows) {
         int docSubmittedFrom = thresholdOf(KPI_DOC_SUBMITTED);
         int completedFrom = thresholdOf(KPI_COMPLETED);
src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
--- src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
+++ src/main/java/kr/itn/itnhub/process/ProcessItemMapper.java
@@ -5,6 +5,13 @@
 
 import java.util.List;
 
+/**
+ * 권리처리 매퍼.
+ *
+ * <p>어느 값이 "처리완료"인지는 쿼리에 박지 않고 {@code doneStatus} 파라미터로 받는다.
+ * 코드값은 코드표에서 오는데 SQL 안에는 코드표를 참조할 방법이 마땅치 않아, 서비스가
+ * 읽어 넘긴다. 이 값이 틀리면 진행률과 처리일시가 조용히 어긋나므로 항상 함께 넘겨야 한다.</p>
+ */
 @Mapper
 public interface ProcessItemMapper {
 
@@ -15,19 +22,23 @@
     List<ProcessItem> findPage(@Param("orgId") Long orgId, @Param("keyword") String keyword,
                                 @Param("site") String site, @Param("board") String board,
                                 @Param("title") String title,
-                                @Param("status") String status, @Param("offset") int offset,
+                                @Param("status") String status,
+                                @Param("doneStatus") String doneStatus,
+                                @Param("offset") int offset,
                                 @Param("limit") int limit);
 
     /** keyword/status로 필터링된 건수 - 필터별 페이지 경계 계산용(현재 응답의 total과는 별개). */
     int countByOrg(@Param("orgId") Long orgId, @Param("keyword") String keyword,
                    @Param("site") String site, @Param("board") String board,
-                   @Param("title") String title, @Param("status") String status);
+                   @Param("title") String title, @Param("status") String status,
+                   @Param("doneStatus") String doneStatus);
 
     /** 여러 건에 같은 값을 한 번에 반영한다(일괄 등록). null 항목은 기존 값을 유지한다. */
     int updateProcessingBulk(@Param("orgId") Long orgId, @Param("ids") List<Long> ids,
                              @Param("processStatus") String processStatus,
                              @Param("judgedKoglType") String judgedKoglType,
-                             @Param("finalOpinion") String finalOpinion);
+                             @Param("finalOpinion") String finalOpinion,
+                             @Param("doneStatus") String doneStatus);
 
     /** 일괄 삭제. */
     int deleteByIds(@Param("orgId") Long orgId, @Param("ids") List<Long> ids);
@@ -54,7 +65,8 @@
                           @Param("judgedAiType") String judgedAiType,
                           @Param("finalOpinion") String finalOpinion,
                           @Param("judgmentBasis") String judgmentBasis,
-                          @Param("processStatus") String processStatus);
+                          @Param("processStatus") String processStatus,
+                          @Param("doneStatus") String doneStatus);
 
     int deleteById(@Param("orgId") Long orgId, @Param("id") Long id);
 
@@ -62,5 +74,5 @@
     int deleteByOrg(@Param("orgId") Long orgId);
 
     /** 검색어/상태 필터와 무관한 기관 전체 진행률 - 목록 헤더와 기관 상세 통계 카드가 함께 쓴다. */
-    ProcessStats stats(@Param("orgId") Long orgId);
+    ProcessStats stats(@Param("orgId") Long orgId, @Param("doneStatus") String doneStatus);
 }
src/main/java/kr/itn/itnhub/process/ProcessService.java (Binary)
--- src/main/java/kr/itn/itnhub/process/ProcessService.java
+++ src/main/java/kr/itn/itnhub/process/ProcessService.java
@@ -1,208 +0,0 @@
-package kr.itn.itnhub.process;
-
-import kr.itn.itnhub.org.OrgNotFoundException;
-import kr.itn.itnhub.org.OrganizationMapper;
-import org.apache.poi.ss.usermodel.Cell;
-import org.apache.poi.ss.usermodel.Row;
-import org.apache.poi.ss.usermodel.Sheet;
-import org.apache.poi.xssf.usermodel.XSSFWorkbook;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.io.ByteArrayOutputStream;
-import java.io.IOException;
-import java.io.UncheckedIOException;
-import java.util.List;
-import java.util.Set;
-
-/** 기관관리 상세의 권리처리 탭이 쓰는 목록/상세/수정/삭제/다운로드 서비스. */
-@Service
-public class ProcessService {
-
-    private static final Set<String> VALID_PROCESS_STATUSES = Set.of("미처리", "처리완료");
-
-    /**
-     * 1행 그룹 제목(A=게시물 정보(조사원), M=권리확인(변호사), U=권리처리(변호사)) + 2행 컬럼 헤더.
-     * U열(수정 링크)과 Y열은 원본 시트의 건너뛰는 열이라 빈 문자열로 남겨둔다.
-     */
-    private static final String[] COLUMN_HEADERS = {
-            "순번", "사이트명", "범주", "게시판명", "게시물제목", "URL주소", "설명",
-            "첨부파일여부", "기존 공공누리유형(+AI)", "계약서 유무", "제작일", "공표일",
-            "권리확인(대분류)", "권리확인(세부)", "처리 구분", "공공누리 유형", "AI유형", "의견", "비고", "기존 증빙자료",
-            "", "공공누리유형(판정)", "AI유형(판정)", "최종의견", "", "판단근거", "처리상태"
-    };
-
-    private final ProcessItemMapper mapper;
-    private final OrganizationMapper orgMapper;
-
-    public ProcessService(ProcessItemMapper mapper, OrganizationMapper orgMapper) {
-        this.mapper = mapper;
-        this.orgMapper = orgMapper;
-    }
-
-    public ProcessPage list(Long orgId, String keyword, String site, String board, String title,
-                            String status, int page, int size) {
-        requireOrg(orgId);
-        String kw = normalizeKeyword(keyword);
-        String siteFilter = normalizeKeyword(site);
-        String boardFilter = normalizeKeyword(board);
-        String titleFilter = normalizeKeyword(title);
-        String st = normalizeStatus(status);
-        int safePage = Math.max(page, 0);
-        int safeSize = size <= 0 ? 30 : size;
-        int offset = safePage * safeSize;
-
-        List<ProcessItem> items =
-                mapper.findPage(orgId, kw, siteFilter, boardFilter, titleFilter, st, offset, safeSize);
-        // total/done은 검색어·상태 필터와 무관한 기관 전체 진행률이다(헤더/통계 카드용 - ProcessPage 참고).
-        ProcessStats stats = mapper.stats(orgId);
-        return new ProcessPage(items, stats.total(), stats.done(), safePage, safeSize);
-    }
-
-    /** 목록에서 고른 여러 건에 같은 값을 한 번에 반영한다. 화면에서 비워 둔 항목은 유지된다. */
-    @Transactional
-    public int updateBulk(Long orgId, BulkProcessingRequest request) {
-        requireOrg(orgId);
-        if (request.ids() == null || request.ids().isEmpty()) {
-            return 0;
-        }
-        return mapper.updateProcessingBulk(orgId, request.ids(),
-                blankToNull(request.processStatus()),
-                blankToNull(request.judgedKoglType()),
-                blankToNull(request.finalOpinion()));
-    }
-
-    /** 목록에서 고른 여러 건을 지운다. */
-    @Transactional
-    public int deleteBulk(Long orgId, List<Long> ids) {
-        requireOrg(orgId);
-        if (ids == null || ids.isEmpty()) {
-            return 0;
-        }
-        return mapper.deleteByIds(orgId, ids);
-    }
-
-    private String blankToNull(String value) {
-        return value == null || value.isBlank() ? null : value;
-    }
-
-    public ProcessItem get(Long orgId, Long itemId) {
-        requireOrg(orgId);
-        return requireItem(orgId, itemId);
-    }
-
-    @Transactional
-    public ProcessItem updateProcessing(Long orgId, Long itemId, ProcessingRequest request) {
-        requireOrg(orgId);
-        validateProcessStatus(request.processStatus());
-        int updated = mapper.updateProcessing(orgId, itemId,
-                request.contractDocs(), request.judgedKoglType(), request.judgedAiType(),
-                request.finalOpinion(), request.judgmentBasis(), request.processStatus());
-        if (updated == 0) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-        return mapper.findById(orgId, itemId);
-    }
-
-    @Transactional
-    public void delete(Long orgId, Long itemId) {
-        requireOrg(orgId);
-        int deleted = mapper.deleteById(orgId, itemId);
-        if (deleted == 0) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-    }
-
-    /** 원본 파일과 같은 헤더 배치(1행 그룹제목, 2행 컬럼헤더, 3행부터 데이터)로 xlsx를 만든다. */
-    public byte[] downloadWorkbook(Long orgId) {
-        requireOrg(orgId);
-        List<ProcessItem> items = mapper.findAllByOrg(orgId);
-
-        try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream out = new ByteArrayOutputStream()) {
-            Sheet sheet = wb.createSheet(ProcessParser.PRIMARY_SHEET_NAME);
-
-            Row groupRow = sheet.createRow(0);
-            groupRow.createCell(0).setCellValue("게시물 정보(조사원)");
-            groupRow.createCell(12).setCellValue("권리확인(변호사)");
-            groupRow.createCell(20).setCellValue("권리처리(변호사)");
-
-            Row headerRow = sheet.createRow(1);
-            for (int i = 0; i < COLUMN_HEADERS.length; i++) {
-                headerRow.createCell(i).setCellValue(COLUMN_HEADERS[i]);
-            }
-
-            int rowNum = 2;
-            for (ProcessItem item : items) {
-                Row row = sheet.createRow(rowNum++);
-                setCell(row, 0, String.valueOf(item.getSeq()));
-                setCell(row, 1, item.getSiteName());
-                setCell(row, 2, item.getCategory());
-                setCell(row, 3, item.getBoardName());
-                setCell(row, 4, item.getPostTitle());
-                setCell(row, 5, item.getUrl());
-                setCell(row, 6, item.getDescription());
-                setCell(row, 7, item.getHasAttachment());
-                setCell(row, 8, item.getPriorKoglType());
-                setCell(row, 9, item.getContractDocs());
-                setCell(row, 10, item.getProducedDate());
-                setCell(row, 11, item.getPublishedDate());
-                setCell(row, 12, item.getReviewMajor());
-                setCell(row, 13, item.getReviewMinor());
-                setCell(row, 14, item.getReviewResult());
-                setCell(row, 15, item.getReviewKoglType());
-                setCell(row, 16, item.getReviewAiType());
-                setCell(row, 17, item.getReviewOpinion());
-                setCell(row, 18, item.getReviewNote());
-                setCell(row, 19, item.getPriorEvidence());
-                // 20 = U, 수정 링크 열, 건너뜀
-                setCell(row, 21, item.getJudgedKoglType());
-                setCell(row, 22, item.getJudgedAiType());
-                setCell(row, 23, item.getFinalOpinion());
-                // 24 = Y, 빈 열
-                setCell(row, 25, item.getJudgmentBasis());
-                setCell(row, 26, item.getProcessStatus());
-            }
-
-            wb.write(out);
-            return out.toByteArray();
-        } catch (IOException e) {
-            throw new UncheckedIOException("권리처리 엑셀을 만들지 못했습니다.", e);
-        }
-    }
-
-    private void setCell(Row row, int columnIndex, String value) {
-        if (value == null) {
-            return;
-        }
-        Cell cell = row.createCell(columnIndex);
-        cell.setCellValue(value);
-    }
-
-    private void validateProcessStatus(String processStatus) {
-        if (processStatus != null && !VALID_PROCESS_STATUSES.contains(processStatus)) {
-            throw new InvalidProcessStatusException("처리상태는 미처리 또는 처리완료여야 합니다.");
-        }
-    }
-
-    private String normalizeKeyword(String keyword) {
-        return (keyword == null || keyword.isBlank()) ? null : keyword.trim();
-    }
-
-    private String normalizeStatus(String status) {
-        return (status == null || status.isBlank()) ? null : status.trim();
-    }
-
-    private void requireOrg(Long orgId) {
-        if (orgMapper.findById(orgId) == null) {
-            throw new OrgNotFoundException("기관을 찾을 수 없습니다: " + orgId);
-        }
-    }
-
-    private ProcessItem requireItem(Long orgId, Long itemId) {
-        ProcessItem item = mapper.findById(orgId, itemId);
-        if (item == null) {
-            throw new ProcessNotFoundException("게시물을 찾을 수 없습니다: " + itemId);
-        }
-        return item;
-    }
-}
+Binary file is not shown
src/main/resources/mapper/DashboardMapper.xml
--- src/main/resources/mapper/DashboardMapper.xml
+++ src/main/resources/mapper/DashboardMapper.xml
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="kr.itn.itnhub.dashboard.DashboardMapper">
@@ -75,7 +75,7 @@
     left join (
       select org_id,
              count(*) as total,
-             count(*) filter (where process_status = '처리완료') as done,
+             count(*) filter (where process_status = #{doneStatus}) as done,
              max(updated_at) as last_at
       from process_item group by org_id
     ) pr on pr.org_id = o.id
src/main/resources/mapper/ProcessItemMapper.xml
--- src/main/resources/mapper/ProcessItemMapper.xml
+++ src/main/resources/mapper/ProcessItemMapper.xml
@@ -1,4 +1,4 @@
-<?xml version="1.0" encoding="UTF-8"?>
+<?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         "https://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="kr.itn.itnhub.process.ProcessItemMapper">
@@ -39,10 +39,10 @@
   <!-- status: null=전체, "DONE"=처리완료, "PENDING"=그 외(null/빈/미처리 등 처리완료가 아닌 전부). -->
   <sql id="statusFilter">
     <if test="status != null and status == 'DONE'">
-      and process_status = '처리완료'
+      and process_status = #{doneStatus}
     </if>
     <if test="status != null and status == 'PENDING'">
-      and (process_status is null or process_status = '' or process_status &lt;&gt; '처리완료')
+      and (process_status is null or process_status = '' or process_status &lt;&gt; #{doneStatus})
     </if>
   </sql>
 
@@ -145,7 +145,7 @@
       process_status   = #{processStatus},
       web_edited_at    = now(),
       processed_at     = case
-                            when #{processStatus} = '처리완료' and process_item.processed_at is null then now()
+                            when #{processStatus} = #{doneStatus} and process_item.processed_at is null then now()
                             else process_item.processed_at
                           end,
       updated_at       = now()
@@ -173,7 +173,7 @@
       process_status   = coalesce(#{processStatus}, process_status),
       judged_kogl_type = coalesce(#{judgedKoglType}, judged_kogl_type),
       final_opinion    = coalesce(#{finalOpinion}, final_opinion),
-      processed_at     = case when #{processStatus} = '처리완료' then now() else processed_at end,
+      processed_at     = case when #{processStatus} = #{doneStatus} then now() else processed_at end,
       web_edited_at    = now(),
       updated_at       = now()
     where org_id = #{orgId}
@@ -191,7 +191,7 @@
   <select id="stats" resultMap="statsResultMap">
     select
       count(*) as total,
-      count(*) filter (where process_status = '처리완료') as done
+      count(*) filter (where process_status = #{doneStatus}) as done
     from process_item
     where org_id = #{orgId}
   </select>
 
src/test/java/kr/itn/itnhub/process/ProcessDoneStatusTest.java (added)
+++ src/test/java/kr/itn/itnhub/process/ProcessDoneStatusTest.java
@@ -0,0 +1,110 @@
+package kr.itn.itnhub.process;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.code.CodeService;
+import kr.itn.itnhub.org.Organization;
+import kr.itn.itnhub.org.OrganizationMapper;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * "어느 값이 처리완료인가"가 코드표에서 온다는 것을 확인한다.
+ *
+ * <p>쿼리에 '처리완료'가 박혀 있던 시절에는 코드표를 바꿔도 집계가 그대로였다. 이 테스트는
+ * 코드표의 done 속성을 옮기면 진행률도 따라 움직이는지를 본다 - 파라미터화가 형식만 바뀐 게
+ * 아니라 실제로 동작하는지를 증명하는 자리다.</p>
+ */
+class ProcessDoneStatusTest extends AbstractDbTest {
+
+    @Autowired
+    ProcessService service;
+
+    @Autowired
+    OrganizationMapper orgMapper;
+
+    @Autowired
+    CodeService codeService;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    private Long orgId;
+
+    @BeforeEach
+    void setUp() {
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '910'");
+
+        Organization org = new Organization();
+        org.setOrgNo("910");
+        org.setOrgName("처리상태테스트기관");
+        org.setChannelSlug("910");
+        orgMapper.upsertBySeed(org);
+        orgId = orgMapper.findByOrgNoAndOrgName("910", "처리상태테스트기관").getId();
+
+        insert(1, "처리완료");
+        insert(2, "처리완료");
+        insert(3, "미처리");
+    }
+
+    @AfterEach
+    void restore() {
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        jdbc.update("delete from process_item");
+        jdbc.update("delete from organization where org_no = '910'");
+    }
+
+    private void insert(int seq, String status) {
+        jdbc.update("insert into process_item (org_id, seq, process_status) values (?, ?, ?)",
+                orgId, seq, status);
+    }
+
+    @Test
+    void 기본_설정에서는_처리완료가_완료로_집계된다() {
+        ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30);
+
+        assertThat(page.total()).isEqualTo(3);
+        assertThat(page.done()).isEqualTo(2);
+    }
+
+    @Test
+    void 코드표에서_done을_옮기면_집계도_따라_움직인다() {
+        // '미처리'를 완료로, '처리완료'를 미완료로 뒤집는다. 쿼리에 값이 박혀 있었다면
+        // 아무 변화도 없었을 것이다.
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        ProcessPage page = service.list(orgId, null, null, null, null, null, 0, 30);
+
+        assertThat(page.total()).isEqualTo(3);
+        assertThat(page.done()).isEqualTo(1);
+    }
+
+    @Test
+    void 상태_필터도_코드표를_따른다() {
+        assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(2);
+        assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(1);
+
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'false') "
+                + "where group_id = 'PROCESS_STATUS' and code = '처리완료'");
+        jdbc.update("update code set attrs = jsonb_set(attrs, '{done}', 'true') "
+                + "where group_id = 'PROCESS_STATUS' and code = '미처리'");
+        codeService.reload();
+
+        assertThat(service.list(orgId, null, null, null, null, "DONE", 0, 30).items()).hasSize(1);
+        assertThat(service.list(orgId, null, null, null, null, "PENDING", 0, 30).items()).hasSize(2);
+    }
+}
Add a comment
List