diff --git a/docs/design/618-structured-data-browser/README.md b/docs/design/618-structured-data-browser/README.md new file mode 100644 index 0000000..0e30c90 --- /dev/null +++ b/docs/design/618-structured-data-browser/README.md @@ -0,0 +1,100 @@ +# 설계서: 정형 데이터 원장 조회 (#618) + +> **상태**: Implemented · QA passed · deployed +> **작성**: [AI] Architect · **최종수정**: 2026-07-07 +> **추적성** — Redmine: #618 · 관련 ADR: 없음 +> · 구현 파일: `StructuredDataController`, `StructuredDataService`, `structured-data.html` · 테스트: `StructuredDataServiceTest`, `GuidedFlowTemplateTest` + +## 1. 목적 (Why) + +VPD 권한 운영자가 KBAIPOC의 정형 보험 원장 데이터를 별도 SQL 도구 없이 빠르게 확인할 수 있게 한다. + +## 2. 범위 (Scope) + +- **포함**: `POC_2`의 고객·상품·계약·담보·청구·외부보유·이해관계자 7개 테이블 선택, 컬럼명, 최대 50건 읽기 전용 미리보기. +- **제외 (out of scope)**: 임의 SQL 실행, 데이터 수정·삭제, VPD 정책 자동 생성, RAG/약관/백업·시스템 테이블 표시. + +## 3. 인수조건 (Acceptance Criteria) + +- [x] 연동 도구 메뉴에서 정형 데이터 조회 화면으로 이동할 수 있다. +- [x] 지정된 7개 테이블만 선택할 수 있다. +- [x] 선택한 테이블의 컬럼 순서와 최대 50건의 행을 표시한다. +- [x] 허용 목록 밖의 table 파라미터는 SQL 문자열에 포함되지 않고 거부된다. +- [x] 데이터가 없거나 DB 조회가 실패해도 안전한 안내를 표시한다. + +## 4. 컨텍스트 & 제약 + +- 데이터 owner는 현재 KBAIPOC의 `POC_2`로 고정한다. +- 백오피스는 관리자용 읽기 화면이므로 페이지를 인증 뒤에만 제공한다. +- 고객 식별 정보는 현 POC의 `RRN_MASKED`처럼 이미 마스킹된 컬럼만 사용한다. 운영 데이터에 적용하기 전에는 별도 VPD/마스킹 정책을 연결한다. + +## 5. 아키텍처 개요 + +``` +브라우저 GET /structured-data?table=customers + ↓ +StructuredDataController + ↓ +StructuredDataService (7개 고정 allowlist) + ↓ +JdbcTemplate → POC_2.KB_CUSTOMERS (ROWNUM <= 50) + ↓ +Thymeleaf 표 미리보기 +``` + +사용자 입력은 allowlist key 선택에만 쓰고, DB owner·테이블명은 서버 상수에서만 가져온다. JDBC I/O는 서비스에 두고, 테이블 카탈로그 검증은 순수 메서드로 분리한다. + +## 6. 데이터 모델 + +| key | DB 테이블 | 업무명 | +|---|---|---| +| `customers` | `KB_CUSTOMERS` | 고객원장 / 고객 기본정보 | +| `products` | `KB_PRODUCTS` | 상품원장 / 보험상품 마스터 | +| `contracts` | `KB_CONTRACTS` | 계약원장 / 보험계약 정보 | +| `coverages` | `KB_COVERAGES` | 담보원장 / 보장·특약 정보 | +| `claims` | `KB_CLAIMS` | 청구원장 / 보험금 청구·지급 정보 | +| `external-holdings` | `KB_EXTERNAL_HOLDINGS` | 외부보유정보 원장 / 타사·외부 가입·보유 정보 | +| `stakeholders` | `KB_STAKEHOLDERS` | 이해관계자 원장 / 역할·담당자 매핑 | + +## 7. 함수 명세 (Function Specs) + +| 함수 | 책임 | 입력 | 출력 | 에러/실패 | 복잡? | +|---|---|---|---|---|---| +| `requireTable` | allowlist key를 테이블 정의로 해석 | key | table definition | 알 수 없는 key 거부 | 단순 | +| `preview` | 컬럼과 제한된 행을 조회 | key | preview view | DB 접근 오류를 안전한 메시지로 변환 | 단순 | + +## 8. 흐름 / 알고리즘 + +1. 기본값으로 고객원장을 선택한다. +2. 요청 key가 7개 allowlist에 있는지 검증한다. +3. `ALL_TAB_COLUMNS`에서 표시 컬럼 순서를 읽는다. +4. 고정 owner/table로 최대 50건을 조회한다. +5. 컬럼 헤더와 행을 표로 렌더링한다. + +## 9. 엣지케이스 & 에러 처리 + +- 알 수 없는 key: SQL을 만들지 않고 안내 메시지를 표시한다. +- 대상 테이블이 없거나 권한이 없음: DB 오류 원문 대신 대상/권한 점검 안내를 표시한다. +- 0건: 빈 결과 안내와 함께 컬럼 헤더는 유지한다. + +## 10. 테스트 계획 + +- 카탈로그가 정확히 7개 key와 테이블을 포함하는지 단위 테스트한다. +- 허용 목록 밖 key를 거부하는지 단위 테스트한다. +- 템플릿·메뉴가 정형 데이터 조회 경로와 읽기 전용 안내를 포함하는지 정적 템플릿 테스트한다. + +## 11. 리스크 & 대안 검토 + +- 관리자 JDBC로 직접 조회하므로 운영 개인정보를 그대로 보여줄 수 있다. 따라서 임의 SQL과 RAG/백업 테이블 노출을 제외하고, 현 POC에서는 마스킹된 고객 식별자만 있는 7개 원장으로 범위를 제한한다. +- 최종 사용자별 행 보기가 필요하면 이 페이지가 아니라 ORDS + VPD 검증 경로를 사용한다. + +## 12. 미해결 질문 (Open Questions) + +- 운영 전환 시 `POC_2` owner를 환경 설정으로 분리할지 결정한다. +- 행 단위 VPD 적용 결과를 이 화면에서 볼 필요가 생기면 검증 세션을 필수 입력으로 하는 별도 화면으로 분리한다. + +## 13. 구현·배포 검증 + +- `mvn test` 통과: allowlist 7개 테이블과 비허용 key 거부 단위 테스트, 메뉴·도움말 템플릿 검증을 포함한다. +- VM SQLcl에서 백오피스 계정으로 `POC_2.KB_CUSTOMERS` 110건을 읽을 수 있음을 확인했다. +- `vpd-backoffice.service`에 새 JAR를 배포한 뒤 인증된 `/structured-data?table=customers` 응답에서 `KB_CUSTOMERS`, `CUST_ID`, `고객원장` 렌더링을 확인했다. diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java new file mode 100644 index 0000000..246c2a8 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataPreview.java @@ -0,0 +1,12 @@ +package com.cloudhandson.vpdbackoffice.domain.structured; + +import java.util.List; +import java.util.Map; + +public record StructuredDataPreview( + StructuredDataTable table, + List columns, + List> rows, + int rowLimit +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java new file mode 100644 index 0000000..a247fc5 --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/domain/structured/StructuredDataTable.java @@ -0,0 +1,9 @@ +package com.cloudhandson.vpdbackoffice.domain.structured; + +public record StructuredDataTable( + String key, + String tableName, + String businessName, + String description +) { +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java new file mode 100644 index 0000000..6d98b8e --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/service/StructuredDataService.java @@ -0,0 +1,69 @@ +package com.cloudhandson.vpdbackoffice.service; + +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataPreview; +import com.cloudhandson.vpdbackoffice.domain.structured.StructuredDataTable; +import java.util.List; +import java.util.Map; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +@Service +public class StructuredDataService { + + private static final String OWNER = "POC_2"; + private static final int ROW_LIMIT = 50; + private static final List TABLES = List.of( + new StructuredDataTable("customers", "KB_CUSTOMERS", "고객원장", "고객 기본정보"), + new StructuredDataTable("products", "KB_PRODUCTS", "상품원장", "보험상품 마스터"), + new StructuredDataTable("contracts", "KB_CONTRACTS", "계약원장", "보험계약 정보"), + new StructuredDataTable("coverages", "KB_COVERAGES", "담보원장", "보장·특약 정보"), + new StructuredDataTable("claims", "KB_CLAIMS", "청구원장", "보험금 청구·지급 정보"), + new StructuredDataTable("external-holdings", "KB_EXTERNAL_HOLDINGS", "외부보유정보 원장", "타사·외부 가입·보유 정보"), + new StructuredDataTable("stakeholders", "KB_STAKEHOLDERS", "이해관계자 원장", "역할·담당자 매핑")); + + private final JdbcTemplate jdbcTemplate; + + public StructuredDataService(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public List tables() { + return TABLES; + } + + public String defaultKey() { + return TABLES.getFirst().key(); + } + + public StructuredDataTable requireTable(String key) { + return TABLES.stream() + .filter(table -> table.key().equals(key)) + .findFirst() + .orElseThrow(() -> new AppException("선택할 수 없는 정형 데이터 테이블입니다.")); + } + + public StructuredDataPreview preview(String key) { + StructuredDataTable table = requireTable(key); + try { + List columns = jdbcTemplate.query( + """ + SELECT column_name + FROM all_tab_columns + WHERE owner = ? + AND table_name = ? + ORDER BY column_id + """, + (resultSet, rowNum) -> resultSet.getString(1), OWNER, table.tableName()); + if (columns.isEmpty()) { + throw new AppException("정형 데이터 테이블의 컬럼 정보를 찾을 수 없습니다."); + } + + List> rows = jdbcTemplate.queryForList( + "SELECT * FROM " + OWNER + "." + table.tableName() + " WHERE ROWNUM <= ?", ROW_LIMIT); + return new StructuredDataPreview(table, columns, rows, ROW_LIMIT); + } catch (DataAccessException exception) { + throw new AppException("정형 데이터를 조회할 수 없습니다. POC_2 조회 권한과 대상 테이블 상태를 확인하세요."); + } + } +} diff --git a/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java new file mode 100644 index 0000000..159095e --- /dev/null +++ b/src/main/java/com/cloudhandson/vpdbackoffice/web/StructuredDataController.java @@ -0,0 +1,34 @@ +package com.cloudhandson.vpdbackoffice.web; + +import com.cloudhandson.vpdbackoffice.service.AppException; +import com.cloudhandson.vpdbackoffice.service.StructuredDataService; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@Controller +public class StructuredDataController { + + private final StructuredDataService structuredDataService; + + public StructuredDataController(StructuredDataService structuredDataService) { + this.structuredDataService = structuredDataService; + } + + @GetMapping("/structured-data") + public String structuredData( + @RequestParam(required = false) String table, + Model model + ) { + String selectedKey = table == null || table.isBlank() ? structuredDataService.defaultKey() : table; + model.addAttribute("tables", structuredDataService.tables()); + model.addAttribute("selectedKey", selectedKey); + try { + model.addAttribute("preview", structuredDataService.preview(selectedKey)); + } catch (AppException exception) { + model.addAttribute("errorMessage", exception.getMessage()); + } + return "structured-data"; + } +} diff --git a/src/main/resources/static/css/app.css b/src/main/resources/static/css/app.css index 1e60a86..209a43b 100644 --- a/src/main/resources/static/css/app.css +++ b/src/main/resources/static/css/app.css @@ -2057,6 +2057,50 @@ body { text-anchor: middle; } +.structured-table-grid { + display: grid; + gap: .75rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.structured-table-card { + display: grid; + gap: .3rem; + min-height: 112px; + padding: .9rem; + color: var(--rw-text); + text-decoration: none; + background: var(--rw-surface); + border: 1px solid var(--rw-border); + border-radius: 8px; +} + +.structured-table-card:hover { + color: var(--rw-text); + border-color: var(--rw-primary); + box-shadow: 0 2px 8px rgb(35 77 153 / 12%); +} + +.structured-table-card.is-selected { + background: var(--rw-primary-soft); + border-color: var(--rw-primary); + box-shadow: inset 0 0 0 1px var(--rw-primary); +} + +.structured-table-card code { + color: var(--rw-primary); + font-size: .8rem; +} + +.structured-table-card small { + color: var(--rw-muted); +} + +.structured-data-result td, +.structured-data-result th { + white-space: nowrap; +} + .product-help-sql { background: var(--rw-primary-soft); border: 1px solid color-mix(in srgb, var(--rw-primary) 26%, var(--rw-border)); diff --git a/src/main/resources/templates/fragments/layout.html b/src/main/resources/templates/fragments/layout.html index ba9ff1e..caca1cb 100644 --- a/src/main/resources/templates/fragments/layout.html +++ b/src/main/resources/templates/fragments/layout.html @@ -44,6 +44,7 @@