이호영 이호영 07-28
feat: 코드 도메인·캐시 서비스·조회 API 추가
CodeService가 기동 후 첫 조회에 코드를 한 번 읽어 메모리에 들고 있는다. 지연 로딩인
이유는 Flyway 마이그레이션보다 빈 초기화가 먼저 돌 수 있기 때문이다.

활성 목록과 전체 목록을 나눠 들고 있는다. 화면 선택지에는 활성만 나가지만, 이미
저장된 과거 값의 라벨을 그리려면 비활성도 조회돼야 한다.

GET /api/meta/codes는 아직 소비자가 없다. 화면 전환은 Phase 2다.

Co-Authored-By: Claude Opus 5 (1M context) 
@bc077ed9588946d201348cc9f6ddfad3acfd53b9
 
src/main/java/kr/itn/itnhub/code/Code.java (added)
+++ src/main/java/kr/itn/itnhub/code/Code.java
@@ -0,0 +1,24 @@
+package kr.itn.itnhub.code;
+
+import java.util.Map;
+
+/** attrs까지 파싱된 코드. 화면과 서비스가 쓰는 형태다. */
+public record Code(
+        String groupId,
+        String code,
+        String label,
+        int sortOrder,
+        boolean active,
+        Map<String, Object> attrs) {
+
+    /** attrs의 문자열 속성. 없으면 null. */
+    public String attr(String key) {
+        Object value = attrs.get(key);
+        return value == null ? null : String.valueOf(value);
+    }
+
+    /** attrs의 불리언 속성. 없으면 false. */
+    public boolean flag(String key) {
+        return Boolean.TRUE.equals(attrs.get(key));
+    }
+}
 
src/main/java/kr/itn/itnhub/code/CodeController.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeController.java
@@ -0,0 +1,26 @@
+package kr.itn.itnhub.code;
+
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 프론트가 로그인 직후 한 번 불러 모든 선택지를 받아가는 엔드포인트. 화면마다 따로 부르지
+ * 않는 이유는 코드가 자주 안 바뀌고, 화면이 옵션을 기다리느라 깜빡이는 것을 피하기 위해서다.
+ */
+@RestController
+public class CodeController {
+
+    private final CodeService codeService;
+
+    public CodeController(CodeService codeService) {
+        this.codeService = codeService;
+    }
+
+    @GetMapping("/api/meta/codes")
+    public Map<String, List<Code>> codes() {
+        return codeService.all();
+    }
+}
 
src/main/java/kr/itn/itnhub/code/CodeGroup.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeGroup.java
@@ -0,0 +1,12 @@
+package kr.itn.itnhub.code;
+
+/**
+ * 코드 그룹. {@code editable}이 false면 관리 화면에서 코드를 추가·삭제할 수 없다 -
+ * 진행단계처럼 로직과 강하게 묶인 그룹은 표시명·순서만 바꾸게 한다.
+ */
+public record CodeGroup(
+        String groupId,
+        String groupName,
+        String description,
+        boolean editable) {
+}
 
src/main/java/kr/itn/itnhub/code/CodeMapper.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeMapper.java
@@ -0,0 +1,14 @@
+package kr.itn.itnhub.code;
+
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+@Mapper
+public interface CodeMapper {
+
+    /** 비활성 코드까지 전부. 활성 필터는 {@link CodeService}가 용도에 따라 건다. */
+    List<CodeRow> findAllCodes();
+
+    List<CodeGroup> findAllGroups();
+}
 
src/main/java/kr/itn/itnhub/code/CodeRow.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeRow.java
@@ -0,0 +1,15 @@
+package kr.itn.itnhub.code;
+
+/**
+ * DB에서 그대로 읽은 코드 한 줄. attrs는 jsonb를 문자열로 받는다 - 커스텀 TypeHandler를
+ * 만들면 매퍼마다 등록을 신경써야 하는데, 코드는 기동 후 한 번만 읽으므로
+ * {@link CodeService}가 그때 한 번 파싱하는 편이 단순하다.
+ */
+public record CodeRow(
+        String groupId,
+        String code,
+        String label,
+        int sortOrder,
+        boolean active,
+        String attrsJson) {
+}
 
src/main/java/kr/itn/itnhub/code/CodeService.java (added)
+++ src/main/java/kr/itn/itnhub/code/CodeService.java
@@ -0,0 +1,115 @@
+package kr.itn.itnhub.code;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+/**
+ * 코드값을 기동 후 첫 조회에 한 번 읽어 메모리에 들고 있는다. 코드는 관리 화면에서 가끔
+ * 바뀔 뿐이라 매 요청 조회가 낭비이고, 단일 인스턴스 운영이라 분산 캐시는 필요 없다.
+ *
+ * <p>지연 로딩을 쓰는 이유는 Flyway 마이그레이션이 끝나기 전에 빈 초기화가 돌 수 있기
+ * 때문이다. 첫 조회 시점에는 마이그레이션이 반드시 끝나 있다.</p>
+ */
+@Service
+public class CodeService {
+
+    private static final Logger log = LoggerFactory.getLogger(CodeService.class);
+
+    private final CodeMapper mapper;
+    private final ObjectMapper json = new ObjectMapper();
+
+    /** 그룹ID -> 활성 코드(정렬순). 화면 선택지가 여기서 나간다. */
+    private volatile Map<String, List<Code>> activeByGroup;
+    /** 그룹ID -> 코드ID -> 코드(비활성 포함). 과거 데이터를 화면에 그릴 때 라벨이 필요하다. */
+    private volatile Map<String, Map<String, Code>> allByGroup;
+
+    public CodeService(CodeMapper mapper) {
+        this.mapper = mapper;
+    }
+
+    /** 관리 화면에서 코드를 고친 뒤 부른다. */
+    public synchronized void reload() {
+        Map<String, List<Code>> active = new LinkedHashMap<>();
+        Map<String, Map<String, Code>> all = new LinkedHashMap<>();
+
+        for (CodeRow row : mapper.findAllCodes()) {
+            Code code = toCode(row);
+            all.computeIfAbsent(code.groupId(), key -> new LinkedHashMap<>())
+                    .put(code.code(), code);
+            if (code.active()) {
+                active.computeIfAbsent(code.groupId(), key -> new ArrayList<>()).add(code);
+            }
+        }
+
+        this.activeByGroup = active;
+        this.allByGroup = all;
+    }
+
+    private Code toCode(CodeRow row) {
+        Map<String, Object> attrs;
+        try {
+            attrs = json.readValue(row.attrsJson(), new TypeReference<>() {
+            });
+        } catch (Exception e) {
+            // 잘못된 JSON 하나가 전체 코드 로딩을 막지는 않게 한다. 해당 코드만 속성이 빈다.
+            log.warn("코드 attrs 파싱 실패 - {}:{} 의 속성을 비운다", row.groupId(), row.code(), e);
+            attrs = Map.of();
+        }
+        return new Code(row.groupId(), row.code(), row.label(), row.sortOrder(), row.active(), attrs);
+    }
+
+    private void ensureLoaded() {
+        if (activeByGroup == null) {
+            reload();
+        }
+    }
+
+    /** 활성 코드만, 그룹별 정렬순. */
+    public Map<String, List<Code>> all() {
+        ensureLoaded();
+        return activeByGroup;
+    }
+
+    /** 활성 코드만. 없는 그룹이면 빈 목록. */
+    public List<Code> group(String groupId) {
+        ensureLoaded();
+        return activeByGroup.getOrDefault(groupId, List.of());
+    }
+
+    /** 비활성 코드도 찾는다 - 과거 데이터의 라벨을 그려야 하기 때문이다. */
+    public Optional<Code> find(String groupId, String code) {
+        ensureLoaded();
+        return Optional.ofNullable(allByGroup.getOrDefault(groupId, Map.of()).get(code));
+    }
+
+    /** attrs.scopes에 해당 자리가 든 활성 코드만. 공공누리 유형이 이걸 쓴다. */
+    public List<Code> scoped(String groupId, String scope) {
+        return group(groupId).stream().filter(code -> scopes(code).contains(scope)).toList();
+    }
+
+    @SuppressWarnings("unchecked")
+    private List<String> scopes(Code code) {
+        Object raw = code.attrs().get(Codes.ATTR_SCOPES);
+        return raw instanceof List<?> list ? (List<String>) list : Collections.emptyList();
+    }
+
+    /** 저장 요청 값이 이 그룹의 활성 코드인지. */
+    public boolean isValid(String groupId, String code) {
+        return group(groupId).stream().anyMatch(candidate -> candidate.code().equals(code));
+    }
+
+    /** 코드 관리 화면이 쓰는 그룹 메타. 캐시하지 않는다 - 호출 빈도가 낮다. */
+    public List<CodeGroup> groups() {
+        return mapper.findAllGroups();
+    }
+}
 
src/main/java/kr/itn/itnhub/code/Codes.java (added)
+++ src/main/java/kr/itn/itnhub/code/Codes.java
@@ -0,0 +1,39 @@
+package kr.itn.itnhub.code;
+
+/**
+ * 코드 그룹 ID와, 로직이 직접 참조해야 하는 attrs 키·scope 상수. 소스에 한글 리터럴을
+ * 흩뿌리지 않기 위한 단일 지점이다.
+ *
+ * <p>화면에 그리는 선택지 자체는 여기가 아니라 DB(code 테이블)에서 온다. 여기 있는 것은
+ * "코드체계를 가리키는 이름"뿐이고 "코드값 목록"이 아니다.</p>
+ */
+public final class Codes {
+
+    private Codes() {
+    }
+
+    public static final String STAGE = "STAGE";
+    public static final String STAGE_GROUP = "STAGE_GROUP";
+    public static final String REVIEW_MAJOR = "REVIEW_MAJOR";
+    public static final String REVIEW_MINOR = "REVIEW_MINOR";
+    public static final String REVIEW_RESULT = "REVIEW_RESULT";
+    public static final String KOGL_TYPE = "KOGL_TYPE";
+    public static final String PROCESS_STATUS = "PROCESS_STATUS";
+    public static final String CONTRACT_DOC = "CONTRACT_DOC";
+    public static final String CONTACT_METHOD = "CONTACT_METHOD";
+    public static final String CONTACT_CATEGORY = "CONTACT_CATEGORY";
+    public static final String ATTACHMENT_YN = "ATTACHMENT_YN";
+    public static final String KOGL_ATTACHED = "KOGL_ATTACHED";
+
+    /** 공공누리 유형이 쓰이는 자리. 같은 코드체계라도 자리마다 노출 목록이 다르다. */
+    public static final String SCOPE_REVIEW = "review";
+    public static final String SCOPE_PROCESS = "process";
+    public static final String SCOPE_PRIOR = "prior";
+
+    /** attrs 키. */
+    public static final String ATTR_SCOPES = "scopes";
+    public static final String ATTR_PROGRESS = "progress";
+    public static final String ATTR_KPI_FROM = "kpiFrom";
+    public static final String ATTR_GROUP_KEY = "groupKey";
+    public static final String ATTR_DONE = "done";
+}
 
src/main/resources/mapper/CodeMapper.xml (added)
+++ src/main/resources/mapper/CodeMapper.xml
@@ -0,0 +1,22 @@
+<?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.code.CodeMapper">
+
+  <!--
+    attrs는 jsonb지만 ::text로 문자열로 받는다. 커스텀 TypeHandler를 두면 매퍼마다
+    등록을 신경써야 하는데, 코드는 기동 후 한 번만 읽으므로 서비스에서 파싱하는 편이 단순하다.
+  -->
+  <select id="findAllCodes" resultType="kr.itn.itnhub.code.CodeRow">
+    select group_id, code, label, sort_order, active, attrs::text as attrs_json
+    from code
+    order by group_id, sort_order
+  </select>
+
+  <select id="findAllGroups" resultType="kr.itn.itnhub.code.CodeGroup">
+    select group_id, group_name, description, editable
+    from code_group
+    order by group_id
+  </select>
+
+</mapper>
 
src/test/java/kr/itn/itnhub/code/CodeControllerTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeControllerTest.java
@@ -0,0 +1,44 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import kr.itn.itnhub.mattermost.MattermostClient;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.mock.mockito.MockBean;
+import org.springframework.security.test.context.support.WithMockUser;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+@AutoConfigureMockMvc
+class CodeControllerTest extends AbstractDbTest {
+
+    @Autowired
+    MockMvc mvc;
+
+    @MockBean
+    MattermostClient mattermost;
+
+    @Test
+    @WithMockUser(roles = "ADMIN")
+    void 전체_코드를_그룹별로_내려준다() throws Exception {
+        mvc.perform(get("/api/meta/codes"))
+                .andExpect(status().isOk())
+                .andExpect(jsonPath("$.STAGE.length()").value(13))
+                .andExpect(jsonPath("$.STAGE[12].code").value("13"))
+                .andExpect(jsonPath("$.STAGE[12].label").value("보고서 제출 완료"))
+                .andExpect(jsonPath("$.STAGE[12].attrs.progress").value("done"))
+                .andExpect(jsonPath("$.KOGL_TYPE.length()").value(7))
+                .andExpect(jsonPath("$.CONTACT_CATEGORY[0].code").value("APPLICANT"))
+                .andExpect(jsonPath("$.CONTACT_CATEGORY[0].label").value("신청기관"));
+    }
+
+    @Test
+    void 인증이_없으면_401이다() throws Exception {
+        mvc.perform(get("/api/meta/codes"))
+                .andExpect(status().isUnauthorized());
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeMapperTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeMapperTest.java
@@ -0,0 +1,63 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CodeMapperTest extends AbstractDbTest {
+
+    @Autowired
+    CodeMapper mapper;
+
+    @Test
+    void 모든_코드를_그룹과_정렬순서대로_읽는다() {
+        List<CodeRow> rows = mapper.findAllCodes();
+
+        assertThat(rows).isNotEmpty();
+        List<CodeRow> stages = rows.stream().filter(r -> r.groupId().equals("STAGE")).toList();
+        assertThat(stages).hasSize(13);
+        assertThat(stages.get(0).code()).isEqualTo("1");
+        assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료");
+    }
+
+    @Test
+    void attrs를_JSON_문자열로_읽는다() {
+        CodeRow stage13 = mapper.findAllCodes().stream()
+                .filter(r -> r.groupId().equals("STAGE") && r.code().equals("13"))
+                .findFirst().orElseThrow();
+
+        assertThat(stage13.attrsJson())
+                .contains("progress")
+                .contains("done")
+                .contains("completed");
+    }
+
+    @Test
+    void 비활성_코드도_함께_읽는다() {
+        // 활성 필터는 CodeService가 용도에 따라 건다. 매퍼는 전부 내려줘야
+        // 과거 데이터의 라벨을 그릴 수 있다.
+        List<CodeRow> rows = mapper.findAllCodes();
+
+        assertThat(rows).allSatisfy(r -> assertThat(r.groupId()).isNotBlank());
+        assertThat(rows).anySatisfy(r -> assertThat(r.active()).isTrue());
+    }
+
+    @Test
+    void 그룹_목록을_읽는다() {
+        List<CodeGroup> groups = mapper.findAllGroups();
+
+        assertThat(groups).hasSize(12);
+        assertThat(groups).anySatisfy(g -> {
+            assertThat(g.groupId()).isEqualTo("STAGE");
+            assertThat(g.editable()).isFalse();
+        });
+        assertThat(groups).anySatisfy(g -> {
+            assertThat(g.groupId()).isEqualTo("CONTACT_METHOD");
+            assertThat(g.editable()).isTrue();
+        });
+    }
+}
 
src/test/java/kr/itn/itnhub/code/CodeServiceTest.java (added)
+++ src/test/java/kr/itn/itnhub/code/CodeServiceTest.java
@@ -0,0 +1,119 @@
+package kr.itn.itnhub.code;
+
+import kr.itn.itnhub.AbstractDbTest;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class CodeServiceTest extends AbstractDbTest {
+
+    @Autowired
+    CodeService service;
+
+    @Autowired
+    JdbcTemplate jdbc;
+
+    @AfterEach
+    void restore() {
+        jdbc.update("update code set active = true where group_id = 'CONTACT_METHOD'");
+        service.reload();
+    }
+
+    @Test
+    void 그룹을_정렬순서대로_돌려준다() {
+        List<Code> stages = service.group(Codes.STAGE);
+
+        assertThat(stages).hasSize(13);
+        assertThat(stages.get(0).code()).isEqualTo("1");
+        assertThat(stages.get(12).label()).isEqualTo("보고서 제출 완료");
+    }
+
+    @Test
+    void attrs가_맵으로_파싱된다() {
+        Code stage13 = service.find(Codes.STAGE, "13").orElseThrow();
+
+        assertThat(stage13.attr("progress")).isEqualTo("done");
+        assertThat(stage13.attr("kpiFrom")).isEqualTo("completed");
+        assertThat(stage13.attr("groupKey")).isEqualTo("closing");
+        assertThat(stage13.attr("없는속성")).isNull();
+    }
+
+    @Test
+    void 불리언_속성을_읽는다() {
+        assertThat(service.find(Codes.PROCESS_STATUS, "처리완료").orElseThrow().flag("done")).isTrue();
+        assertThat(service.find(Codes.PROCESS_STATUS, "미처리").orElseThrow().flag("done")).isFalse();
+        // 속성이 아예 없는 코드도 false여야 한다.
+        assertThat(service.find(Codes.CONTRACT_DOC, "공문").orElseThrow().flag("freeText")).isFalse();
+        assertThat(service.find(Codes.CONTRACT_DOC, "기타").orElseThrow().flag("freeText")).isTrue();
+    }
+
+    @Test
+    void 사용처별로_공공누리_유형을_거른다() {
+        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_REVIEW)).hasSize(7);
+        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PROCESS))
+                .hasSize(6)
+                .noneMatch(c -> c.code().equals("보류"));
+        assertThat(service.scoped(Codes.KOGL_TYPE, Codes.SCOPE_PRIOR))
+                .extracting(Code::code)
+                .containsExactly("0유형", "1유형", "2유형", "3유형", "4유형");
+    }
+
+    @Test
+    void scopes가_없는_그룹은_빈_결과다() {
+        assertThat(service.scoped(Codes.CONTACT_METHOD, Codes.SCOPE_REVIEW)).isEmpty();
+    }
+
+    @Test
+    void 비활성_코드는_목록에서_빠지지만_조회는_된다() {
+        jdbc.update("update code set active = false "
+                + "where group_id = 'CONTACT_METHOD' and code = '방문'");
+        service.reload();
+
+        assertThat(service.group(Codes.CONTACT_METHOD))
+                .extracting(Code::code)
+                .containsExactly("전화", "메일", "기타");
+        // 이미 '방문'으로 저장된 과거 데이터를 화면에 그리려면 조회는 되어야 한다.
+        assertThat(service.find(Codes.CONTACT_METHOD, "방문")).isPresent();
+    }
+
+    @Test
+    void 유효성_검사는_활성_코드만_통과시킨다() {
+        assertThat(service.isValid(Codes.PROCESS_STATUS, "처리완료")).isTrue();
+        assertThat(service.isValid(Codes.PROCESS_STATUS, "없는값")).isFalse();
+
+        jdbc.update("update code set active = false "
+                + "where group_id = 'CONTACT_METHOD' and code = '방문'");
+        service.reload();
+
+        assertThat(service.isValid(Codes.CONTACT_METHOD, "방문")).isFalse();
+    }
+
+    @Test
+    void 전체_조회는_활성_코드만_그룹별로_담는다() {
+        assertThat(service.all())
+                .containsKeys(Codes.STAGE, Codes.KOGL_TYPE, Codes.CONTACT_CATEGORY)
+                .hasSize(12);
+    }
+
+    @Test
+    void 없는_그룹은_빈_목록이고_없는_코드는_빈_결과다() {
+        assertThat(service.group("NO_SUCH_GROUP")).isEmpty();
+        assertThat(service.find("NO_SUCH_GROUP", "A")).isEmpty();
+        assertThat(service.find(Codes.STAGE, "999")).isEmpty();
+    }
+
+    @Test
+    void 그룹_메타를_돌려준다() {
+        assertThat(service.groups())
+                .hasSize(12)
+                .anySatisfy(g -> {
+                    assertThat(g.groupId()).isEqualTo("STAGE");
+                    assertThat(g.groupName()).isEqualTo("진행단계");
+                });
+    }
+}
Add a comment
List