From 12c40c60044c9f0100f85ca0a0932b3145077324 Mon Sep 17 00:00:00 2001 From: koreafood Date: Sun, 31 May 2026 13:22:03 +0900 Subject: [PATCH] feat(board-generator): add board code generator and sample CRUD artifacts Add Node.js CLI tool with Handlebars templates for generating standard CRUD artifacts: Java entity, service, DAO, controller, MyBatis mapper XML, and Vue frontend pages. Also generate the full SampleTableBoard CRUD reference implementation, update README with backend execution instructions, and add project plan documentation. --- README.md | 5 +- board_plan.md | 471 ++++++++++++++ board_spec.md | 605 ++++++++++++++++++ .../admin/pgBoard/SampleTableBoardDetail.vue | 252 ++++++++ .../admin/pgBoard/SampleTableBoardList.vue | 129 ++++ .../admin/pgBoard/SampleTableBoardPage.vue | 97 +++ .../view/admin/pgBoard/masterDetailPlan.md | 216 +++++++ .../board/SampleTableBoardService.java | 18 + .../SampleTableBoardController.java | 95 +++ .../board/dao/SampleTableBoardDao.java | 46 ++ .../board/entity/SampleTableBoard.java | 21 + .../impl/SampleTableBoardServiceImpl.java | 52 ++ .../mapper-mybatis-sampleTable-board.xml | 55 ++ .../config/board-generator.yml | 23 + tools/board-generator/package-lock.json | 248 +++++++ tools/board-generator/package.json | 20 + tools/board-generator/src/generator.js | 401 ++++++++++++ tools/board-generator/src/index.js | 55 ++ .../board-generator/templates/controller.hbs | 98 +++ tools/board-generator/templates/dao.hbs | 46 ++ tools/board-generator/templates/entity.hbs | 15 + tools/board-generator/templates/mapper.hbs | 49 ++ tools/board-generator/templates/page.vue.hbs | 219 +++++++ tools/board-generator/templates/service.hbs | 18 + .../board-generator/templates/serviceImpl.hbs | 52 ++ 25 files changed, 3305 insertions(+), 1 deletion(-) create mode 100644 board_plan.md create mode 100644 board_spec.md create mode 100644 frontend/src/components/view/admin/pgBoard/SampleTableBoardDetail.vue create mode 100644 frontend/src/components/view/admin/pgBoard/SampleTableBoardList.vue create mode 100644 frontend/src/components/view/admin/pgBoard/SampleTableBoardPage.vue create mode 100644 frontend/src/components/view/admin/pgBoard/masterDetailPlan.md create mode 100644 src/main/java/com/samsung/sampleTable/board/SampleTableBoardService.java create mode 100644 src/main/java/com/samsung/sampleTable/board/controller/SampleTableBoardController.java create mode 100644 src/main/java/com/samsung/sampleTable/board/dao/SampleTableBoardDao.java create mode 100644 src/main/java/com/samsung/sampleTable/board/entity/SampleTableBoard.java create mode 100644 src/main/java/com/samsung/sampleTable/board/impl/SampleTableBoardServiceImpl.java create mode 100644 src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sampleTable-board.xml create mode 100644 tools/board-generator/config/board-generator.yml create mode 100644 tools/board-generator/package-lock.json create mode 100644 tools/board-generator/package.json create mode 100644 tools/board-generator/src/generator.js create mode 100644 tools/board-generator/src/index.js create mode 100644 tools/board-generator/templates/controller.hbs create mode 100644 tools/board-generator/templates/dao.hbs create mode 100644 tools/board-generator/templates/entity.hbs create mode 100644 tools/board-generator/templates/mapper.hbs create mode 100644 tools/board-generator/templates/page.vue.hbs create mode 100644 tools/board-generator/templates/service.hbs create mode 100644 tools/board-generator/templates/serviceImpl.hbs diff --git a/README.md b/README.md index f04bd54..e693706 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,10 @@ npm run local ```bash ./mvnw clean package ``` - +### 백엔드 실행 +```bash +mvn spring-boot:run -Plocal -Dspring-boot.run.jvmArguments=-DJASYPT_KEY=test-key +``` ### 프론트엔드 운영 빌드 ```bash diff --git a/board_plan.md b/board_plan.md new file mode 100644 index 0000000..9d861c4 --- /dev/null +++ b/board_plan.md @@ -0,0 +1,471 @@ +# Board Source Generator Plan + +## 1. 목표 + +테이블은 이미 생성되어 있다고 가정하고, 테이블명 기준으로 Java, MyBatis XML, Vue 초기 소스를 자동 생성하는 `Node.js CLI` 프로그램을 만든다. + +이 생성기의 목적은 완성본을 만드는 것이 아니다. + +- 반복적인 CRUD 초기 뼈대를 빠르게 만든다. +- 공통 규칙이 적용된 시작 파일을 자동 생성한다. +- 사람이 이후 업무 로직과 화면 상세를 이어서 개발할 수 있게 시간 절약을 돕는다. + +즉 결과물은 "바로 운영 투입하는 완성본"이 아니라, "초기 공통 사항이 반영된 출발점"이다. + +## 2. 생성 대상 + +생성 대상은 아래 파일들이다. + +- Java Entity +- Java Service Interface +- Java Service Impl +- Java Dao +- Java Controller +- MyBatis Mapper XML +- Vue CRUD Page + +1차 범위는 단일 테이블 CRUD 기준으로 제한한다. + +## 3. 기준 템플릿 원칙 + +생성기의 기준은 현재 프로젝트 안에서 실제로 동작하는 샘플 소스이다. + +- `src/main/java/com/samsung/sample/board/entity/SampleBoard.java` +- `src/main/java/com/samsung/sample/board/SampleBoardService.java` +- `src/main/java/com/samsung/sample/board/impl/SampleBoardServiceImpl.java` +- `src/main/java/com/samsung/sample/board/dao/SampleBoardDao.java` +- `src/main/java/com/samsung/sample/board/controller/SampleBoardController.java` +- `src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sample-board.xml` +- `frontend/src/components/view/admin/pgBoard/SampleBoardPage.vue` + +여기서 중요한 원칙은 아래와 같다. + +- 샘플 템플릿은 단순 참고용 문서가 아니라 실제 실행 가능한 상태로 유지한다. +- 샘플 템플릿이 변경되면 이후 새로 generate 하는 소스에도 그 변경이 반영되어야 한다. +- 즉 생성기는 고정 문자열 하드코딩보다 "실행 가능한 샘플 템플릿 + 치환 규칙" 구조로 가는 것이 좋다. + +## 4. 핵심 결정 사항 + +이번 계획에서는 아래 사항을 고정으로 본다. + +- 생성기는 `Node.js CLI` 로 구현한다. +- DB 메타데이터를 읽어서 컬럼을 반영한다. +- 컬럼이 추가된 경우 다시 generate 해서 새 구조를 반영할 수 있어야 한다. +- 이미 사용자가 수정해서 업무 로직이 들어간 소스는 생성기가 병합 관리하지 않는다. +- 변경 반영이 필요하면 새로 생성한다. +- 생성 결과는 공통적인 초기 코드 자동화가 목적이며, 완성형 산출물이 아니다. + +## 5. 생성기 방식 + +가장 적합한 방식은 `DB 메타데이터 조회 + 템플릿 렌더링 + 파일 생성` 구조이다. + +이 방식의 장점은 아래와 같다. + +- 실제 테이블 컬럼을 기준으로 생성하므로 신뢰도가 높다. +- 컬럼 추가 시 다시 generate 해서 반영할 수 있다. +- Java, XML, Vue를 같은 규칙으로 묶어 생성할 수 있다. +- 샘플 템플릿 변경 사항을 이후 생성물에 계속 반영할 수 있다. + +반대로 아래 방식은 지양한다. + +- 테이블명만 보고 컬럼을 추측하는 방식 +- 생성 후 기존 파일 내용을 분석해서 자동 병합하는 방식 +- LLM 출력 결과만을 신뢰하는 방식 + +특히 기존에 수정된 파일을 병합 관리하는 기능은 초기에 넣지 않는 것이 맞다. + +이 기능은 복잡도가 높고 충돌 위험이 커서, 1차 생성기의 목적과 맞지 않는다. + +## 6. 재생성 정책 + +재생성 정책은 이번 문서에서 매우 중요하다. + +### 6.1 컬럼 추가 시 정책 + +- DB 컬럼이 추가되면 생성기를 다시 실행한다. +- 생성기는 현재 DB 메타데이터를 읽어 새 파일을 만든다. +- 새로 생성된 초기 소스를 기준으로 개발자가 필요한 변경을 다시 옮긴다. + +즉 "기존 파일에 컬럼만 안전하게 끼워 넣는 병합 기능"은 1차에 넣지 않는다. + +### 6.2 기존 수정 파일 정책 + +- 이미 사용 중이며 수정된 소스는 생성기가 추적하지 않는다. +- 생성기는 기존 수정 파일의 의미를 이해하거나 병합하지 않는다. +- 변경 반영이 필요하면 새 파일을 새로 생성한다. + +이 정책을 명확히 해야 생성기의 역할이 단순해지고 안정적이다. + +### 6.3 생성 결과 관리 정책 + +아래 두 방식 중 하나를 권장한다. + +1. 별도 생성 경로에 새로 만든 뒤 비교 후 적용 +2. 모듈명을 바꿔 신규 버전처럼 생성 + +예시: + +- `sample/board_v2` +- `sample/board_v2` + +즉 생성기는 "업데이트 도구"가 아니라 "초기 코드 새로 만들기 도구"로 정의한다. + +## 7. Node.js CLI 로 하는 이유 + +`Node.js CLI` 로 고정하는 이유는 아래와 같다. + +- Vue 파일 생성과 템플릿 처리에 가장 유리하다. +- Java, XML, Vue를 문자열 템플릿으로 다루기 쉽다. +- 빠르게 프로토타입을 만들기 좋다. +- CLI 사용 경험을 만들기 쉽다. +- 추후 npm script 또는 사내 툴 형태로 묶기 쉽다. + +따라서 이번 생성기는 Java 기반이 아니라 `Node.js CLI` 로 결정한다. + +## 8. 입력 값 설계 + +최소 입력은 테이블명이지만, 실제 사용을 위해 아래 입력을 권장한다. + +- `--table`: DB 테이블명 +- `--module`: 모듈 경로 +- `--entity`: Entity 클래스명 +- `--menu-name`: 화면 표시명 +- `--output`: 출력 루트 또는 생성 그룹 + +예시: + +```text +generate-board --table samples --module sample/board --entity SampleBoard --menu-name "Sample Board" +``` + +테이블명만 넣는 모드도 제공할 수 있다. + +```text +generate-board --table sample_board +``` + +다만 테이블명만으로 클래스명과 모듈명을 완벽히 정할 수는 없으므로, 기본 추천값을 보여주고 확정 후 생성하는 흐름이 더 좋다. + +## 9. DB 메타데이터 수집 범위 + +생성기는 최소한 아래 메타데이터를 읽어야 한다. + +- 컬럼명 +- DB 타입 +- nullable 여부 +- 기본키 여부 +- 자동 증가 여부 +- 컬럼 순서 + +PostgreSQL에서는 `information_schema.columns` 와 `pg_catalog` 정보를 사용하면 충분하다. + +1차 지원 범위는 아래처럼 단순하게 가져간다. + +- 단일 테이블 +- 단일 PK +- PostgreSQL +- 일반 CRUD + +## 10. 컬럼 매핑 규칙 + +DB 컬럼을 Java, XML, Vue에 일관되게 매핑해야 한다. + +예시: + +- `id` -> `Integer id` +- `title` -> `String title` +- `content` -> `String content` +- `author` -> `String author` +- `created_at` -> `Date createdAt` +- `updated_at` -> `Date updatedAt` + +기본 규칙: + +- snake_case -> camelCase +- PostgreSQL `varchar`, `text` -> `String` +- `int4` -> `Integer` +- `int8` -> `Long` +- `numeric` -> `BigDecimal` +- `timestamp` -> `Date` + +현재 프로젝트 샘플과 맞추기 위해 1차는 `Date` 기준이 가장 안전하다. + +## 11. 생성 대상 파일별 역할 + +### 11.1 Entity + +- 컬럼 필드 선언 +- `@Data` 사용 +- 현재 프로젝트 import/스타일 유지 + +### 11.2 Service Interface + +- CRUD 메서드 선언 + +기본 메서드: + +- `getXxxList()` +- `getXxx(Integer id)` +- `createXxx(Xxx entity)` +- `updateXxx(Xxx entity)` +- `deleteXxx(Integer id)` + +### 11.3 Service Impl + +- DAO 위임 +- 등록 후 재조회 +- 수정 후 재조회 + +### 11.4 Dao + +- MyBatis `SqlSession` 호출 + +### 11.5 Controller + +- 현재 프로젝트의 `FormulaController` 유사 스타일 사용 +- `@RestController` +- `@RequestMapping("/.../")` +- `@Log4j2` +- `main.do?method=...` 패턴 사용 + +### 11.6 MyBatis XML + +- resultMap 생성 +- 목록/상세/등록/수정/삭제 SQL 생성 + +### 11.7 Vue Page + +- 목록/상세/등록/수정/삭제 초기 화면 생성 +- axios 기반 API 연결 +- 생성 시점의 컬럼 기준 입력 폼과 목록 컬럼 생성 + +## 12. 템플릿 전략 + +이번 생성기의 핵심은 템플릿을 실제 실행 가능한 샘플 코드로 유지하는 것이다. + +즉 아래 원칙으로 간다. + +- 샘플 파일은 실제 프로젝트 안에서 동작 가능해야 한다. +- 생성기는 그 샘플 구조를 공통 템플릿으로 활용한다. +- 샘플이 변경되면 이후 generate 결과도 같이 바뀌어야 한다. + +구현 방식은 아래 구조가 좋다. + +- 템플릿 원본 파일 보관 +- 치환 포인트 정의 +- 반복 구간은 템플릿 엔진으로 처리 + +템플릿 엔진은 아래 중 하나를 권장한다. + +- Mustache +- Handlebars + +이유는 아래와 같다. + +- 필드 반복 생성이 쉽다. +- XML resultMap 반복 생성이 쉽다. +- Vue form/table 반복 생성이 쉽다. + +## 13. 샘플 템플릿 운영 정책 + +샘플 템플릿은 향후 생성 품질을 좌우하므로 별도 규칙이 필요하다. + +운영 원칙: + +- 샘플 템플릿은 실제 실행 가능해야 한다. +- 샘플 템플릿은 프로젝트 표준 예제로 관리한다. +- 샘플 템플릿 수정은 곧 생성기 출력 규칙 변경과 연결된다. +- 생성기는 샘플 템플릿을 기준으로 치환/반복 렌더링한다. + +즉 샘플 템플릿은 "문서 예제"가 아니라 "살아있는 기준 코드"이다. + +## 14. 파일 생성 위치 정책 + +예시 모듈이 `sample/board` 라면 아래 경로를 생성한다. + +- `src/main/java/com/samsung/sample/board/entity/SampleBoard.java` +- `src/main/java/com/samsung/sample/board/SampleBoardService.java` +- `src/main/java/com/samsung/sample/board/dao/SampleBoardDao.java` +- `src/main/java/com/samsung/sample/board/impl/SampleBoardServiceImpl.java` +- `src/main/java/com/samsung/sample/board/controller/SampleBoardController.java` +- `src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sample-board.xml` +- `frontend/src/components/view/admin/pgBoard/SampleBoardPage.vue` + +다만 현재 기본 동작은 실제 소스 위치에 직접 생성하는 것으로 한다. + +- 기본 출력은 실제 소스 경로에 직접 생성한다. +- 기존 파일이 있으면 `--force` 없이는 덮어쓰지 않는다. +- 필요 시 `--output` 으로 별도 경로에 우회 생성할 수 있다. + +## 15. 프로그램 구조 제안 + +추천 구조: + +```text +tools/board-generator/ + src/ + cli/ + metadata/ + naming/ + generators/ + writers/ + validators/ + template-engine/ + templates/ + java/ + xml/ + vue/ + config/ + board-generator.yml +``` + +모듈 역할: + +- `cli`: 명령행 입력 처리 +- `metadata`: DB 메타데이터 조회 +- `naming`: 이름 변환 규칙 처리 +- `generators`: 파일별 렌더링 +- `writers`: 파일 생성 +- `validators`: 입력값 및 스키마 검증 +- `template-engine`: 템플릿 치환 및 반복 처리 + +## 16. 생성 순서 + +권장 순서는 아래와 같다. + +1. 입력 파라미터 수집 +2. DB 메타데이터 조회 +3. PK 및 컬럼 구조 검증 +4. 이름 규칙 계산 +5. 템플릿 렌더링 +6. 신규 출력 경로 결정 +7. 파일 생성 +8. 생성 결과 요약 출력 + +## 17. 안전장치 + +반드시 넣을 기능: + +- 기존 파일 자동 병합 금지 +- 기본은 신규 생성 +- 덮어쓰기 옵션은 제한적으로만 허용 +- 생성 전 preview 출력 +- 지원하지 않는 구조는 중단 + +예시: + +- 복합 PK면 생성 중단 +- PK가 없으면 생성 중단 +- 테이블이 없으면 생성 중단 + +## 18. 검증 전략 + +생성기 자체의 검증은 아래 기준으로 진행한다. + +### 백엔드 검증 + +- 생성된 Java 소스 컴파일 가능 여부 확인 +- import 누락 여부 확인 +- MyBatis XML 파싱 가능 여부 확인 + +### 프론트 검증 + +- Vue 문법 오류 확인 +- axios URL 패턴 확인 + +### 템플릿 검증 + +- `samples` 테이블 기준으로 generate 했을 때 샘플 구조와 유사한 결과가 나오는지 확인 +- 샘플 템플릿 변경 후 재생성 시 변경 내용이 반영되는지 확인 + +## 19. 구현 단계 + +### Step 1. 실행 가능한 샘플 템플릿 정리 + +- 현재 샘플 보드 소스를 템플릿 기준으로 분리 +- 실행 가능한 상태 유지 +- 치환 영역과 반복 영역 정의 + +### Step 2. PostgreSQL 메타데이터 조회기 작성 + +- 컬럼 목록 조회 +- PK 조회 +- 타입 매핑 정보 생성 + +### Step 3. Node.js 템플릿 렌더러 작성 + +- Java 템플릿 렌더링 +- XML 템플릿 렌더링 +- Vue 템플릿 렌더링 + +### Step 4. CLI 작성 + +- `generate-board --table ...` 형태 구현 +- 기본값 추천 +- preview 출력 + +### Step 5. 재생성 흐름 검증 + +- 컬럼 추가 후 다시 generate +- 새 생성본에 새 컬럼이 반영되는지 확인 +- 기존 수정 파일을 건드리지 않는지 확인 + +## 20. MVP 범위 + +MVP는 아래 수준이면 충분하다. + +입력: + +- `table` +- `module` +- `entity` + +출력: + +- Entity +- Dao +- Service +- ServiceImpl +- Controller +- MyBatis XML +- Vue Page + +단, 생성 결과는 공통 초기 코드 수준으로 제한한다. + +포함되는 것: + +- 기본 CRUD +- 기본 입력 폼 +- 기본 목록 컬럼 +- 기본 API 연결 + +포함하지 않는 것: + +- 복잡한 비즈니스 로직 +- 고급 화면 동작 +- 권한별 세부 분기 +- 기존 수정본 자동 병합 + +## 21. 최종 정리 + +현재 가장 적합한 방향은 아래와 같다. + +1. 생성기는 `Node.js CLI` 로 구현한다. +2. PostgreSQL 메타데이터를 읽어 컬럼 기반으로 생성한다. +3. 실행 가능한 샘플 템플릿을 기준으로 Java/XML/Vue를 생성한다. +4. 컬럼이 추가되면 다시 generate 해서 새 구조를 반영한다. +5. 이미 수정된 기존 소스는 생성기가 관리하지 않는다. +6. 필요하면 새 경로 또는 새 모듈로 다시 생성한다. +7. 생성 결과는 완성본이 아니라 초기 공통 코드 자동화 산출물로 본다. + +즉 이 생성기의 본질은 "운영 코드 수정기"가 아니라 "공통 초기 코드 부스터"이다. + +## 22. 다음 작업 제안 + +다음 단계로는 아래 순서를 권장한다. + +1. `board_spec.md` 작성 +2. PostgreSQL 메타데이터 조회 SQL 작성 +3. Node.js CLI 디렉터리 구조 생성 +4. 템플릿 파일 분리 +5. `samples` 기준 MVP 구현 diff --git a/board_spec.md b/board_spec.md new file mode 100644 index 0000000..53748e0 --- /dev/null +++ b/board_spec.md @@ -0,0 +1,605 @@ +# Board Generator Specification + +## 1. 문서 목적 + +이 문서는 `board_plan.md`를 기반으로 `Node.js CLI` 기반 보드 소스 생성기의 구현 명세를 정의한다. + +이 문서의 목적은 아래와 같다. + +- CLI 입력값을 확정한다. +- 생성 파일 범위와 출력 경로를 확정한다. +- DB 메타데이터 조회 항목을 확정한다. +- 템플릿 렌더링 규칙을 확정한다. +- 재생성 정책과 비범위를 명확히 한다. + +이 문서는 구현자가 바로 MVP 개발을 시작할 수 있는 수준의 기준 문서로 사용한다. + +## 2. 생성기 개요 + +생성기 이름은 임시로 `generate-board` 로 정의한다. + +생성기는 PostgreSQL 테이블 메타데이터를 조회한 뒤, 현재 프로젝트의 실행 가능한 샘플 템플릿 규칙에 맞춰 아래 파일을 생성한다. + +- Java Entity +- Java Service Interface +- Java Service Impl +- Java Dao +- Java Controller +- MyBatis Mapper XML +- Vue CRUD Page + +생성 결과는 완성본이 아니라 공통 초기 코드를 빠르게 확보하기 위한 초안 산출물이다. + +## 3. 구현 대상 범위 + +### 3.1 포함 범위 + +- PostgreSQL 단일 테이블 기준 생성 +- 단일 PK 기준 생성 +- Java 백엔드 CRUD 코드 생성 +- MyBatis XML 생성 +- Vue CRUD 초기 페이지 생성 +- 컬럼 추가 후 재생성 지원 + +### 3.2 제외 범위 + +- 복합 PK +- 조인 조회 +- 첨부파일 기능 +- 메뉴 자동 등록 +- 라우터 자동 등록 +- 권한 정책 자동 삽입 +- 기존 수정본 자동 병합 +- 다국어 리소스 자동 생성 + +## 4. CLI 명령 규격 + +### 4.1 기본 명령 + +```bash +generate-board --table +``` + +### 4.2 권장 명령 + +```bash +generate-board \ + --table sample_board \ + --module sample/board \ + --entity SampleBoard \ + --menu-name "Sample Board" \ + --output generated +``` + +### 4.3 옵션 정의 + +- `--table` + - 필수 + - DB 테이블명 + - 예: `samples`, `sample_board` + +- `--module` + - 선택 + - Java 패키지 및 디렉터리 기준 모듈 경로 + - 예: `sample/board` + +- `--entity` + - 선택 + - 생성할 Entity 클래스명 + - 예: `SampleBoard` + +- `--menu-name` + - 선택 + - Vue 화면 제목과 설명 기본값 + - 예: `Sample Board` + +- `--output` + - 선택 + - 생성 파일 묶음을 저장할 루트 경로 또는 그룹명 + - 예: `.`, `generated`, `generated/v1` + +- `--force` + - 선택 + - 기존 생성 대상이 존재할 때 덮어쓰기 허용 + - 기본값은 `false` + +- `--preview` + - 선택 + - 실제 파일 생성 없이 생성 대상 목록과 계산된 이름만 출력 + +- `--scope` + - 선택 + - 생성 범위 제한 + - 허용값: `all`, `backend`, `xml`, `frontend` + +## 5. 입력값 보정 규칙 + +입력값 일부가 생략되면 생성기가 기본값을 계산한다. + +### 5.1 테이블명 기반 기본값 계산 + +- `sample_board` -> Entity 기본값 `SampleBoard` +- `sample_board` -> module 기본값 `sample/board` +- `samples` -> Entity 추천값 `Sample` +- `samples` -> module 추천값 `samples` + +### 5.2 추천 후 확정 정책 + +테이블명만으로 Entity 명과 module 명을 완벽히 결정할 수 없으므로 아래 정책을 적용한다. + +- 기본값을 자동 계산한다. +- preview 출력 시 계산 결과를 보여준다. +- 사용자는 필요 시 `--module`, `--entity` 로 명시한다. + +MVP에서는 대화형 입력 없이 명령 인자 우선으로 처리한다. + +## 6. 출력 경로 규칙 + +### 6.1 기본 출력 루트 + +기본 출력은 프로젝트 루트 기준 실제 소스 위치에 바로 생성하는 것을 기본 정책으로 한다. + +예시: + +- `src/main/java/com/samsung/sample/board/entity/SampleBoard.java` +- `src/main/java/com/samsung/sample/board/SampleBoardService.java` +- `src/main/java/com/samsung/sample/board/dao/SampleBoardDao.java` +- `src/main/java/com/samsung/sample/board/impl/SampleBoardServiceImpl.java` +- `src/main/java/com/samsung/sample/board/controller/SampleBoardController.java` +- `src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sample-board.xml` +- `frontend/src/components/view/admin/pgBoard/SampleBoardPage.vue` + +### 6.2 출력 경로 정책 + +- 기본값은 실제 소스 경로에 직접 생성한다. +- 기존 파일이 이미 있으면 `--force` 없이는 덮어쓰지 않는다. +- 필요 시 `--output generated` 같은 별도 경로를 지정해 우회 생성할 수 있다. + +### 6.3 `--output` 사용 예 + +- `--output generated` +- `--output generated/v2` +- `--output output/sample-board` + +## 7. 샘플 템플릿 기준 파일 + +생성기는 아래 파일들을 기준 템플릿의 원형으로 사용한다. + +- `src/main/java/com/samsung/sample/board/entity/SampleBoard.java` +- `src/main/java/com/samsung/sample/board/SampleBoardService.java` +- `src/main/java/com/samsung/sample/board/impl/SampleBoardServiceImpl.java` +- `src/main/java/com/samsung/sample/board/dao/SampleBoardDao.java` +- `src/main/java/com/samsung/sample/board/controller/SampleBoardController.java` +- `src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sample-board.xml` +- `frontend/src/components/view/admin/pgBoard/SampleBoardPage.vue` + +이 파일들은 단순 예제가 아니라 실제로 동작 가능한 샘플 템플릿이어야 한다. + +## 8. 템플릿 엔진 규격 + +MVP 템플릿 엔진은 `Handlebars` 를 우선 권장한다. + +선정 이유는 아래와 같다. + +- 반복 렌더링이 쉽다. +- 조건부 블록 표현이 쉽다. +- Java 필드, XML resultMap, Vue 입력 항목 반복 생성에 적합하다. + +대체안으로 `Mustache` 도 가능하지만, 조건 분기가 필요한 Vue 템플릿 생성에는 `Handlebars` 가 조금 더 유리하다. + +## 9. 템플릿 변수 규격 + +모든 템플릿은 아래 공통 변수를 사용할 수 있어야 한다. + +### 9.1 전역 변수 + +- `tableName` +- `modulePath` +- `entityName` +- `entityVarName` +- `serviceName` +- `serviceImplName` +- `daoName` +- `controllerName` +- `mapperNamespace` +- `menuName` +- `requestBasePath` +- `vueComponentName` +- `mapperFileName` + +### 9.2 컬럼 변수 + +각 컬럼은 아래 속성을 가진다. + +- `columnName` +- `propertyName` +- `dbType` +- `javaType` +- `nullable` +- `primaryKey` +- `autoIncrement` +- `ordinalPosition` +- `isAuditColumn` +- `isSearchCandidate` +- `isListCandidate` +- `isFormCandidate` + +### 9.3 파생 변수 + +- `primaryKeyColumn` +- `primaryKeyProperty` +- `primaryKeyJavaType` +- `normalColumns` +- `insertColumns` +- `updateColumns` +- `listColumns` +- `formColumns` + +## 10. DB 메타데이터 조회 명세 + +생성기는 아래 정보를 반드시 조회해야 한다. + +- 테이블 존재 여부 +- 컬럼 목록 +- 컬럼 타입 +- PK 컬럼 +- nullable 여부 +- identity 또는 sequence 기반 자동 증가 여부 +- 컬럼 순서 + +### 10.1 메타데이터 조회 순서 + +1. 테이블 존재 확인 +2. 컬럼 목록 조회 +3. PK 조회 +4. 자동 증가 여부 조회 +5. 결과 통합 + +### 10.2 실패 조건 + +아래 조건이면 생성 중단한다. + +- 테이블이 존재하지 않음 +- PK가 없음 +- PK가 2개 이상임 +- 지원하지 않는 DB 타입 존재 + +## 11. 타입 매핑 명세 + +PostgreSQL -> Java 매핑 규칙은 아래를 기본으로 한다. + +- `varchar`, `text`, `char` -> `String` +- `int2`, `int4`, `serial` -> `Integer` +- `int8`, `bigserial` -> `Long` +- `numeric`, `decimal` -> `BigDecimal` +- `bool` -> `Boolean` +- `date` -> `Date` +- `timestamp`, `timestamptz` -> `Date` + +MVP에서는 현재 샘플 스타일을 맞추기 위해 `java.util.Date` 를 사용한다. + +## 12. 컬럼 분류 규칙 + +생성기는 컬럼을 의미상 분류해서 Vue 및 SQL 생성에 활용한다. + +### 12.1 기본 분류 + +- PK 컬럼 +- 일반 입력 컬럼 +- 생성일 컬럼 +- 수정일 컬럼 +- 제외 컬럼 + +### 12.2 기본 제외 후보 + +아래 컬럼은 기본적으로 등록 입력 폼에서 제외 가능 대상으로 본다. + +- `created_at` +- `created_datetime` +- `updated_at` +- `updated_datetime` +- `first_reg_datetime` +- `last_mod_datetime` + +### 12.3 기본 목록 후보 + +기본적으로 아래 규칙을 따른다. + +- PK는 목록에 포함 +- 문자형 일반 컬럼은 목록에 포함 +- 긴 본문 컬럼은 목록에서 제외 가능 +- 생성일, 수정일은 목록에 포함 가능 + +MVP에서는 단순 규칙 기반으로 처리하고, 이후 설정 파일로 분리할 수 있다. + +## 13. 파일별 생성 명세 + +### 13.1 Entity + +생성 내용: + +- package 선언 +- import 선언 +- `@Data` +- 컬럼 필드 선언 + +스타일: + +- 현재 프로젝트 Java 스타일 유지 +- 탭 들여쓰기 유지 + +### 13.2 Service Interface + +생성 메서드: + +- `getXxxList()` +- `getXxx(PK id)` +- `createXxx(Xxx entity)` +- `updateXxx(Xxx entity)` +- `deleteXxx(PK id)` + +### 13.3 Service Impl + +생성 내용: + +- DAO 의존성 주입 +- CRUD 메서드 구현 +- 등록 후 재조회 +- 수정 후 재조회 + +### 13.4 Dao + +생성 내용: + +- MyBatis `SqlSession` 사용 +- mapper namespace 상수 +- CRUD DAO 메서드 + +### 13.5 Controller + +생성 규칙: + +- `@RestController` +- `@RequestMapping` +- `@Log4j2` +- `main.do?method=...` 패턴 + +생성 메서드: + +- `page` +- `list_search` +- `item_get...` +- `item_regist` +- `item_update` +- `item_delete` + +응답 정책: + +- 목록, 상세, 등록, 수정은 `Map` +- 삭제는 `"success"` + +### 13.6 MyBatis XML + +생성 내용: + +- `mapper` +- `resultMap` +- `selectList` +- `selectOne` +- `insert` +- `update` +- `delete` + +SQL 규칙: + +- 컬럼명은 DB 원본 유지 +- property는 camelCase 사용 +- insert, update 대상 컬럼은 PK 및 제외 컬럼 기준 분리 + +### 13.7 Vue Page + +생성 내용: + +- 목록 화면 +- 상세/등록/수정 화면 +- form 상태 +- 목록 조회 API +- 상세 조회 API +- 등록 API +- 수정 API +- 삭제 API + +화면 정책: + +- 생성 결과는 기본 CRUD 화면 수준으로 제한 +- 레이아웃과 스타일은 샘플 보드 기준 유지 + +## 14. API URL 생성 규칙 + +Vue 페이지는 백엔드 Controller 생성 규칙과 맞아야 한다. + +기본 규칙: + +- 목록 조회: `.../list/main.do?method=search` +- 상세 조회: `.../item/main.do?method=get` +- 등록: `.../item/main.do?method=regist` +- 수정: `.../item/main.do?method=update` +- 삭제: `.../item/main.do?method=delete` + +예시: + +- `/pg-board/samples/list/main.do?method=search` +- `/pg-board/samples/item/main.do?method=getSampleBoard` + +## 15. 재생성 정책 명세 + +### 15.1 목적 + +재생성은 기존 파일을 안전하게 병합하는 기능이 아니라, 최신 DB 메타데이터 기준으로 새 초기 코드를 다시 만드는 기능이다. + +### 15.2 동작 방식 + +- 컬럼이 추가되면 생성기를 다시 실행한다. +- 생성기는 새 파일 세트를 다시 만든다. +- 기존 변경 파일은 건드리지 않는다. +- 개발자는 새 생성본과 기존 파일을 비교해 필요한 부분만 반영한다. + +### 15.3 비지원 정책 + +아래 기능은 지원하지 않는다. + +- 기존 Java 파일 내부 AST 기반 병합 +- 기존 Vue 파일 diff 병합 +- 기존 XML 구문 병합 + +## 16. 샘플 템플릿 변경 반영 정책 + +샘플 템플릿은 미래 생성물의 기준이므로 아래 규칙을 적용한다. + +- 샘플 템플릿은 항상 실행 가능한 상태를 유지한다. +- 샘플 템플릿 변경은 생성기 템플릿 변경과 동일한 의미를 가진다. +- 새로 generate 하는 모든 소스는 최신 샘플 템플릿 기준으로 생성한다. + +따라서 템플릿 관련 개선은 생성기 로직보다 샘플 템플릿과 치환 규칙 정리가 우선이다. + +## 17. 설정 파일 명세 + +설정 파일은 `tools/board-generator/config/board-generator.yml` 을 기본 경로로 가정한다. + +예시 항목: + +```yaml +database: + client: postgresql + host: 127.0.0.1 + port: 5432 + database: app + user: app + password: app + +paths: + basePackage: com.samsung + javaRoot: src/main/java + resourceRoot: src/main/resources + mapperRoot: src/main/resources/sql/mybatis/postgresql + vueRoot: frontend/src/components/view/admin + generatedRoot: . + +conventions: + controllerStyle: formula_like + dateType: java.util.Date + apiStyle: main_do_method + indent: tab +``` + +MVP에서는 CLI 인자 우선, 설정 파일 보조 정책을 따른다. + +## 18. 내부 모듈 구조 명세 + +권장 구조: + +```text +tools/board-generator/ + src/ + cli/ + index.js + config/ + loadConfig.js + metadata/ + postgresMetadataReader.js + naming/ + namingRules.js + render/ + renderJava.js + renderXml.js + renderVue.js + write/ + fileWriter.js + validate/ + schemaValidator.js + optionValidator.js + utils/ + pathUtils.js + templates/ + java/ + xml/ + vue/ + config/ + board-generator.yml +``` + +## 19. 로그 및 출력 규격 + +CLI 출력은 아래 순서를 따른다. + +1. 입력값 출력 +2. 계산된 이름 출력 +3. 조회된 테이블 메타데이터 요약 출력 +4. 생성 대상 파일 목록 출력 +5. preview 또는 생성 결과 출력 + +예시 메시지: + +- `Table found: sample_board` +- `Primary key: id(Integer)` +- `Generate target: src/main/java/...` +- `Preview mode enabled` +- `Files generated: 7` + +## 20. 오류 처리 규칙 + +오류 메시지는 가능한 한 명확해야 한다. + +예시: + +- `Table not found: sample_board` +- `Composite primary key is not supported` +- `Unsupported column type: jsonb` +- `Output path already exists. Use --force to overwrite` + +MVP에서는 예외 스택 전체 출력보다 원인 중심 메시지를 우선한다. + +## 21. 검증 기준 + +생성기 완료 후 아래 검증을 통과해야 한다. + +### 21.1 샘플 기준 검증 + +- `samples` 또는 `sample_board` 기준으로 생성한 결과가 현재 샘플 구조와 유사해야 한다. + +### 21.2 백엔드 검증 + +- 생성된 Java 코드 컴파일 가능 +- import 오류 없음 +- MyBatis XML 파싱 가능 + +### 21.3 프론트 검증 + +- Vue 문법 오류 없음 +- axios URL 패턴 정상 + +### 21.4 재생성 검증 + +- 컬럼 추가 후 다시 generate 했을 때 새 컬럼이 Entity, XML, Vue에 반영되어야 한다. +- 기존 수정 파일은 변경되지 않아야 한다. + +## 22. MVP 완료 기준 + +아래 조건을 만족하면 MVP 완료로 본다. + +- PostgreSQL 단일 테이블 기준으로 소스 생성 가능 +- Entity, Service, ServiceImpl, Dao, Controller, XML, Vue 생성 가능 +- preview 가능 +- 실제 소스 경로 또는 지정한 `--output` 경로에 파일 생성 가능 +- 컬럼 추가 후 재생성 가능 +- 기존 파일 병합 없이 새 생성 정책 유지 + +## 23. 다음 작업 + +이 문서 다음 단계는 아래 순서를 따른다. + +1. PostgreSQL 메타데이터 조회 SQL 작성 +2. Node.js CLI 프로젝트 골격 생성 +3. 템플릿 파일 분리 +4. naming 규칙 구현 +5. `samples` 기준 MVP 생성 테스트 diff --git a/frontend/src/components/view/admin/pgBoard/SampleTableBoardDetail.vue b/frontend/src/components/view/admin/pgBoard/SampleTableBoardDetail.vue new file mode 100644 index 0000000..2ac3367 --- /dev/null +++ b/frontend/src/components/view/admin/pgBoard/SampleTableBoardDetail.vue @@ -0,0 +1,252 @@ + + + + + diff --git a/frontend/src/components/view/admin/pgBoard/SampleTableBoardList.vue b/frontend/src/components/view/admin/pgBoard/SampleTableBoardList.vue new file mode 100644 index 0000000..8bc4222 --- /dev/null +++ b/frontend/src/components/view/admin/pgBoard/SampleTableBoardList.vue @@ -0,0 +1,129 @@ + + + + + + + + diff --git a/frontend/src/components/view/admin/pgBoard/SampleTableBoardPage.vue b/frontend/src/components/view/admin/pgBoard/SampleTableBoardPage.vue new file mode 100644 index 0000000..5bca764 --- /dev/null +++ b/frontend/src/components/view/admin/pgBoard/SampleTableBoardPage.vue @@ -0,0 +1,97 @@ + + + + + diff --git a/frontend/src/components/view/admin/pgBoard/masterDetailPlan.md b/frontend/src/components/view/admin/pgBoard/masterDetailPlan.md new file mode 100644 index 0000000..cf76e60 --- /dev/null +++ b/frontend/src/components/view/admin/pgBoard/masterDetailPlan.md @@ -0,0 +1,216 @@ +# SampleTableBoard Master/Detail 분리 분석 및 실행 계획 + +## 1. 현재 구조 요약 + +현재 `SampleTableBoardPage.vue` 는 하나의 Vue 파일 안에서 아래 두 화면을 함께 처리한다. + +- 목록 화면: `itemList` 조회, 테이블 렌더링, `새 글` 버튼, 상세 이동 +- 상세 화면: 등록/수정 폼, 상세 조회, 저장, 삭제 +- 화면 전환 방식: 별도 라우트가 아니라 동일한 path 에서 query(`detailId`, `mode=create`)로 제어 + +즉, 현재 구조는 "단일 페이지 + 내부 분기" 방식이다. + +## 2. 현재 파일 기준에서 느껴지는 복잡도 + +현재 파일은 절대적으로 매우 큰 편은 아니다. 다만 관심사가 이미 섞이기 시작했다. + +- 템플릿에 `v-if="isDetailView"` 분기가 있어 목록/상세 DOM 이 한 파일에 공존한다. +- `watch('$route.query')`, `syncRouteState()`, `getRouteDetailId()` 같은 화면 전환용 보일러플레이트가 필요하다. +- 목록 전용 메서드(`fetchList`, `moveToCreate`, `openDetail`)와 상세 전용 메서드(`fetchDetail`, `saveItem`, `deleteItem`)가 같이 있다. +- 저장/삭제 이후 다시 목록을 새로고침하고 라우트를 바꾸는 흐름까지 한 컴포넌트가 모두 책임진다. + +지금은 관리 가능하지만, 필드가 늘어나거나 validation, 첨부파일, 검색/페이징, 권한 처리 등이 추가되면 빠르게 복잡해질 구조다. + +## 3. 두 개의 Vue 로 분리할 때 기대 장점 + +예시 분리안: + +- `SampleTableBoardListPage.vue` +- `SampleTableBoardDetailPage.vue` + +장점은 아래와 같다. + +### 3.1 관심사 분리 + +- 목록 컴포넌트는 목록 조회/이동만 담당한다. +- 상세 컴포넌트는 조회/등록/수정/삭제와 폼 상태만 담당한다. +- 각 파일을 읽을 때 "이 화면에서 필요한 코드"만 보이므로 이해 비용이 줄어든다. + +### 3.2 템플릿 단순화 + +- `v-if / v-else` 로 큰 화면 블록 두 개를 한 파일에 둘 필요가 없다. +- 목록 수정 중 상세 마크업을 건드리거나, 상세 작업 중 목록 레이아웃을 건드릴 가능성이 줄어든다. + +### 3.3 상태 관리 단순화 + +- 목록 화면에서는 `form`, `mode`, `fetchDetail` 이 필요 없다. +- 상세 화면에서는 `itemList` 전체와 목록 테이블 렌더링 로직이 필요 없다. +- `isDetailView`, `syncRouteState()` 같은 내부 화면 전환 상태가 사라지거나 매우 단순해진다. + +### 3.4 유지보수성 향상 + +- 이후 상세 폼에 필드가 늘어나도 목록 화면 영향이 줄어든다. +- 목록에 검색/정렬/페이징이 붙어도 상세 화면이 비대해지지 않는다. +- 리뷰, 테스트, 수정 범위를 화면별로 좁힐 수 있다. + +### 3.5 라우팅 명확성 향상 + +- URL 만 봐도 목록인지 상세인지 더 분명하게 설계할 수 있다. +- 브라우저 뒤로가기/새로고침/직접 진입 시 동작 의도가 명확해진다. + +## 4. 분리 시 단점과 비용 + +장점만 있는 것은 아니다. 현재 파일 규모를 기준으로 보면 아래 비용도 분명하다. + +### 4.1 파일 수 증가 + +- 지금은 하나의 파일만 보면 되지만, 분리하면 라우트/이동/API 공통 처리까지 확인 범위가 넓어진다. +- 작은 화면에서는 오히려 "파일만 늘어난 느낌"이 될 수 있다. + +### 4.2 라우트 수정 필요 가능성 + +- 현재는 같은 path 에서 query 만 바꾸면 되지만, 분리 시 별도 path 설계가 필요할 수 있다. +- 메뉴 시스템과 동적 route 구조를 함께 고려해야 한다. +- 프로젝트가 menu/pagePath 기반으로 라우트를 등록하는 구조이므로, 실제 적용 시 메뉴 설정 영향도 확인해야 한다. + +### 4.3 공통 로직 중복 가능성 + +- `axios`, `SDLUtil`, API URL, 저장 후 이동 로직 등이 list/detail 에 나뉘면서 일부 중복될 수 있다. +- 중복이 생기면 별도 composable/util 로 정리하고 싶어질 수 있는데, 그러면 변경 범위가 다시 커진다. + +### 4.4 화면 간 데이터 동기화 고려 필요 + +- 상세 저장 후 목록 복귀 시 목록 재조회 전략을 정해야 한다. +- 현재는 한 컴포넌트 안이라 `await this.fetchList()` 로 즉시 동기화되지만, 분리 후에는 "목록 복귀 시 재조회" 또는 "캐시/스토어 반영" 중 하나를 선택해야 한다. + +### 4.5 현재 규모 대비 체감 이득이 제한적 + +- `SampleTableBoardPage.vue` 는 아직 1개 파일로 유지해도 큰 무리는 없는 크기다. +- 즉시 분리해도 복잡도가 극적으로 줄어드는 수준은 아니다. + +## 5. 현재 프로젝트 맥락에서의 판단 + +내 판단은 아래와 같다. + +### 결론 + +- **단기적으로는 "분리해도 좋지만 필수는 아님"** +- **중기적으로 기능 확장 가능성이 높다면 지금 분리 기준을 잡아두는 것이 유리함** + +판단 근거: + +- 현재 `SampleTableBoardPage.vue` 자체는 상대적으로 단순하다. +- 하지만 같은 폴더의 `SampleBoardPage.vue` 처럼 화면/검증/표현이 늘어나면 단일 파일 방식이 빠르게 무거워진다. +- 즉, 지금 파일만 놓고 보면 분리 효과는 "중간", 앞으로의 확장성을 고려하면 분리 가치는 "높아질 가능성이 큼" 이다. + +## 6. 추천 방향 + +두 가지 방향 중 하나를 추천한다. + +### 방향 A. 지금은 단일 파일 유지 + +다음 조건이면 유지가 적절하다. + +- 이 화면이 샘플/데모 성격에 가깝다. +- 상세 필드 증가 계획이 거의 없다. +- 검색, 페이징, 첨부, validation 강화 계획이 없다. +- 메뉴/라우트 구조 변경 비용을 최소화하고 싶다. + +이 경우에도 최소한 아래는 정리 가치가 있다. + +- API 호출부를 작은 메서드 단위로 정리 +- `validateForm()` 추가 +- `moveToCreate`, `moveToList`, `openDetail` 중복 방어 추가 + +### 방향 B. list/detail 두 파일로 분리 + +다음 조건이면 분리를 권장한다. + +- 실제 운영 화면으로 확장될 가능성이 높다. +- 상세 입력 필드/검증/첨부파일/권한 등이 추가될 수 있다. +- 목록에 검색, 필터, 정렬, 페이징이 붙을 예정이다. +- 유지보수 담당자가 여러 명이라 화면 책임 분리가 도움이 된다. + +현재 상황에서는 **방향 B를 준비하되, 실제 구현은 작게 시작하는 방식**이 가장 균형이 좋다. + +## 7. 가장 현실적인 실행 전략 + +바로 별도 route 두 개로 찢기보다, 아래 순서를 추천한다. + +### 1단계. 화면 컴포넌트 분리 + +라우트는 유지하고 내부에서 자식 컴포넌트로 먼저 분리한다. + +- 부모: `SampleTableBoardPage.vue` +- 자식1: `SampleTableBoardList.vue` +- 자식2: `SampleTableBoardDetail.vue` + +이 방식의 장점: + +- 메뉴/라우트 영향이 작다. +- 현재 query 기반 전환 로직을 그대로 쓸 수 있다. +- 템플릿과 화면 책임만 먼저 분리할 수 있다. + +즉, "두 개의 vue 로 나누는 효과"는 얻으면서도 라우트 리스크는 줄일 수 있다. + +### 2단계. 필요 시 라우트 분리 + +1단계 적용 후에도 아래 문제가 남으면 route 분리를 진행한다. + +- query 기반 상태 관리가 여전히 헷갈린다. +- 직접 진입 URL 을 더 명확히 나누고 싶다. +- breadcrumb, page title, 접근 권한을 화면별로 다르게 가져가야 한다. + +가능한 예: + +- 목록: `/sample-table` +- 신규: `/sample-table/create` +- 상세/수정: `/sample-table/:id` + +## 8. 권장 실행 계획 + +### 실행안 + +1. 현재 API/화면 책임을 목록과 상세로 분류한다. +2. `SampleTableBoardList.vue` 와 `SampleTableBoardDetail.vue` 를 같은 폴더에 추가한다. +3. 부모 `SampleTableBoardPage.vue` 는 query 해석과 화면 전환만 담당하게 축소한다. +4. 목록 조회는 부모 또는 목록 컴포넌트 중 한 곳에만 두고 책임을 명확히 정한다. +5. 저장/삭제 후 목록 재조회 규칙을 정한다. +6. 동작이 안정되면, 필요할 때만 route 분리를 검토한다. + +### 권장 책임 배치 + +- 부모 `SampleTableBoardPage.vue` + - query 해석 + - list/detail 표시 결정 + - 화면 간 이동 함수 +- `SampleTableBoardList.vue` + - 목록 테이블 렌더링 + - row 클릭/새 글 이벤트 emit +- `SampleTableBoardDetail.vue` + - 폼 렌더링 + - 상세 조회 + - 저장/삭제 + +## 9. 구현 시 주의점 + +- 저장 후 상세 유지 여부를 명확히 정해야 한다. +- 삭제 후 목록으로 이동하면서 목록 재조회가 누락되지 않게 해야 한다. +- 현재 `detailId` 는 숫자 변환 검증이 없으므로, 분리 시 입력값 검증 기준을 같이 정하는 것이 좋다. +- loading bar 를 여러 API 호출에서 중첩 사용하므로 호출 순서에 따라 UX 를 점검해야 한다. +- route 분리까지 가는 경우 메뉴 시스템의 `pagePath`, `meta` 연결 방식을 먼저 확인해야 한다. + +## 10. 최종 제안 + +최종적으로는 아래 제안을 권장한다. + +- **지금 당장 별도 route 두 개로 분리하는 것보다는, 먼저 list/detail 자식 컴포넌트 2개로 분리** +- **기능 확장이 시작되면 그 시점에 route 분리까지 진행** + +이유는 다음과 같다. + +- 현재 파일은 완전히 감당 불가능한 크기는 아니다. +- 하지만 관심사가 이미 나뉘어 있어서 화면 컴포넌트 분리의 실익은 분명하다. +- 반면 route 까지 한 번에 바꾸면 메뉴/경로 체계 영향으로 변경 비용이 커질 수 있다. + +즉, 현재 기준 최적해는 **"컴포넌트 분리 우선, 라우트 분리는 필요 시 후속 적용"** 이다. diff --git a/src/main/java/com/samsung/sampleTable/board/SampleTableBoardService.java b/src/main/java/com/samsung/sampleTable/board/SampleTableBoardService.java new file mode 100644 index 0000000..64440f8 --- /dev/null +++ b/src/main/java/com/samsung/sampleTable/board/SampleTableBoardService.java @@ -0,0 +1,18 @@ +package com.samsung.sampleTable.board; + +import java.util.List; + +import com.samsung.sampleTable.board.entity.SampleTableBoard; + +public interface SampleTableBoardService { + + List getSampleTableBoardList(); + + SampleTableBoard getSampleTableBoard(Integer id); + + SampleTableBoard createSampleTableBoard(SampleTableBoard sampleTableBoard); + + SampleTableBoard updateSampleTableBoard(SampleTableBoard sampleTableBoard); + + void deleteSampleTableBoard(Integer id); +} diff --git a/src/main/java/com/samsung/sampleTable/board/controller/SampleTableBoardController.java b/src/main/java/com/samsung/sampleTable/board/controller/SampleTableBoardController.java new file mode 100644 index 0000000..fe39988 --- /dev/null +++ b/src/main/java/com/samsung/sampleTable/board/controller/SampleTableBoardController.java @@ -0,0 +1,95 @@ +package com.samsung.sampleTable.board.controller; + +import java.util.HashMap; +import java.util.Map; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.samsung.sampleTable.board.SampleTableBoardService; +import com.samsung.sampleTable.board.entity.SampleTableBoard; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("/pg-board/sample-table/") +@Log4j2 +public class SampleTableBoardController { + + private final SampleTableBoardService sampleTableBoardService; + + public SampleTableBoardController(SampleTableBoardService sampleTableBoardService) { + + this.sampleTableBoardService = sampleTableBoardService; + } + + @Operation(summary = "page 초기정보.") + @PostMapping(value = "list/main.do", params = "method=page") + public Map page(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("menuName", "SampleTable Board"); + result.put("result", sampleTableBoardService.getSampleTableBoardList()); + return result; + } + + @Operation(summary = "SampleTable Board 목록 조회") + @PostMapping(value = "list/main.do", params = "method=search") + public Map list_search(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("result", sampleTableBoardService.getSampleTableBoardList()); + return result; + } + + @Operation(summary = "SampleTable Board 상세 조회") + @PostMapping(value = "item/main.do", params = "method=getSampleTableBoard") + public Map item_getSampleTableBoard(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("result", sampleTableBoardService.getSampleTableBoard(getPrimaryKey(params))); + return result; + } + + @Operation(summary = "SampleTable Board 등록") + @PostMapping(value = "item/main.do", params = "method=regist") + public Map item_regist(@RequestBody SampleTableBoard sampleTableBoard) throws Exception { + + Map result = new HashMap(); + result.put("result", sampleTableBoardService.createSampleTableBoard(sampleTableBoard)); + return result; + } + + @Operation(summary = "SampleTable Board 수정") + @PostMapping(value = "item/main.do", params = "method=update") + public Map item_update(@RequestBody SampleTableBoard sampleTableBoard) throws Exception { + + if (sampleTableBoard.getId() == null) { + throw new IllegalArgumentException("id 값은 필수입니다."); + } + + Map result = new HashMap(); + result.put("result", sampleTableBoardService.updateSampleTableBoard(sampleTableBoard)); + return result; + } + + @Operation(summary = "SampleTable Board 삭제") + @PostMapping(value = "item/main.do", params = "method=delete") + public String item_delete(@RequestBody Map params) throws Exception { + + sampleTableBoardService.deleteSampleTableBoard(getPrimaryKey(params)); + return "success"; + } + + private Integer getPrimaryKey(Map params) { + + if (params == null || params.get("id") == null) { + throw new IllegalArgumentException("id 값은 필수입니다."); + } + + String value = params.get("id").toString(); + return Integer.valueOf(value); + } +} diff --git a/src/main/java/com/samsung/sampleTable/board/dao/SampleTableBoardDao.java b/src/main/java/com/samsung/sampleTable/board/dao/SampleTableBoardDao.java new file mode 100644 index 0000000..8eda884 --- /dev/null +++ b/src/main/java/com/samsung/sampleTable/board/dao/SampleTableBoardDao.java @@ -0,0 +1,46 @@ +package com.samsung.sampleTable.board.dao; + +import java.util.List; + +import org.apache.ibatis.session.SqlSession; +import org.springframework.stereotype.Repository; + +import com.samsung.sampleTable.board.entity.SampleTableBoard; + +@Repository +public class SampleTableBoardDao { + + private static final String MAPPER = "sampleTableBoardMapper."; + + private final SqlSession sqlSession; + + public SampleTableBoardDao(SqlSession sqlSession) { + + this.sqlSession = sqlSession; + } + + public List selectSampleTableBoardList() { + + return sqlSession.selectList(MAPPER + "selectSampleTableBoardList"); + } + + public SampleTableBoard selectSampleTableBoard(Integer id) { + + return sqlSession.selectOne(MAPPER + "selectSampleTableBoard", id); + } + + public void insertSampleTableBoard(SampleTableBoard sampleTableBoard) { + + sqlSession.insert(MAPPER + "insertSampleTableBoard", sampleTableBoard); + } + + public void updateSampleTableBoard(SampleTableBoard sampleTableBoard) { + + sqlSession.update(MAPPER + "updateSampleTableBoard", sampleTableBoard); + } + + public void deleteSampleTableBoard(Integer id) { + + sqlSession.delete(MAPPER + "deleteSampleTableBoard", id); + } +} diff --git a/src/main/java/com/samsung/sampleTable/board/entity/SampleTableBoard.java b/src/main/java/com/samsung/sampleTable/board/entity/SampleTableBoard.java new file mode 100644 index 0000000..0e9101e --- /dev/null +++ b/src/main/java/com/samsung/sampleTable/board/entity/SampleTableBoard.java @@ -0,0 +1,21 @@ +package com.samsung.sampleTable.board.entity; + +import java.util.Date; +import lombok.Data; + +@Data +public class SampleTableBoard { + + private Integer id; + + private String title; + + private String content; + + private String contentType; + + private String author; + + private Date createdAt; + +} diff --git a/src/main/java/com/samsung/sampleTable/board/impl/SampleTableBoardServiceImpl.java b/src/main/java/com/samsung/sampleTable/board/impl/SampleTableBoardServiceImpl.java new file mode 100644 index 0000000..0ee1230 --- /dev/null +++ b/src/main/java/com/samsung/sampleTable/board/impl/SampleTableBoardServiceImpl.java @@ -0,0 +1,52 @@ +package com.samsung.sampleTable.board.impl; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import com.samsung.sampleTable.board.SampleTableBoardService; +import com.samsung.sampleTable.board.dao.SampleTableBoardDao; +import com.samsung.sampleTable.board.entity.SampleTableBoard; + +@Service +public class SampleTableBoardServiceImpl implements SampleTableBoardService { + + private final SampleTableBoardDao sampleTableBoardDao; + + public SampleTableBoardServiceImpl(SampleTableBoardDao sampleTableBoardDao) { + + this.sampleTableBoardDao = sampleTableBoardDao; + } + + @Override + public List getSampleTableBoardList() { + + return sampleTableBoardDao.selectSampleTableBoardList(); + } + + @Override + public SampleTableBoard getSampleTableBoard(Integer id) { + + return sampleTableBoardDao.selectSampleTableBoard(id); + } + + @Override + public SampleTableBoard createSampleTableBoard(SampleTableBoard sampleTableBoard) { + + sampleTableBoardDao.insertSampleTableBoard(sampleTableBoard); + return sampleTableBoardDao.selectSampleTableBoard(sampleTableBoard.getId()); + } + + @Override + public SampleTableBoard updateSampleTableBoard(SampleTableBoard sampleTableBoard) { + + sampleTableBoardDao.updateSampleTableBoard(sampleTableBoard); + return sampleTableBoardDao.selectSampleTableBoard(sampleTableBoard.getId()); + } + + @Override + public void deleteSampleTableBoard(Integer id) { + + sampleTableBoardDao.deleteSampleTableBoard(id); + } +} diff --git a/src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sampleTable-board.xml b/src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sampleTable-board.xml new file mode 100644 index 0000000..6845ccb --- /dev/null +++ b/src/main/resources/sql/mybatis/postgresql/mapper-mybatis-sampleTable-board.xml @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + INSERT INTO public.sample_table ( + title, + content, + content_type, + author + ) + VALUES ( + #{title}, + #{content}, + #{contentType}, + #{author} + ) + + + + UPDATE public.sample_table + SET + title = #{title}, + content = #{content}, + content_type = #{contentType}, + author = #{author} + WHERE id = #{id} + + + + DELETE FROM public.sample_table + WHERE id = #{id} + + diff --git a/tools/board-generator/config/board-generator.yml b/tools/board-generator/config/board-generator.yml new file mode 100644 index 0000000..cd5d56c --- /dev/null +++ b/tools/board-generator/config/board-generator.yml @@ -0,0 +1,23 @@ +database: + client: postgresql + host: 192.168.0.60 + port: 5432 + database: casaos + user: casaos + password: casaos + schema: public + +paths: + basePackage: com.samsung + javaRoot: src/main/java + mapperRoot: src/main/resources/sql/mybatis/postgresql + vueRoot: frontend/src/components/view/admin + vueBoardDir: pgBoard + generatedRoot: . + +conventions: + controllerStyle: formula_like + dateType: java.util.Date + apiStyle: main_do_method + requestBasePrefix: /pg-board + indent: tab diff --git a/tools/board-generator/package-lock.json b/tools/board-generator/package-lock.json new file mode 100644 index 0000000..6629d29 --- /dev/null +++ b/tools/board-generator/package-lock.json @@ -0,0 +1,248 @@ +{ + "name": "board-generator", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "board-generator", + "version": "0.1.0", + "dependencies": { + "handlebars": "^4.7.8", + "pg": "^8.13.1", + "yaml": "^2.6.0" + }, + "bin": { + "generate-board": "src/index.js" + }, + "engines": { + "node": ">=20.14.0" + } + }, + "node_modules/handlebars": { + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.5", + "neo-async": "^2.6.2", + "source-map": "^0.6.1", + "wordwrap": "^1.0.0" + }, + "bin": { + "handlebars": "bin/handlebars" + }, + "engines": { + "node": ">=0.4.7" + }, + "optionalDependencies": { + "uglify-js": "^3.1.4" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT" + }, + "node_modules/pg": { + "version": "8.21.0", + "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", + "integrity": "sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==", + "license": "MIT", + "dependencies": { + "pg-connection-string": "^2.13.0", + "pg-pool": "^3.14.0", + "pg-protocol": "^1.14.0", + "pg-types": "2.2.0", + "pgpass": "1.0.5" + }, + "engines": { + "node": ">= 16.0.0" + }, + "optionalDependencies": { + "pg-cloudflare": "^1.4.0" + }, + "peerDependencies": { + "pg-native": ">=3.0.1" + }, + "peerDependenciesMeta": { + "pg-native": { + "optional": true + } + } + }, + "node_modules/pg-cloudflare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.0.tgz", + "integrity": "sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==", + "license": "MIT", + "optional": true + }, + "node_modules/pg-connection-string": { + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.13.0.tgz", + "integrity": "sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==", + "license": "MIT" + }, + "node_modules/pg-int8": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz", + "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==", + "license": "ISC", + "engines": { + "node": ">=4.0.0" + } + }, + "node_modules/pg-pool": { + "version": "3.14.0", + "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz", + "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==", + "license": "MIT", + "peerDependencies": { + "pg": ">=8.0" + } + }, + "node_modules/pg-protocol": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.14.0.tgz", + "integrity": "sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==", + "license": "MIT" + }, + "node_modules/pg-types": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz", + "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==", + "license": "MIT", + "dependencies": { + "pg-int8": "1.0.1", + "postgres-array": "~2.0.0", + "postgres-bytea": "~1.0.0", + "postgres-date": "~1.0.4", + "postgres-interval": "^1.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/pgpass": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz", + "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==", + "license": "MIT", + "dependencies": { + "split2": "^4.1.0" + } + }, + "node_modules/postgres-array": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz", + "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/postgres-bytea": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz", + "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-date": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz", + "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/postgres-interval": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz", + "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "license": "ISC", + "engines": { + "node": ">= 10.x" + } + }, + "node_modules/uglify-js": { + "version": "3.19.3", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz", + "integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==", + "license": "BSD-2-Clause", + "optional": true, + "bin": { + "uglifyjs": "bin/uglifyjs" + }, + "engines": { + "node": ">=0.8.0" + } + }, + "node_modules/wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==", + "license": "MIT" + }, + "node_modules/xtend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", + "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==", + "license": "MIT", + "engines": { + "node": ">=0.4" + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/tools/board-generator/package.json b/tools/board-generator/package.json new file mode 100644 index 0000000..ea2abdf --- /dev/null +++ b/tools/board-generator/package.json @@ -0,0 +1,20 @@ +{ + "name": "board-generator", + "version": "0.1.0", + "private": true, + "type": "module", + "bin": { + "generate-board": "./src/index.js" + }, + "scripts": { + "start": "node ./src/index.js --help" + }, + "engines": { + "node": ">=20.14.0" + }, + "dependencies": { + "handlebars": "^4.7.8", + "pg": "^8.13.1", + "yaml": "^2.6.0" + } +} diff --git a/tools/board-generator/src/generator.js b/tools/board-generator/src/generator.js new file mode 100644 index 0000000..11825e3 --- /dev/null +++ b/tools/board-generator/src/generator.js @@ -0,0 +1,401 @@ +import fs from 'node:fs/promises'; // 파일 시스템 비동기 API를 사용한다. +import path from 'node:path'; // 경로 유틸리티를 사용한다. +import YAML from 'yaml'; // YAML 설정 파일 파서를 사용한다. +import Handlebars from 'handlebars'; // 템플릿 렌더링 엔진을 사용한다. +import pg from 'pg'; // PostgreSQL 클라이언트를 사용한다. + +const { Client } = pg; // PostgreSQL 클라이언트 생성자를 분리한다. + +const DEFAULT_CONFIG = { // 기본 설정 객체를 정의한다. + database: { // 데이터베이스 기본 설정을 정의한다. + client: 'postgresql', // 데이터베이스 종류를 설정한다. + host: '127.0.0.1', // 기본 호스트를 설정한다. + port: 5432, // 기본 포트를 설정한다. + database: 'app', // 기본 데이터베이스명을 설정한다. + user: 'app', // 기본 사용자명을 설정한다. + password: 'app', // 기본 비밀번호를 설정한다. + schema: 'public', // 기본 스키마를 설정한다. + }, // database 설정을 종료한다. + paths: { // 출력 경로 기본 설정을 정의한다. + basePackage: 'com.samsung', // 베이스 패키지를 설정한다. + javaRoot: 'src/main/java', // Java 루트 경로를 설정한다. + mapperRoot: 'src/main/resources/sql/mybatis/postgresql', // 매퍼 루트 경로를 설정한다. + vueRoot: 'frontend/src/components/view/admin', // Vue 루트 경로를 설정한다. + vueBoardDir: 'pgBoard', // Vue 하위 보드 경로를 설정한다. + generatedRoot: '.', // 생성 루트 경로를 프로젝트 루트로 설정한다. + }, // paths 설정을 종료한다. + conventions: { // 코드 생성 규칙 기본 설정을 정의한다. + controllerStyle: 'formula_like', // 컨트롤러 스타일을 설정한다. + dateType: 'java.util.Date', // 날짜 타입을 설정한다. + apiStyle: 'main_do_method', // API 스타일을 설정한다. + requestBasePrefix: '/pg-board', // 요청 경로 접두어를 설정한다. + indent: 'tab', // 들여쓰기 유형을 설정한다. + }, // conventions 설정을 종료한다. +}; // 기본 설정 정의를 종료한다. + +const AUDIT_COLUMNS = new Set([ // 감사 컬럼 집합을 정의한다. + 'created_at', // 생성일 컬럼을 등록한다. + 'created_datetime', // 생성일시 컬럼을 등록한다. + 'updated_at', // 수정일 컬럼을 등록한다. + 'updated_datetime', // 수정일시 컬럼을 등록한다. + 'first_reg_datetime', // 최초 등록일시 컬럼을 등록한다. + 'last_mod_datetime', // 최종 수정일시 컬럼을 등록한다. +]); // 감사 컬럼 집합 정의를 종료한다. + +const TEMPLATE_MAP = { // 템플릿 파일 매핑을 정의한다. + entity: 'entity.hbs', // Entity 템플릿 파일명을 설정한다. + service: 'service.hbs', // Service 템플릿 파일명을 설정한다. + dao: 'dao.hbs', // DAO 템플릿 파일명을 설정한다. + serviceImpl: 'serviceImpl.hbs', // ServiceImpl 템플릿 파일명을 설정한다. + controller: 'controller.hbs', // Controller 템플릿 파일명을 설정한다. + mapper: 'mapper.hbs', // Mapper 템플릿 파일명을 설정한다. + page: 'page.vue.hbs', // Vue 템플릿 파일명을 설정한다. +}; // 템플릿 파일 매핑 정의를 종료한다. + +function deepMerge(baseObject, overrideObject) { // 두 객체를 깊게 병합한다. + const mergedObject = { ...baseObject }; // 기본 객체를 얕게 복사한다. + for (const [key, value] of Object.entries(overrideObject || {})) { // 재정의 객체 항목을 순회한다. + if (value && typeof value === 'object' && !Array.isArray(value) && mergedObject[key] && typeof mergedObject[key] === 'object') { // 재귀 병합 대상인지 확인한다. + mergedObject[key] = deepMerge(mergedObject[key], value); // 중첩 객체를 재귀적으로 병합한다. + continue; // 다음 항목으로 이동한다. + } // 조건문을 종료한다. + mergedObject[key] = value; // 현재 값을 덮어쓴다. + } // 반복문을 종료한다. + return mergedObject; // 병합 결과를 반환한다. +} // 함수를 종료한다. + +async function fileExists(filePath) { // 파일 존재 여부를 확인한다. + return fs.access(filePath).then(() => true).catch(() => false); // 접근 가능 여부를 불리언으로 반환한다. +} // 함수를 종료한다. + +async function loadConfig(projectRoot) { // 설정 파일과 환경 변수를 읽는다. + const configPath = path.join(projectRoot, 'tools', 'board-generator', 'config', 'board-generator.yml'); // 설정 파일 경로를 계산한다. + let loadedConfig = DEFAULT_CONFIG; // 기본 설정으로 초기화한다. + if (await fileExists(configPath)) { // 설정 파일이 존재하는지 확인한다. + const rawConfig = await fs.readFile(configPath, 'utf8'); // YAML 설정 문자열을 읽는다. + loadedConfig = deepMerge(DEFAULT_CONFIG, YAML.parse(rawConfig) || {}); // 기본 설정과 사용자 설정을 병합한다. + } // 조건문을 종료한다. + return deepMerge(loadedConfig, { // 환경 변수 재정의를 반영해 반환한다. + database: { // 데이터베이스 재정의 항목을 정의한다. + host: process.env.BOARD_DB_HOST || loadedConfig.database.host, // 호스트 재정의를 반영한다. + port: Number(process.env.BOARD_DB_PORT || loadedConfig.database.port), // 포트 재정의를 반영한다. + database: process.env.BOARD_DB_NAME || loadedConfig.database.database, // 데이터베이스명 재정의를 반영한다. + user: process.env.BOARD_DB_USER || loadedConfig.database.user, // 사용자명 재정의를 반영한다. + password: process.env.BOARD_DB_PASSWORD || loadedConfig.database.password, // 비밀번호 재정의를 반영한다. + schema: process.env.BOARD_DB_SCHEMA || loadedConfig.database.schema, // 스키마 재정의를 반영한다. + }, // database 재정의 정의를 종료한다. + }); // 병합 결과 반환을 종료한다. +} // 함수를 종료한다. + +function splitSchemaAndTable(inputTableName, defaultSchema) { // 스키마와 테이블명을 분리한다. + const normalizedValue = String(inputTableName || '').trim(); // 입력 테이블명을 문자열로 정규화한다. + if (!normalizedValue) { // 입력값이 비어 있는지 확인한다. + throw new Error('필수 옵션 --table 이 필요합니다.'); // 필수 옵션 누락 예외를 발생시킨다. + } // 조건문을 종료한다. + if (!normalizedValue.includes('.')) { // 스키마 구분자가 없는지 확인한다. + return { schema: defaultSchema, table: normalizedValue }; // 기본 스키마와 함께 반환한다. + } // 조건문을 종료한다. + const [schema, table] = normalizedValue.split('.'); // 스키마와 테이블명을 분리한다. + return { schema, table }; // 분리된 값을 반환한다. +} // 함수를 종료한다. + +function singularizeTableName(tableName) { // 테이블명을 단수형에 가깝게 보정한다. + const normalizedValue = String(tableName || '').trim(); // 입력 테이블명을 정규화한다. + if (normalizedValue.endsWith('ies')) { // ies 로 끝나는지를 확인한다. + return `${normalizedValue.slice(0, -3)}y`; // y 형태로 단수형을 만든다. + } // 조건문을 종료한다. + if (normalizedValue.endsWith('s') && !normalizedValue.endsWith('ss')) { // 일반적인 복수형 s 인지 확인한다. + return normalizedValue.slice(0, -1); // 마지막 s 를 제거한다. + } // 조건문을 종료한다. + return normalizedValue; // 변경 없이 반환한다. +} // 함수를 종료한다. + +function toPascalCase(value) { // 문자열을 PascalCase 로 변환한다. + return String(value || '') // 입력값을 문자열로 변환한다. + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') // 기존 camelCase 경계를 구분 문자로 분리한다. + .split(/[^a-zA-Z0-9]+/) // 영숫자가 아닌 구분자로 분리한다. + .filter(Boolean) // 빈 문자열을 제거한다. + .map(token => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()) // 각 토큰을 PascalCase 조각으로 변환한다. + .join(''); // 조각들을 하나의 문자열로 합친다. +} // 함수를 종료한다. + +function toCamelCase(value) { // 문자열을 camelCase 로 변환한다. + const pascalCaseValue = toPascalCase(value); // PascalCase 값을 계산한다. + return pascalCaseValue ? pascalCaseValue.charAt(0).toLowerCase() + pascalCaseValue.slice(1) : ''; // 첫 글자를 소문자로 바꿔 반환한다. +} // 함수를 종료한다. + +function toKebabCase(value) { // 문자열을 kebab-case 로 변환한다. + return String(value || '') // 입력값을 문자열로 변환한다. + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') // camelCase 경계를 하이픈으로 분리한다. + .replace(/[_\s]+/g, '-') // 언더스코어와 공백을 하이픈으로 치환한다. + .toLowerCase(); // 전체를 소문자로 변환한다. +} // 함수를 종료한다. + +function toDisplayLabel(value) { // 컬럼명을 화면용 라벨로 변환한다. + return String(value || '') // 입력값을 문자열로 변환한다. + .split('_') // 언더스코어 기준으로 분리한다. + .filter(Boolean) // 빈 항목을 제거한다. + .map(token => token.charAt(0).toUpperCase() + token.slice(1).toLowerCase()) // 각 토큰을 화면용 대소문자로 변환한다. + .join(' '); // 공백으로 합쳐 반환한다. +} // 함수를 종료한다. + +function toGetterName(propertyName) { // getter 메서드명을 만든다. + const normalizedValue = String(propertyName || ''); // 프로퍼티명을 정규화한다. + return `get${normalizedValue.charAt(0).toUpperCase()}${normalizedValue.slice(1)}`; // getter 메서드명을 반환한다. +} // 함수를 종료한다. + +function buildModulePath(tableName) { // 테이블명을 기본 모듈 경로로 변환한다. + if (String(tableName).includes('_')) { // 언더스코어가 포함되는지 확인한다. + return String(tableName).split('_').filter(Boolean).join('/'); // 언더스코어를 슬래시 경로로 변환한다. + } // 조건문을 종료한다. + return String(tableName); // 원본 값을 그대로 반환한다. +} // 함수를 종료한다. + +function mapColumnType(column, config) { // DB 타입을 Java 타입으로 매핑한다. + const normalizedDataType = String(column.dataType || '').toLowerCase(); // data_type 값을 소문자로 정규화한다. + const normalizedUdtName = String(column.udtName || '').toLowerCase(); // udt_name 값을 소문자로 정규화한다. + const dateImportType = config.conventions.dateType || 'java.util.Date'; // 날짜 타입 설정을 읽는다. + if (['varchar', 'text', 'char', 'bpchar'].includes(normalizedDataType) || ['varchar', 'text', 'bpchar'].includes(normalizedUdtName)) { // 문자열 타입인지 확인한다. + return { javaType: 'String', myBatisType: 'java.lang.String', importType: null, supported: true }; // 문자열 매핑 결과를 반환한다. + } // 조건문을 종료한다. + if (['smallint', 'integer'].includes(normalizedDataType) || ['int2', 'int4', 'serial', 'smallint', 'integer'].includes(normalizedUdtName)) { // Integer 타입인지 확인한다. + return { javaType: 'Integer', myBatisType: 'java.lang.Integer', importType: null, supported: true }; // Integer 매핑 결과를 반환한다. + } // 조건문을 종료한다. + if (normalizedDataType === 'bigint' || ['int8', 'bigserial', 'bigint'].includes(normalizedUdtName)) { // Long 타입인지 확인한다. + return { javaType: 'Long', myBatisType: 'java.lang.Long', importType: null, supported: true }; // Long 매핑 결과를 반환한다. + } // 조건문을 종료한다. + if (['numeric', 'decimal'].includes(normalizedDataType) || normalizedUdtName === 'numeric') { // BigDecimal 타입인지 확인한다. + return { javaType: 'BigDecimal', myBatisType: 'java.math.BigDecimal', importType: 'java.math.BigDecimal', supported: true }; // BigDecimal 매핑 결과를 반환한다. + } // 조건문을 종료한다. + if (normalizedDataType === 'boolean' || ['bool', 'boolean'].includes(normalizedUdtName)) { // Boolean 타입인지 확인한다. + return { javaType: 'Boolean', myBatisType: 'java.lang.Boolean', importType: null, supported: true }; // Boolean 매핑 결과를 반환한다. + } // 조건문을 종료한다. + if (['date', 'timestamp without time zone', 'timestamp with time zone'].includes(normalizedDataType) || ['date', 'timestamp', 'timestamptz'].includes(normalizedUdtName)) { // 날짜 계열 타입인지 확인한다. + return { javaType: 'Date', myBatisType: dateImportType, importType: dateImportType, supported: true }; // 날짜 타입 매핑 결과를 반환한다. + } // 조건문을 종료한다. + return { javaType: 'Object', myBatisType: 'java.lang.Object', importType: null, supported: false }; // 지원하지 않는 타입 결과를 반환한다. +} // 함수를 종료한다. + +async function readMetadata(config, inputTableName) { // PostgreSQL 메타데이터를 조회한다. + const tableInfo = splitSchemaAndTable(inputTableName, config.database.schema || 'public'); // 스키마와 테이블명을 분리한다. + const client = new Client({ // PostgreSQL 연결 설정을 구성한다. + host: config.database.host, // DB 호스트를 설정한다. + port: config.database.port, // DB 포트를 설정한다. + database: config.database.database, // DB 이름을 설정한다. + user: config.database.user, // DB 사용자를 설정한다. + password: config.database.password, // DB 비밀번호를 설정한다. + }); // 클라이언트 생성을 종료한다. + const metadataSql = ` + SELECT + c.column_name AS "columnName", + c.data_type AS "dataType", + c.udt_name AS "udtName", + (c.is_nullable = 'YES') AS "nullable", + c.ordinal_position AS "ordinalPosition", + c.column_default AS "columnDefault", + COALESCE(c.identity_generation, '') AS "identityGeneration", + EXISTS ( + SELECT 1 + FROM information_schema.table_constraints tc + JOIN information_schema.key_column_usage kcu + ON tc.constraint_name = kcu.constraint_name + AND tc.table_schema = kcu.table_schema + AND tc.table_name = kcu.table_name + WHERE tc.constraint_type = 'PRIMARY KEY' + AND tc.table_schema = c.table_schema + AND tc.table_name = c.table_name + AND kcu.column_name = c.column_name + ) AS "primaryKey" + FROM information_schema.columns c + WHERE c.table_schema = $1 + AND c.table_name = $2 + ORDER BY c.ordinal_position + `; // SQL 문자열 정의를 종료한다. + try { // DB 연결과 조회를 시도한다. + await client.connect(); // 데이터베이스에 연결한다. + const queryResult = await client.query(metadataSql, [tableInfo.schema, tableInfo.table]); // 메타데이터 조회 SQL 을 실행한다. + if (!queryResult.rows.length) { // 조회 결과가 없는지 확인한다. + throw new Error(`Table not found: ${tableInfo.schema}.${tableInfo.table}`); // 테이블 미존재 예외를 발생시킨다. + } // 조건문을 종료한다. + const columns = queryResult.rows.map(row => { // 각 컬럼의 파생 정보를 계산한다. + const typeInfo = mapColumnType(row, config); // 컬럼 타입 매핑 정보를 계산한다. + const autoIncrement = Boolean(row.identityGeneration) || String(row.columnDefault || '').includes('nextval'); // 자동 증가 여부를 계산한다. + return { ...row, ...typeInfo, autoIncrement }; // 컬럼 메타데이터와 파생 정보를 합쳐 반환한다. + }); // 컬럼 배열 생성 처리를 종료한다. + return { schema: tableInfo.schema, tableName: tableInfo.table, columns }; // 최종 메타데이터를 반환한다. + } finally { // 연결 종료를 보장한다. + await client.end().catch(() => undefined); // 연결 종료 실패는 무시한다. + } // try-finally 를 종료한다. +} // 함수를 종료한다. + +function validateMetadata(metadata) { // 조회된 메타데이터를 검증한다. + const primaryKeyColumns = metadata.columns.filter(column => column.primaryKey); // 기본키 컬럼 목록을 구한다. + if (!primaryKeyColumns.length) { // 기본키가 없는지 확인한다. + throw new Error('기본키가 없는 테이블은 지원하지 않습니다.'); // 기본키 누락 예외를 발생시킨다. + } // 조건문을 종료한다. + if (primaryKeyColumns.length > 1) { // 기본키가 여러 개인지 확인한다. + throw new Error('복합 기본키는 지원하지 않습니다.'); // 복합 기본키 예외를 발생시킨다. + } // 조건문을 종료한다. + const unsupportedColumns = metadata.columns.filter(column => !column.supported); // 지원하지 않는 컬럼 목록을 구한다. + if (unsupportedColumns.length) { // 지원 불가 컬럼이 있는지 확인한다. + const unsupportedColumnMessage = unsupportedColumns.map(column => `${column.columnName}(${column.dataType || column.udtName})`).join(', '); // 메시지용 컬럼 문자열을 만든다. + throw new Error(`지원하지 않는 컬럼 타입이 있습니다: ${unsupportedColumnMessage}`); // 지원 불가 타입 예외를 발생시킨다. + } // 조건문을 종료한다. +} // 함수를 종료한다. + +function buildPrimaryKeyParser(primaryKeyColumn) { // 기본키 문자열 파싱 식을 계산한다. + if (primaryKeyColumn.javaType === 'Integer') { // Integer 타입인지 확인한다. + return { parserExpression: 'Integer.valueOf(value)', importType: null }; // Integer 파싱 식을 반환한다. + } // 조건문을 종료한다. + if (primaryKeyColumn.javaType === 'Long') { // Long 타입인지 확인한다. + return { parserExpression: 'Long.valueOf(value)', importType: null }; // Long 파싱 식을 반환한다. + } // 조건문을 종료한다. + if (primaryKeyColumn.javaType === 'BigDecimal') { // BigDecimal 타입인지 확인한다. + return { parserExpression: 'new BigDecimal(value)', importType: 'java.math.BigDecimal' }; // BigDecimal 파싱 식을 반환한다. + } // 조건문을 종료한다. + return { parserExpression: 'value', importType: null }; // 기본 문자열 파싱 식을 반환한다. +} // 함수를 종료한다. + +function buildContext(config, options, metadata) { // 템플릿 렌더링 컨텍스트를 생성한다. + const singularTableName = singularizeTableName(metadata.tableName); // 단수형 테이블명을 계산한다. + const entityName = options.entity || toPascalCase(singularTableName); // Entity 이름을 계산한다. + const modulePath = options.module || buildModulePath(singularTableName); // 모듈 경로를 계산한다. + const entityVarName = toCamelCase(entityName); // Entity 변수명을 계산한다. + const modulePackage = modulePath.split('/').filter(Boolean).join('.'); // 모듈 패키지명을 계산한다. + const basePackage = config.paths.basePackage; // 베이스 패키지를 읽는다. + const basePackagePath = basePackage.split('.').join('/'); // 베이스 패키지를 경로로 변환한다. + const rootPackage = modulePackage ? `${basePackage}.${modulePackage}` : basePackage; // 루트 패키지를 계산한다. + const requestBasePath = `${String(config.conventions.requestBasePrefix || '/pg-board').replace(/\/$/, '')}/${toKebabCase(metadata.tableName)}/`; // API 기본 경로를 계산한다. + const columns = metadata.columns.map(column => { // 컬럼별 파생 정보를 계산한다. + const propertyName = toCamelCase(column.columnName); // 프로퍼티명을 계산한다. + const isAuditColumn = AUDIT_COLUMNS.has(column.columnName); // 감사 컬럼 여부를 계산한다. + const isTextarea = column.dataType === 'text' || /content|desc|memo/i.test(column.columnName); // textarea 후보 여부를 계산한다. + return { // 파생 정보를 포함한 컬럼 객체를 반환한다. + ...column, // 원본 컬럼 정보를 유지한다. + propertyName, // 프로퍼티명을 저장한다. + getterName: toGetterName(propertyName), // getter 메서드명을 저장한다. + label: toDisplayLabel(column.columnName), // 화면 라벨을 저장한다. + vueItemExpression: `{{ item.${propertyName} }}`, // Vue 목록 출력 표현식을 저장한다. + isAuditColumn, // 감사 컬럼 여부를 저장한다. + isTextarea, // textarea 여부를 저장한다. + isFormCandidate: !column.primaryKey && !column.autoIncrement && !isAuditColumn, // 폼 포함 여부를 저장한다. + isListCandidate: column.primaryKey || !isTextarea || isAuditColumn, // 목록 포함 여부를 저장한다. + }; // 컬럼 객체 생성을 종료한다. + }); // 컬럼 배열 생성을 종료한다. + const primaryKeyColumn = columns.find(column => column.primaryKey); // 기본키 컬럼을 찾는다. + const parserInfo = buildPrimaryKeyParser(primaryKeyColumn); // 기본키 파싱 정보를 계산한다. + const formColumns = columns.filter(column => column.isFormCandidate); // 폼 컬럼 목록을 계산한다. + const insertColumns = columns.filter(column => !column.primaryKey && !column.autoIncrement && !column.isAuditColumn); // insert 컬럼 목록을 계산한다. + const updateColumns = columns.filter(column => !column.primaryKey && !column.isAuditColumn); // update 컬럼 목록을 계산한다. + const listColumns = (columns.filter(column => column.isListCandidate).length ? columns.filter(column => column.isListCandidate) : columns).slice(0, 4); // 목록 컬럼을 계산한다. + const entityImports = Array.from(new Set(columns.map(column => column.importType).filter(Boolean))).sort(); // Entity import 목록을 계산한다. + const controllerImports = Array.from(new Set([parserInfo.importType].filter(Boolean))).sort(); // Controller import 목록을 계산한다. + const javaModuleRoot = path.posix.join(config.paths.javaRoot, basePackagePath, modulePath); // Java 모듈 루트 경로를 계산한다. + return { // 최종 렌더링 컨텍스트를 반환한다. + config, // 설정 객체를 포함한다. + metadata, // 메타데이터를 포함한다. + tableName: metadata.tableName, // 테이블명을 포함한다. + modulePath, // 모듈 경로를 포함한다. + entityName, // Entity 이름을 포함한다. + entityVarName, // Entity 변수명을 포함한다. + serviceName: `${entityName}Service`, // 서비스 인터페이스명을 포함한다. + serviceImplName: `${entityName}ServiceImpl`, // 서비스 구현체명을 포함한다. + daoName: `${entityName}Dao`, // DAO 이름을 포함한다. + controllerName: `${entityName}Controller`, // 컨트롤러 이름을 포함한다. + mapperNamespace: `${entityVarName}Mapper`, // 매퍼 네임스페이스를 포함한다. + mapperFileName: `mapper-mybatis-${modulePath.replace(/\//g, '-')}.xml`, // 매퍼 파일명을 포함한다. + vueComponentName: `${entityName}Page`, // Vue 컴포넌트명을 포함한다. + vueFileName: `${entityName}Page.vue`, // Vue 파일명을 포함한다. + menuName: options.menuName || entityName, // 화면 표시명을 포함한다. + requestBasePath, // API 기본 경로를 포함한다. + detailModeExpression: "{{ mode === 'CREATE' ? '등록' : '상세' }}", // 상세 제목 표현식을 포함한다. + totalCountExpression: '{{ itemList.length }}', // 목록 건수 표현식을 포함한다. + rootPackage, // 루트 패키지를 포함한다. + entityPackage: `${rootPackage}.entity`, // Entity 패키지를 포함한다. + daoPackage: `${rootPackage}.dao`, // DAO 패키지를 포함한다. + implPackage: `${rootPackage}.impl`, // 구현체 패키지를 포함한다. + controllerPackage: `${rootPackage}.controller`, // 컨트롤러 패키지를 포함한다. + entityFqcn: `${rootPackage}.entity.${entityName}`, // Entity 전체 클래스명을 포함한다. + primaryKeyColumn, // 기본키 컬럼 정보를 포함한다. + primaryKeyProperty: primaryKeyColumn.propertyName, // 기본키 프로퍼티명을 포함한다. + primaryKeyGetterName: primaryKeyColumn.getterName, // 기본키 getter 명을 포함한다. + primaryKeyJavaType: primaryKeyColumn.javaType, // 기본키 Java 타입을 포함한다. + primaryKeyMyBatisType: primaryKeyColumn.myBatisType, // 기본키 MyBatis 타입을 포함한다. + primaryKeyParseExpression: parserInfo.parserExpression, // 기본키 파싱 식을 포함한다. + columns, // 전체 컬럼 목록을 포함한다. + formColumns, // 폼 컬럼 목록을 포함한다. + insertColumns, // insert 컬럼 목록을 포함한다. + updateColumns, // update 컬럼 목록을 포함한다. + listColumns, // 목록 컬럼 목록을 포함한다. + entityImports, // Entity import 목록을 포함한다. + controllerImports, // Controller import 목록을 포함한다. + javaEntityPath: path.posix.join(javaModuleRoot, 'entity', `${entityName}.java`), // Entity 파일 경로를 포함한다. + javaServicePath: path.posix.join(javaModuleRoot, `${entityName}Service.java`), // Service 파일 경로를 포함한다. + javaDaoPath: path.posix.join(javaModuleRoot, 'dao', `${entityName}Dao.java`), // DAO 파일 경로를 포함한다. + javaServiceImplPath: path.posix.join(javaModuleRoot, 'impl', `${entityName}ServiceImpl.java`), // ServiceImpl 파일 경로를 포함한다. + javaControllerPath: path.posix.join(javaModuleRoot, 'controller', `${entityName}Controller.java`), // Controller 파일 경로를 포함한다. + xmlMapperPath: path.posix.join(config.paths.mapperRoot, `mapper-mybatis-${modulePath.replace(/\//g, '-')}.xml`), // XML 파일 경로를 포함한다. + vuePagePath: path.posix.join(config.paths.vueRoot, config.paths.vueBoardDir, `${entityName}Page.vue`), // Vue 파일 경로를 포함한다. + }; // 컨텍스트 반환을 종료한다. +} // 함수를 종료한다. + +async function renderTemplate(projectRoot, templateName, context) { // 템플릿 파일을 렌더링한다. + const templatePath = path.join(projectRoot, 'tools', 'board-generator', 'templates', templateName); // 템플릿 파일 경로를 계산한다. + const source = await fs.readFile(templatePath, 'utf8'); // 템플릿 원문을 읽는다. + const template = Handlebars.compile(source, { noEscape: true }); // Handlebars 템플릿을 컴파일한다. + return template(context); // 렌더링 결과를 반환한다. +} // 함수를 종료한다. + +async function buildFilePlan(projectRoot, context, scope) { // 생성 파일 계획을 구성한다. + const plannedFiles = []; // 생성 파일 목록을 초기화한다. + if (scope === 'all' || scope === 'backend') { // 백엔드 파일 생성 범위인지 확인한다. + plannedFiles.push({ relativePath: context.javaEntityPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.entity, context) }); // Entity 파일 계획을 추가한다. + plannedFiles.push({ relativePath: context.javaServicePath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.service, context) }); // Service 파일 계획을 추가한다. + plannedFiles.push({ relativePath: context.javaDaoPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.dao, context) }); // DAO 파일 계획을 추가한다. + plannedFiles.push({ relativePath: context.javaServiceImplPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.serviceImpl, context) }); // ServiceImpl 파일 계획을 추가한다. + plannedFiles.push({ relativePath: context.javaControllerPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.controller, context) }); // Controller 파일 계획을 추가한다. + } // 조건문을 종료한다. + if (scope === 'all' || scope === 'xml') { // XML 파일 생성 범위인지 확인한다. + plannedFiles.push({ relativePath: context.xmlMapperPath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.mapper, context) }); // XML 파일 계획을 추가한다. + } // 조건문을 종료한다. + if (scope === 'all' || scope === 'frontend') { // 프론트 파일 생성 범위인지 확인한다. + plannedFiles.push({ relativePath: context.vuePagePath, content: await renderTemplate(projectRoot, TEMPLATE_MAP.page, context) }); // Vue 파일 계획을 추가한다. + } // 조건문을 종료한다. + return plannedFiles; // 생성 파일 계획 목록을 반환한다. +} // 함수를 종료한다. + +function previewFiles(projectRoot, outputRoot, files) { // preview 결과를 출력한다. + console.log('Preview mode enabled'); // preview 모드 시작 메시지를 출력한다. + for (const file of files) { // 파일 목록을 순회한다. + console.log(path.join(projectRoot, outputRoot, file.relativePath)); // 각 파일의 전체 경로를 출력한다. + } // 반복문을 종료한다. +} // 함수를 종료한다. + +async function writeFiles(projectRoot, outputRoot, files, force) { // 파일들을 실제로 기록한다. + for (const file of files) { // 파일 목록을 순회한다. + const fullPath = path.join(projectRoot, outputRoot, file.relativePath); // 전체 저장 경로를 계산한다. + await fs.mkdir(path.dirname(fullPath), { recursive: true }); // 상위 디렉터리를 생성한다. + if (!force && (await fileExists(fullPath))) { // 강제 덮어쓰기가 아니고 파일이 이미 있는지 확인한다. + throw new Error(`Output path already exists. Use --force to overwrite: ${fullPath}`); // 파일 중복 예외를 발생시킨다. + } // 조건문을 종료한다. + await fs.writeFile(fullPath, file.content, 'utf8'); // 렌더링 결과를 UTF-8 로 저장한다. + } // 반복문을 종료한다. +} // 함수를 종료한다. + +export async function runGenerator(projectRoot, options) { // 생성기 메인 흐름을 실행한다. + const config = await loadConfig(projectRoot); // 설정 파일과 환경 변수를 로드한다. + const metadata = await readMetadata(config, options.table); // DB 메타데이터를 조회한다. + validateMetadata(metadata); // 메타데이터를 검증한다. + const context = buildContext(config, options, metadata); // 템플릿 컨텍스트를 계산한다. + const outputRoot = options.output || config.paths.generatedRoot; // 출력 루트 경로를 결정한다. + const files = await buildFilePlan(projectRoot, context, options.scope || 'all'); // 생성 파일 계획을 만든다. + console.log(`Table found: ${metadata.schema}.${metadata.tableName}`); // 조회된 테이블 정보를 출력한다. + console.log(`Primary key: ${context.primaryKeyColumn.columnName}(${context.primaryKeyJavaType})`); // 기본키 정보를 출력한다. + console.log(`Files planned: ${files.length}`); // 생성 대상 파일 개수를 출력한다. + if (options.preview) { // preview 모드인지 확인한다. + previewFiles(projectRoot, outputRoot, files); // 파일 경로 미리보기를 출력한다. + return; // 파일 저장 없이 종료한다. + } // 조건문을 종료한다. + await writeFiles(projectRoot, outputRoot, files, options.force); // 렌더링된 파일들을 저장한다. + console.log(`Files generated: ${files.length}`); // 생성 완료 메시지를 출력한다. +} // 함수를 종료한다. diff --git a/tools/board-generator/src/index.js b/tools/board-generator/src/index.js new file mode 100644 index 0000000..6e6ad72 --- /dev/null +++ b/tools/board-generator/src/index.js @@ -0,0 +1,55 @@ +#!/usr/bin/env node +import path from 'node:path'; // 경로 유틸리티를 사용한다. +import { fileURLToPath } from 'node:url'; // 파일 URL 을 실제 경로로 변환한다. +import { runGenerator } from './generator.js'; // 생성기 실행 함수를 사용한다. + +const HELP_TEXT = ` +Board Generator CLI + +Usage: + generate-board --table [options] + +Options: + --table DB 테이블명 + --module 모듈 경로 예: sample/board + --entity Entity 클래스명 예: SampleBoard + --menu-name 화면 표시명 예: Sample Board + --output 생성 루트 경로 예: . 또는 generated + --scope all | backend | xml | frontend + --preview 생성 경로만 미리 출력 + --force 기존 파일 덮어쓰기 허용 + --help 도움말 출력 +`; // 도움말 문자열 정의를 종료한다. + +function parseArgs(argv) { // 명령행 인자를 파싱한다. + const parsedOptions = {}; // 파싱 결과 객체를 초기화한다. + for (let index = 0; index < argv.length; index += 1) { // 전달된 인자 배열을 순회한다. + const currentArg = argv[index]; // 현재 인자를 저장한다. + if (currentArg === '--preview' || currentArg === '--force' || currentArg === '--help') { // 불리언 플래그 옵션인지 확인한다. + parsedOptions[currentArg.replace(/^--/, '')] = true; // 플래그 값을 true 로 저장한다. + continue; // 다음 인자로 이동한다. + } // 조건문을 종료한다. + if (!currentArg.startsWith('--')) { // 옵션 형식이 아닌 인자인지 확인한다. + continue; // 위치 인자는 무시하고 다음으로 이동한다. + } // 조건문을 종료한다. + const nextValue = argv[index + 1]; // 다음 값을 읽는다. + parsedOptions[currentArg.replace(/^--/, '').replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())] = nextValue; // 옵션 이름을 camelCase 키로 저장한다. + index += 1; // 값 인자를 소비했으므로 인덱스를 증가시킨다. + } // 반복문을 종료한다. + return parsedOptions; // 파싱 결과를 반환한다. +} // 함수를 종료한다. + +async function main() { // CLI 메인 흐름을 실행한다. + const options = parseArgs(process.argv.slice(2)); // 명령행 인자를 파싱한다. + if (options.help || !options.table) { // 도움말 출력 조건인지 확인한다. + console.log(HELP_TEXT); // 도움말을 출력한다. + return; // 프로그램을 종료한다. + } // 조건문을 종료한다. + const projectRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../..'); // 프로젝트 루트 경로를 계산한다. + await runGenerator(projectRoot, options); // 생성기 실행 함수를 호출한다. +} // 함수를 종료한다. + +main().catch(error => { // 메인 실행 중 예외를 처리한다. + console.error(error.message); // 원인 중심 오류 메시지를 출력한다. + process.exitCode = 1; // 종료 코드를 실패로 설정한다. +}); // 예외 처리 구문을 종료한다. diff --git a/tools/board-generator/templates/controller.hbs b/tools/board-generator/templates/controller.hbs new file mode 100644 index 0000000..df118e9 --- /dev/null +++ b/tools/board-generator/templates/controller.hbs @@ -0,0 +1,98 @@ +package {{controllerPackage}}; + +import java.util.HashMap; +import java.util.Map; +{{#each controllerImports}} +import {{this}}; +{{/each}} +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import {{rootPackage}}.{{serviceName}}; +import {{entityFqcn}}; + +import io.swagger.v3.oas.annotations.Operation; +import lombok.extern.log4j.Log4j2; + +@RestController +@RequestMapping("{{requestBasePath}}") +@Log4j2 +public class {{controllerName}} { + + private final {{serviceName}} {{entityVarName}}Service; + + public {{controllerName}}({{serviceName}} {{entityVarName}}Service) { + + this.{{entityVarName}}Service = {{entityVarName}}Service; + } + + @Operation(summary = "page 초기정보.") + @PostMapping(value = "list/main.do", params = "method=page") + public Map page(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("menuName", "{{menuName}}"); + result.put("result", {{entityVarName}}Service.get{{entityName}}List()); + return result; + } + + @Operation(summary = "{{menuName}} 목록 조회") + @PostMapping(value = "list/main.do", params = "method=search") + public Map list_search(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("result", {{entityVarName}}Service.get{{entityName}}List()); + return result; + } + + @Operation(summary = "{{menuName}} 상세 조회") + @PostMapping(value = "item/main.do", params = "method=get{{entityName}}") + public Map item_get{{entityName}}(@RequestBody Map params) throws Exception { + + Map result = new HashMap(); + result.put("result", {{entityVarName}}Service.get{{entityName}}(getPrimaryKey(params))); + return result; + } + + @Operation(summary = "{{menuName}} 등록") + @PostMapping(value = "item/main.do", params = "method=regist") + public Map item_regist(@RequestBody {{entityName}} {{entityVarName}}) throws Exception { + + Map result = new HashMap(); + result.put("result", {{entityVarName}}Service.create{{entityName}}({{entityVarName}})); + return result; + } + + @Operation(summary = "{{menuName}} 수정") + @PostMapping(value = "item/main.do", params = "method=update") + public Map item_update(@RequestBody {{entityName}} {{entityVarName}}) throws Exception { + + if ({{entityVarName}}.{{primaryKeyGetterName}}() == null) { + throw new IllegalArgumentException("{{primaryKeyProperty}} 값은 필수입니다."); + } + + Map result = new HashMap(); + result.put("result", {{entityVarName}}Service.update{{entityName}}({{entityVarName}})); + return result; + } + + @Operation(summary = "{{menuName}} 삭제") + @PostMapping(value = "item/main.do", params = "method=delete") + public String item_delete(@RequestBody Map params) throws Exception { + + {{entityVarName}}Service.delete{{entityName}}(getPrimaryKey(params)); + return "success"; + } + + private {{primaryKeyJavaType}} getPrimaryKey(Map params) { + + if (params == null || params.get("{{primaryKeyProperty}}") == null) { + throw new IllegalArgumentException("{{primaryKeyProperty}} 값은 필수입니다."); + } + + String value = params.get("{{primaryKeyProperty}}").toString(); + return {{primaryKeyParseExpression}}; + } +} diff --git a/tools/board-generator/templates/dao.hbs b/tools/board-generator/templates/dao.hbs new file mode 100644 index 0000000..b553c95 --- /dev/null +++ b/tools/board-generator/templates/dao.hbs @@ -0,0 +1,46 @@ +package {{daoPackage}}; + +import java.util.List; + +import org.apache.ibatis.session.SqlSession; +import org.springframework.stereotype.Repository; + +import {{entityFqcn}}; + +@Repository +public class {{daoName}} { + + private static final String MAPPER = "{{mapperNamespace}}."; + + private final SqlSession sqlSession; + + public {{daoName}}(SqlSession sqlSession) { + + this.sqlSession = sqlSession; + } + + public List<{{entityName}}> select{{entityName}}List() { + + return sqlSession.selectList(MAPPER + "select{{entityName}}List"); + } + + public {{entityName}} select{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) { + + return sqlSession.selectOne(MAPPER + "select{{entityName}}", {{primaryKeyProperty}}); + } + + public void insert{{entityName}}({{entityName}} {{entityVarName}}) { + + sqlSession.insert(MAPPER + "insert{{entityName}}", {{entityVarName}}); + } + + public void update{{entityName}}({{entityName}} {{entityVarName}}) { + + sqlSession.update(MAPPER + "update{{entityName}}", {{entityVarName}}); + } + + public void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) { + + sqlSession.delete(MAPPER + "delete{{entityName}}", {{primaryKeyProperty}}); + } +} diff --git a/tools/board-generator/templates/entity.hbs b/tools/board-generator/templates/entity.hbs new file mode 100644 index 0000000..09750a7 --- /dev/null +++ b/tools/board-generator/templates/entity.hbs @@ -0,0 +1,15 @@ +package {{entityPackage}}; + +{{#each entityImports}} +import {{this}}; +{{/each}} +import lombok.Data; + +@Data +public class {{entityName}} { + +{{#each columns}} + private {{javaType}} {{propertyName}}; + +{{/each}} +} diff --git a/tools/board-generator/templates/mapper.hbs b/tools/board-generator/templates/mapper.hbs new file mode 100644 index 0000000..7339f38 --- /dev/null +++ b/tools/board-generator/templates/mapper.hbs @@ -0,0 +1,49 @@ + + + + + +{{#each columns}} + {{#if primaryKey}}{{else}}{{/if}} +{{/each}} + + + + + + + + INSERT INTO {{metadata.schema}}.{{tableName}} ( +{{#each insertColumns}} + {{columnName}}{{#unless @last}},{{/unless}} +{{/each}} + ) + VALUES ( +{{#each insertColumns}} + #{{'{'}}{{propertyName}}{{'}'}}{{#unless @last}},{{/unless}} +{{/each}} + ) + + + + UPDATE {{metadata.schema}}.{{tableName}} + SET +{{#each updateColumns}} + {{columnName}} = #{{'{'}}{{propertyName}}{{'}'}}{{#unless @last}},{{/unless}} +{{/each}} + WHERE {{primaryKeyColumn.columnName}} = #{{'{'}}{{primaryKeyProperty}}{{'}'}} + + + + DELETE FROM {{metadata.schema}}.{{tableName}} + WHERE {{primaryKeyColumn.columnName}} = #{{'{'}}{{primaryKeyProperty}}{{'}'}} + + diff --git a/tools/board-generator/templates/page.vue.hbs b/tools/board-generator/templates/page.vue.hbs new file mode 100644 index 0000000..aa4195b --- /dev/null +++ b/tools/board-generator/templates/page.vue.hbs @@ -0,0 +1,219 @@ + + + + + diff --git a/tools/board-generator/templates/service.hbs b/tools/board-generator/templates/service.hbs new file mode 100644 index 0000000..f30c1e5 --- /dev/null +++ b/tools/board-generator/templates/service.hbs @@ -0,0 +1,18 @@ +package {{rootPackage}}; + +import java.util.List; + +import {{entityFqcn}}; + +public interface {{serviceName}} { + + List<{{entityName}}> get{{entityName}}List(); + + {{entityName}} get{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}); + + {{entityName}} create{{entityName}}({{entityName}} {{entityVarName}}); + + {{entityName}} update{{entityName}}({{entityName}} {{entityVarName}}); + + void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}); +} diff --git a/tools/board-generator/templates/serviceImpl.hbs b/tools/board-generator/templates/serviceImpl.hbs new file mode 100644 index 0000000..cb67b08 --- /dev/null +++ b/tools/board-generator/templates/serviceImpl.hbs @@ -0,0 +1,52 @@ +package {{implPackage}}; + +import java.util.List; + +import org.springframework.stereotype.Service; + +import {{rootPackage}}.{{serviceName}}; +import {{daoPackage}}.{{daoName}}; +import {{entityFqcn}}; + +@Service +public class {{serviceImplName}} implements {{serviceName}} { + + private final {{daoName}} {{entityVarName}}Dao; + + public {{serviceImplName}}({{daoName}} {{entityVarName}}Dao) { + + this.{{entityVarName}}Dao = {{entityVarName}}Dao; + } + + @Override + public List<{{entityName}}> get{{entityName}}List() { + + return {{entityVarName}}Dao.select{{entityName}}List(); + } + + @Override + public {{entityName}} get{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) { + + return {{entityVarName}}Dao.select{{entityName}}({{primaryKeyProperty}}); + } + + @Override + public {{entityName}} create{{entityName}}({{entityName}} {{entityVarName}}) { + + {{entityVarName}}Dao.insert{{entityName}}({{entityVarName}}); + return {{entityVarName}}Dao.select{{entityName}}({{entityVarName}}.{{primaryKeyGetterName}}()); + } + + @Override + public {{entityName}} update{{entityName}}({{entityName}} {{entityVarName}}) { + + {{entityVarName}}Dao.update{{entityName}}({{entityVarName}}); + return {{entityVarName}}Dao.select{{entityName}}({{entityVarName}}.{{primaryKeyGetterName}}()); + } + + @Override + public void delete{{entityName}}({{primaryKeyJavaType}} {{primaryKeyProperty}}) { + + {{entityVarName}}Dao.delete{{entityName}}({{primaryKeyProperty}}); + } +}