feat: 데이터 도메인 사전 + 데이터 사전 시스템 (V20)

3 agents 병렬 작업 (dba / api / frontend) 결과물 + SQL 버그 2건 fix.

DB (V20__데이터사전.sql):
- data_domain (domain_code PK, category, data_type, length/precision/scale,
  format_pattern, validation_regex, masking_type, encrypted, ...)
- data_dictionary (dict_id PK, table_name, column_name, domain_code FK,
  column_label, is_pk/is_fk/fk_reference, business_rule, sample_value, ...)
- VIEW v_data_dictionary_full: information_schema + pg_description +
  data_dictionary + data_domain 4-way LEFT JOIN
  → ga 스키마 모든 컬럼 자동 노출 (사전 미등록 컬럼도 회색 표시)
- 도메인 30개 초기 데이터 (IDENTIFIER 7 / PII 6 / MONEY 5 / RATE 3 /
  DATE 4 / CODE 4 / TEXT 3 / JSON 2)
- 사전 매핑 50개 (agent 10 / contract 7 / recruit_ledger 9 /
  settle_master 9 / payment 6)
- 메뉴 SYSTEM_DATA_DOMAIN, SYSTEM_DATA_DICT 등록 + 권한

Backend:
- ga-core VO 9개 + Mapper 2개 + XML 2개
- ga-api Service 2 (도메인 삭제 시 사전 참조 검증) + Controller 2
- 엔드포인트:
  · /api/data-domains (CRUD + /options)
  · /api/data-dictionary (CRUD + /tables + /full?tableName=)

Frontend:
- api/dict.ts (13 endpoints)
- pages/system/DataDomainList.tsx — 카테고리 색상 Tag, 마스킹/암호화 표시,
  ModalForm 등록/수정/삭제
- pages/system/DataDictionary.tsx — 좌(테이블 목록 + prefix 그룹핑) +
  우(v_data_dictionary_full 조회, 사전 미등록 컬럼은 회색+점선 추가 버튼)
- App.tsx + MenuIcon.tsx 매핑 추가

Bug fix:
- DataDomainMapper: WHERE is_active = true → 'Y' (CHAR vs boolean)
- DataDictionaryMapper.selectFromView: ORDER BY sort_order 제거
  (view 에 없는 컬럼)

검증:
- ./gradlew :ga-api:bootJar 통과
- Flyway V20 운영 DB 적용 (17초)
- 4 엔드포인트 모두 200 응답
- agent 테이블 15컬럼 사전 자동 노출 + 도메인 매핑 정상

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
GA Pro
2026-05-11 00:29:13 +09:00
parent 2136b38e1b
commit cece084631
24 changed files with 2268 additions and 1 deletions
@@ -0,0 +1,37 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDictionaryFullResp;
import com.ga.core.vo.dict.DataDictionaryResp;
import com.ga.core.vo.dict.DataDictionarySearchParam;
import com.ga.core.vo.dict.DataDictionaryVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDictionaryMapper {
/** 목록 조회 (data_dictionary + data_domain 조인, 페이징) */
List<DataDictionaryResp> selectList(DataDictionarySearchParam param);
/** 특정 테이블의 모든 컬럼 (sort_order 순, LEFT JOIN data_domain) */
List<DataDictionaryResp> selectByTable(@Param("tableName") String tableName);
/** VIEW v_data_dictionary_full 조회. tableName null 이면 전체 */
List<DataDictionaryFullResp> selectFromView(@Param("tableName") String tableName);
/** 사전에 등록된 distinct table_name 리스트 */
List<String> selectTables();
DataDictionaryResp selectById(@Param("dictId") Long dictId);
int insert(DataDictionaryVO vo);
int update(DataDictionaryVO vo);
int delete(@Param("dictId") Long dictId);
/** domain_code 존재 여부 확인 */
int countDomainByCode(@Param("domainCode") String domainCode);
}
@@ -0,0 +1,30 @@
package com.ga.core.mapper.dict;
import com.ga.core.vo.dict.DataDomainOptionResp;
import com.ga.core.vo.dict.DataDomainResp;
import com.ga.core.vo.dict.DataDomainSearchParam;
import com.ga.core.vo.dict.DataDomainVO;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface DataDomainMapper {
List<DataDomainResp> selectList(DataDomainSearchParam param);
DataDomainResp selectByCode(@Param("domainCode") String domainCode);
/** 드롭다운 Select 옵션용 전체 목록 (활성 도메인) */
List<DataDomainOptionResp> selectAll();
int insert(DataDomainVO vo);
int update(DataDomainVO vo);
int delete(@Param("domainCode") String domainCode);
/** data_dictionary 에서 해당 domainCode 를 참조하는 컬럼 수 */
int countUsageByCode(@Param("domainCode") String domainCode);
}
@@ -0,0 +1,42 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* VIEW v_data_dictionary_full 조회 결과.
* data_dictionary + data_domain + information_schema.COLUMNS 결합.
*/
@Data
public class DataDictionaryFullResp {
// data_dictionary
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
// data_domain
private String domainName;
private String domainCategory;
private String maskingType;
private Boolean encrypted;
// information_schema.COLUMNS
private String physicalDataType;
private Long physicalLength;
private Integer physicalPrecision;
private Integer physicalScale;
private String physicalNullable;
private String physicalDefault;
private String physicalComment;
private Long ordinalPosition;
}
@@ -0,0 +1,39 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
/**
* data_dictionary + data_domain 조인 결과
*/
@Data
public class DataDictionaryResp {
// data_dictionary 컬럼
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
// data_domain 조인 컬럼
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private String maskingType;
private Boolean encrypted;
}
@@ -0,0 +1,63 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDictionarySaveReq {
@NotBlank(message = "테이블명은 필수입니다")
@Size(max = 100)
private String tableName;
@NotBlank(message = "컬럼명은 필수입니다")
@Size(max = 100)
private String columnName;
@Size(max = 50)
private String domainCode;
@Size(max = 200)
private String columnLabel;
@Size(max = 500)
private String description;
private Boolean isPk;
private Boolean isFk;
@Size(max = 200)
private String fkReference;
private Boolean isRequired;
@Size(max = 200)
private String defaultValue;
@Size(max = 1000)
private String businessRule;
@Size(max = 200)
private String sampleValue;
private Integer sortOrder;
public DataDictionaryVO toVO() {
DataDictionaryVO vo = new DataDictionaryVO();
vo.setTableName(tableName);
vo.setColumnName(columnName);
vo.setDomainCode(domainCode);
vo.setColumnLabel(columnLabel);
vo.setDescription(description);
vo.setIsPk(isPk != null ? isPk : Boolean.FALSE);
vo.setIsFk(isFk != null ? isFk : Boolean.FALSE);
vo.setFkReference(fkReference);
vo.setIsRequired(isRequired != null ? isRequired : Boolean.FALSE);
vo.setDefaultValue(defaultValue);
vo.setBusinessRule(businessRule);
vo.setSampleValue(sampleValue);
vo.setSortOrder(sortOrder != null ? sortOrder : 0);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDictionarySearchParam extends SearchParam {
private String tableName;
private String domainCode;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDictionaryVO {
private Long dictId;
private String tableName;
private String columnName;
private String domainCode;
private String columnLabel;
private String description;
private Boolean isPk;
private Boolean isFk;
private String fkReference;
private Boolean isRequired;
private String defaultValue;
private String businessRule;
private String sampleValue;
private Integer sortOrder;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,13 @@
package com.ga.core.vo.dict;
import lombok.Data;
/**
* 드롭다운 Select 옵션용 경량 VO (selectAll 전용)
*/
@Data
public class DataDomainOptionResp {
private String domainCode;
private String domainName;
private String domainCategory;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainResp {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,67 @@
package com.ga.core.vo.dict;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Data
public class DataDomainSaveReq {
@NotBlank(message = "도메인코드는 필수입니다")
@Size(max = 50)
private String domainCode;
@NotBlank(message = "도메인명은 필수입니다")
@Size(max = 100)
private String domainName;
@NotBlank(message = "도메인 분류는 필수입니다")
@Size(max = 50)
private String domainCategory;
@NotBlank(message = "데이터 타입은 필수입니다")
@Size(max = 30)
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
@Size(max = 100)
private String formatPattern;
@Size(max = 500)
private String validationRegex;
@Size(max = 200)
private String exampleValue;
@Size(max = 30)
private String maskingType;
private Boolean encrypted;
@Size(max = 500)
private String description;
private Boolean isActive;
public DataDomainVO toVO() {
DataDomainVO vo = new DataDomainVO();
vo.setDomainCode(domainCode);
vo.setDomainName(domainName);
vo.setDomainCategory(domainCategory);
vo.setDataType(dataType);
vo.setLength(length);
vo.setPrecision(precision);
vo.setScale(scale);
vo.setFormatPattern(formatPattern);
vo.setValidationRegex(validationRegex);
vo.setExampleValue(exampleValue);
vo.setMaskingType(maskingType);
vo.setEncrypted(encrypted != null ? encrypted : Boolean.FALSE);
vo.setDescription(description);
vo.setIsActive(isActive != null ? isActive : Boolean.TRUE);
return vo;
}
}
@@ -0,0 +1,12 @@
package com.ga.core.vo.dict;
import com.ga.common.model.SearchParam;
import lombok.Data;
import lombok.EqualsAndHashCode;
@Data
@EqualsAndHashCode(callSuper = true)
public class DataDomainSearchParam extends SearchParam {
private String domainCategory;
private Boolean isActive;
}
@@ -0,0 +1,27 @@
package com.ga.core.vo.dict;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class DataDomainVO {
private String domainCode;
private String domainName;
private String domainCategory;
private String dataType;
private Integer length;
private Integer precision;
private Integer scale;
private String formatPattern;
private String validationRegex;
private String exampleValue;
private String maskingType;
private Boolean encrypted;
private String description;
private Boolean isActive;
private LocalDateTime createdAt;
private String createdBy;
private LocalDateTime updatedAt;
private String updatedBy;
}
@@ -0,0 +1,167 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.dict.DataDictionaryMapper">
<resultMap id="DataDictionaryRespMap" type="com.ga.core.vo.dict.DataDictionaryResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<result property="createdAt" column="created_at"/>
<result property="createdBy" column="created_by"/>
<result property="updatedAt" column="updated_at"/>
<result property="updatedBy" column="updated_by"/>
<!-- data_domain join -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="dataType" column="data_type"/>
<result property="length" column="length"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
</resultMap>
<resultMap id="DataDictionaryFullRespMap" type="com.ga.core.vo.dict.DataDictionaryFullResp">
<id property="dictId" column="dict_id"/>
<result property="tableName" column="table_name"/>
<result property="columnName" column="column_name"/>
<result property="domainCode" column="domain_code"/>
<result property="columnLabel" column="column_label"/>
<result property="description" column="description"/>
<result property="isPk" column="is_pk"/>
<result property="isFk" column="is_fk"/>
<result property="fkReference" column="fk_reference"/>
<result property="isRequired" column="is_required"/>
<result property="defaultValue" column="default_value"/>
<result property="businessRule" column="business_rule"/>
<result property="sampleValue" column="sample_value"/>
<result property="sortOrder" column="sort_order"/>
<!-- data_domain -->
<result property="domainName" column="domain_name"/>
<result property="domainCategory" column="domain_category"/>
<result property="maskingType" column="masking_type"/>
<result property="encrypted" column="encrypted"/>
<!-- information_schema -->
<result property="physicalDataType" column="physical_data_type"/>
<result property="physicalLength" column="physical_length"/>
<result property="physicalPrecision" column="physical_precision"/>
<result property="physicalScale" column="physical_scale"/>
<result property="physicalNullable" column="physical_nullable"/>
<result property="physicalDefault" column="physical_default"/>
<result property="physicalComment" column="physical_comment"/>
<result property="ordinalPosition" column="ordinal_position"/>
</resultMap>
<sql id="dictDomainJoinCols">
d.dict_id, d.table_name, d.column_name, d.domain_code,
d.column_label, d.description, d.is_pk, d.is_fk, d.fk_reference,
d.is_required, d.default_value, d.business_rule, d.sample_value, d.sort_order,
d.created_at, d.created_by, d.updated_at, d.updated_by,
dom.domain_name, dom.domain_category, dom.data_type,
dom.length, dom.masking_type, dom.encrypted
</sql>
<select id="selectList" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
<where>
<if test="tableName != null and tableName != ''">
AND d.table_name = #{tableName}
</if>
<if test="domainCode != null and domainCode != ''">
AND d.domain_code = #{domainCode}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (d.column_name LIKE CONCAT('%', #{searchKeyword}, '%')
OR d.column_label LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY d.table_name, d.sort_order, d.column_name
</select>
<!-- 특정 테이블의 모든 컬럼 (도메인 미등록 컬럼도 노출) -->
<select id="selectByTable" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.table_name = #{tableName}
ORDER BY d.sort_order, d.column_name
</select>
<!-- v_data_dictionary_full VIEW 조회. View 에는 sort_order 가 없음 -->
<select id="selectFromView" resultMap="DataDictionaryFullRespMap">
SELECT *
FROM v_data_dictionary_full
<where>
<if test="tableName != null and tableName != ''">
AND table_name = #{tableName}
</if>
</where>
ORDER BY table_name, ordinal_position
</select>
<select id="selectTables" resultType="string">
SELECT DISTINCT table_name
FROM data_dictionary
ORDER BY table_name
</select>
<select id="selectById" resultMap="DataDictionaryRespMap">
SELECT <include refid="dictDomainJoinCols"/>
FROM data_dictionary d
LEFT JOIN data_domain dom ON dom.domain_code = d.domain_code
WHERE d.dict_id = #{dictId}
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDictionaryVO" useGeneratedKeys="true" keyProperty="dictId">
INSERT INTO data_dictionary (
table_name, column_name, domain_code, column_label, description,
is_pk, is_fk, fk_reference, is_required, default_value,
business_rule, sample_value, sort_order, created_at, created_by
) VALUES (
#{tableName}, #{columnName}, #{domainCode}, #{columnLabel}, #{description},
#{isPk}, #{isFk}, #{fkReference}, #{isRequired}, #{defaultValue},
#{businessRule}, #{sampleValue}, #{sortOrder}, NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDictionaryVO">
UPDATE data_dictionary SET
table_name = #{tableName},
column_name = #{columnName},
domain_code = #{domainCode},
column_label = #{columnLabel},
description = #{description},
is_pk = #{isPk},
is_fk = #{isFk},
fk_reference = #{fkReference},
is_required = #{isRequired},
default_value = #{defaultValue},
business_rule = #{businessRule},
sample_value = #{sampleValue},
sort_order = #{sortOrder},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE dict_id = #{dictId}
</update>
<delete id="delete">
DELETE FROM data_dictionary WHERE dict_id = #{dictId}
</delete>
<select id="countDomainByCode" resultType="int">
SELECT COUNT(*) FROM data_domain WHERE domain_code = #{domainCode}
</select>
</mapper>
@@ -0,0 +1,89 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.ga.core.mapper.dict.DataDomainMapper">
<resultMap id="DataDomainRespMap" type="com.ga.core.vo.dict.DataDomainResp"/>
<resultMap id="DataDomainOptionMap" type="com.ga.core.vo.dict.DataDomainOptionResp"/>
<sql id="domainCols">
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by, updated_at, updated_by
</sql>
<select id="selectList" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
<where>
<if test="domainCategory != null and domainCategory != ''">
AND domain_category = #{domainCategory}
</if>
<if test="isActive != null">
AND is_active = #{isActive}
</if>
<if test="searchKeyword != null and searchKeyword != ''">
AND (domain_code LIKE CONCAT('%', #{searchKeyword}, '%')
OR domain_name LIKE CONCAT('%', #{searchKeyword}, '%'))
</if>
</where>
ORDER BY domain_code ASC
</select>
<select id="selectByCode" resultMap="DataDomainRespMap">
SELECT <include refid="domainCols"/>
FROM data_domain
WHERE domain_code = #{domainCode}
</select>
<select id="selectAll" resultMap="DataDomainOptionMap">
SELECT domain_code, domain_name, domain_category
FROM data_domain
WHERE is_active = 'Y'
ORDER BY domain_category, domain_name
</select>
<insert id="insert" parameterType="com.ga.core.vo.dict.DataDomainVO">
INSERT INTO data_domain (
domain_code, domain_name, domain_category, data_type,
length, precision, scale, format_pattern, validation_regex,
example_value, masking_type, encrypted, description, is_active,
created_at, created_by
) VALUES (
#{domainCode}, #{domainName}, #{domainCategory}, #{dataType},
#{length}, #{precision}, #{scale}, #{formatPattern}, #{validationRegex},
#{exampleValue}, #{maskingType}, #{encrypted}, #{description}, #{isActive},
NOW(), #{createdBy}
)
</insert>
<update id="update" parameterType="com.ga.core.vo.dict.DataDomainVO">
UPDATE data_domain SET
domain_name = #{domainName},
domain_category = #{domainCategory},
data_type = #{dataType},
length = #{length},
precision = #{precision},
scale = #{scale},
format_pattern = #{formatPattern},
validation_regex = #{validationRegex},
example_value = #{exampleValue},
masking_type = #{maskingType},
encrypted = #{encrypted},
description = #{description},
is_active = #{isActive},
updated_at = NOW(),
updated_by = #{updatedBy}
WHERE domain_code = #{domainCode}
</update>
<delete id="delete">
DELETE FROM data_domain WHERE domain_code = #{domainCode}
</delete>
<select id="countUsageByCode" resultType="int">
SELECT COUNT(*) FROM data_dictionary WHERE domain_code = #{domainCode}
</select>
</mapper>