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}}); + } +}