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.
This commit is contained in:
2026-05-31 13:22:03 +09:00
parent d5ac812703
commit 12c40c6004
25 changed files with 3305 additions and 1 deletions
@@ -0,0 +1,252 @@
<!-- 샘플 테이블 게시판 상세 컴포넌트 템플릿이다. -->
<template>
<!-- 상세 화면 전체 영역이다. -->
<div>
<!-- 상세 헤더 영역이다. -->
<div class="ui--list-heading clearfix">
<!-- 상세 화면 제목을 출력한다. -->
<span class="ui--text-total"><strong>SampleTable Board</strong> {{ mode === 'CREATE' ? '등록' : '상세' }}</span>
<!-- 우측 버튼 영역이다. -->
<div class="float-end">
<!-- 목록 이동 이벤트를 발생시킨다. -->
<button type="button" class="btn btn-secondary btn-sm" @click="$emit('list')">목록</button>
</div>
</div>
<!-- 상세 컨테이너이다. -->
<div class="ui--form-container">
<!-- 제목 입력 영역이다. -->
<div class="mb-3">
<!-- 제목 라벨이다. -->
<label>Title</label>
<!-- 제목 입력창이다. -->
<input v-model.trim="form.title" type="text" class="form-control" />
</div>
<!-- 내용 입력 영역이다. -->
<div class="mb-3">
<!-- 내용 라벨이다. -->
<label>Content</label>
<!-- 내용 입력창이다. -->
<textarea v-model.trim="form.content" class="form-control" rows="6"></textarea>
</div>
<!-- 콘텐츠 타입 입력 영역이다. -->
<div class="mb-3">
<!-- 콘텐츠 타입 라벨이다. -->
<label>Content Type</label>
<!-- 콘텐츠 타입 입력창이다. -->
<textarea v-model.trim="form.contentType" class="form-control" rows="6"></textarea>
</div>
<!-- 작성자 입력 영역이다. -->
<div class="mb-3">
<!-- 작성자 라벨이다. -->
<label>Author</label>
<!-- 작성자 입력창이다. -->
<input v-model.trim="form.author" type="text" class="form-control" />
</div>
<!-- 버튼 영역이다. -->
<div class="ui--button-container">
<!-- 목록 이동 이벤트를 발생시킨다. -->
<button type="button" class="btn btn-secondary btn-lg" @click="$emit('list')">목록</button>
<!-- 저장을 수행한다. -->
<button type="button" class="btn btn-primary btn-lg" @click="saveItem()">저장</button>
<!-- 수정 모드일 때만 삭제 버튼을 노출한다. -->
<button v-if="mode === 'EDIT'" type="button" class="btn btn-danger btn-lg" @click="deleteItem()">삭제</button>
</div>
</div>
</div>
</template>
<!-- 샘플 테이블 게시판 상세 컴포넌트 스크립트이다. -->
<script>
// HTTP 통신을 위해 axios 를 사용한다.
import axios from 'axios';
// 공통 유틸을 사용한다.
import SDLUtil from '@/utils/SDLUtil';
// 샘플 테이블 게시판 상세 컴포넌트를 정의한다.
export default {
// 컴포넌트 이름을 정의한다.
name: 'SampleTableBoardDetail',
// 부모로부터 전달받는 값을 정의한다.
props: {
// 상세 식별자 prop 이다.
detailId: {
// 문자열 타입을 사용한다.
type: String,
// 기본값은 null 이다.
default: null,
},
// 생성 화면 여부 prop 이다.
isCreateMode: {
// 불리언 타입을 사용한다.
type: Boolean,
// 기본값은 false 이다.
default: false,
},
},
// 부모로 전달할 이벤트를 선언한다.
emits: ['list', 'saved', 'deleted'],
// 화면 상태를 정의한다.
data() {
// 초기 상태를 반환한다.
return {
// 현재 상세 화면 모드를 저장한다.
mode: 'CREATE',
// 입력 폼 상태를 저장한다.
form: this.createEmptyForm(),
};
},
// prop 변경을 감시한다.
watch: {
// 상세 식별자 변경을 감시한다.
detailId: {
// 변경 시 상세 상태를 동기화한다.
handler() {
// 현재 전달값 기준으로 화면 상태를 갱신한다.
this.syncDetailState();
},
// 최초 렌더링 시에도 즉시 실행한다.
immediate: true,
},
// 생성 화면 여부 변경을 감시한다.
isCreateMode() {
// 현재 전달값 기준으로 화면 상태를 갱신한다.
this.syncDetailState();
},
},
// 화면 동작 메서드를 정의한다.
methods: {
// 빈 입력 폼 객체를 생성한다.
createEmptyForm() {
// 기본 폼 값을 반환한다.
return {
// 식별자 기본값이다.
id: null,
// 제목 기본값이다.
title: '',
// 내용 기본값이다.
content: '',
// 콘텐츠 타입 기본값이다.
contentType: '',
// 작성자 기본값이다.
author: '',
};
},
// 현재 prop 상태에 맞춰 상세 화면을 동기화한다.
syncDetailState() {
// 상세 식별자가 있으면 상세 조회를 수행한다.
if (this.detailId) {
// 상세 조회를 수행한다.
this.fetchDetail(this.detailId);
// 상세 조회 처리 후 메서드를 종료한다.
return;
}
// 생성 모드이면 빈 폼으로 초기화한다.
if (this.isCreateMode) {
// 생성 모드 상태로 초기화한다.
this.resetForm();
// 생성 모드 처리 후 메서드를 종료한다.
return;
}
// 기본값으로 폼을 초기화한다.
this.resetForm();
},
// 입력 폼을 초기 상태로 되돌린다.
resetForm() {
// 생성 모드로 전환한다.
this.mode = 'CREATE';
// 입력 폼을 초기화한다.
this.form = this.createEmptyForm();
},
// 상세 데이터를 조회한다.
async fetchDetail(detailId) {
// 로딩 바를 표시한다.
SDLUtil.showLoadingBar(true);
try {
// 상세 조회 API 를 호출한다.
const { data } = await axios.post(`${SDLUtil.API_URL}/pg-board/sample-table/item/main.do?method=getSampleTableBoard`, JSON.stringify({ id: detailId }), {
headers: { 'Content-Type': 'application/json' },
});
// 조회 결과를 입력 폼에 반영한다.
this.form = {
// 기본 폼 구조를 유지한다.
...this.createEmptyForm(),
// 조회한 결과를 덮어쓴다.
...(data.result || {}),
};
// 수정 모드로 전환한다.
this.mode = 'EDIT';
} catch (error) {
// 오류가 발생하면 공통 에러 알림을 출력한다.
SDLUtil.errorAlert(error);
} finally {
// 로딩 바를 종료한다.
SDLUtil.showLoadingBar(false);
}
},
// 현재 입력값을 저장한다.
async saveItem() {
// 로딩 바를 표시한다.
SDLUtil.showLoadingBar(true);
try {
// 현재 모드에 맞는 요청 URL 을 계산한다.
const requestUrl = this.mode === 'EDIT'
? `${SDLUtil.API_URL}/pg-board/sample-table/item/main.do?method=update`
: `${SDLUtil.API_URL}/pg-board/sample-table/item/main.do?method=regist`;
// 저장 API 를 호출한다.
const { data } = await axios.post(requestUrl, JSON.stringify(this.form), {
headers: { 'Content-Type': 'application/json' },
});
// 저장 결과에 식별자가 있으면 부모에 전달한다.
if (data.result && data.result.id) {
// 저장 완료 이벤트를 발생시킨다.
this.$emit('saved', data.result.id);
}
// 저장 완료 알림을 출력한다.
SDLUtil.alert('저장되었습니다.');
} catch (error) {
// 오류가 발생하면 공통 에러 알림을 출력한다.
SDLUtil.errorAlert(error);
} finally {
// 로딩 바를 종료한다.
SDLUtil.showLoadingBar(false);
}
},
// 삭제 확인 팝업을 출력한다.
deleteItem() {
// 삭제 대상이 없으면 종료한다.
if (!this.form.id) {
// 함수 실행을 종료한다.
return;
}
// 공통 확인 팝업을 출력한다.
SDLUtil.confirm({
// 삭제 확인 문구를 설정한다.
msg: '삭제하시겠습니까?',
// 확인 시 실제 삭제를 수행한다.
onOkEvt: () => this.doDelete(),
});
},
// 실제 삭제를 수행한다.
async doDelete() {
// 로딩 바를 표시한다.
SDLUtil.showLoadingBar(true);
try {
// 삭제 API 를 호출한다.
await axios.post(`${SDLUtil.API_URL}/pg-board/sample-table/item/main.do?method=delete`, JSON.stringify({ id: this.form.id }), {
headers: { 'Content-Type': 'application/json' },
});
// 삭제 완료 이벤트를 부모에 전달한다.
this.$emit('deleted');
// 삭제 완료 알림을 출력한다.
SDLUtil.alert('삭제되었습니다.');
} catch (error) {
// 오류가 발생하면 공통 에러 알림을 출력한다.
SDLUtil.errorAlert(error);
} finally {
// 로딩 바를 종료한다.
SDLUtil.showLoadingBar(false);
}
},
},
};
</script>
@@ -0,0 +1,129 @@
<!-- 샘플 테이블 게시판 목록 컴포넌트 템플릿이다. -->
<template>
<!-- 목록 화면 전체 영역이다. -->
<div>
<!-- 목록 헤더 영역이다. -->
<div class="ui--list-heading clearfix">
<!-- 전체 건수를 출력한다. -->
<span class="ui--text-total"><strong>Total</strong> {{ itemList.length }}</span>
<!-- 우측 버튼 영역이다. -->
<div class="float-end">
<!-- 신규 등록 화면 요청 이벤트를 발생시킨다. -->
<button type="button" class="btn btn-primary btn-sm" @click="$emit('create')"> </button>
</div>
</div>
<!-- 목록 테이블 영역이다. -->
<table class="table table-bordered ui--table">
<!-- 테이블 헤더이다. -->
<thead>
<!-- 헤더 행이다. -->
<tr>
<!-- 아이디 헤더이다. -->
<th scope="col">Id</th>
<!-- 제목 헤더이다. -->
<th scope="col">Title</th>
<!-- 작성자 헤더이다. -->
<th scope="col">Author</th>
<!-- 등록일 헤더이다. -->
<th scope="col">Created At</th>
</tr>
</thead>
<!-- 테이블 본문이다. -->
<tbody>
<!-- 데이터가 없을 안내 문구를 출력한다. -->
<tr v-if="itemList.length === 0">
<!-- 목록 안내 셀이다. -->
<td :colspan="4" class="text-center">등록된 데이터가 없습니다.</td>
</tr>
<!-- 목록 데이터를 반복 출력한다. -->
<tr v-for="item in itemList" :key="item.id">
<!-- 아이디 컬럼이다. -->
<td class="text-center">
<!-- 상세 화면 요청 이벤트를 발생시킨다. -->
<button type="button" class="btn btn-link ui--board-link" @click="$emit('detail', item.id)">{{ item.id }}</button>
</td>
<!-- 제목 컬럼이다. -->
<td class="text-center">
<!-- 제목을 출력한다. -->
{{ item.title }}
</td>
<!-- 작성자 컬럼이다. -->
<td class="text-center">
<!-- 작성자를 출력한다. -->
{{ item.author }}
</td>
<!-- 등록일 컬럼이다. -->
<td class="text-center">
<!-- 등록일을 출력한다. -->
{{ item.createdAt }}
</td>
</tr>
</tbody>
</table>
</div>
</template>
<!-- 샘플 테이블 게시판 목록 컴포넌트 스크립트이다. -->
<script>
// HTTP 통신을 위해 axios 를 사용한다.
import axios from 'axios';
// 공통 유틸을 사용한다.
import SDLUtil from '@/utils/SDLUtil';
// 샘플 테이블 게시판 목록 컴포넌트를 정의한다.
export default {
// 컴포넌트 이름을 정의한다.
name: 'SampleTableBoardList',
// 부모로 전달할 이벤트를 선언한다.
emits: ['create', 'detail'],
// 화면 상태를 정의한다.
data() {
// 초기 상태를 반환한다.
return {
// 목록 데이터를 저장한다.
itemList: [],
};
},
// 화면 동작 메서드를 정의한다.
methods: {
// 목록 데이터를 조회한다.
async fetchList() {
// 로딩 바를 표시한다.
SDLUtil.showLoadingBar(true);
try {
// 목록 조회 API 를 호출한다.
const { data } = await axios.post(`${SDLUtil.API_URL}/pg-board/sample-table/list/main.do?method=search`, JSON.stringify({}), {
headers: { 'Content-Type': 'application/json' },
});
// 조회 결과를 목록 상태에 반영한다.
this.itemList = data.result || [];
} catch (error) {
// 오류가 발생하면 공통 에러 알림을 출력한다.
SDLUtil.errorAlert(error);
} finally {
// 로딩 바를 종료한다.
SDLUtil.showLoadingBar(false);
}
},
},
// 컴포넌트 마운트 이후 초기 목록을 조회한다.
mounted() {
// 다음 틱에서 목록 조회를 수행한다.
this.$nextTick(() => {
// 초기 목록 조회를 수행한다.
this.fetchList();
});
},
};
</script>
<!-- 샘플 테이블 게시판 목록 컴포넌트 스타일이다. -->
<style scoped>
/* 목록 링크 버튼 커서를 제어한다. */
.ui--board-link {
/* 포인터 커서를 사용한다. */
cursor: pointer;
/* 기본 패딩을 제거한다. */
padding: 0;
}
</style>
@@ -0,0 +1,97 @@
<!-- 샘플 테이블 게시판 부모 페이지 템플릿이다. -->
<template>
<!-- 페이지 전체 래퍼이다. -->
<div class="ui--content-wrapper">
<!-- 공통 브레드크럼을 출력한다. -->
<sdl-breadcrumb></sdl-breadcrumb>
<!-- 상세 화면 여부에 따라 자식 컴포넌트를 전환한다. -->
<SampleTableBoardDetail
v-if="isDetailView"
:detail-id="detailId"
:is-create-mode="isCreateMode"
@list="moveToList()"
@saved="handleSaved"
@deleted="handleDeleted"
/>
<!-- 목록 화면일 목록 자식 컴포넌트를 출력한다. -->
<SampleTableBoardList
v-else
@create="moveToCreate()"
@detail="openDetail"
/>
</div>
</template>
<!-- 샘플 테이블 게시판 부모 페이지 스크립트이다. -->
<script>
// 목록 자식 컴포넌트를 사용한다.
import SampleTableBoardList from '@/components/view/admin/pgBoard/SampleTableBoardList.vue';
// 상세 자식 컴포넌트를 사용한다.
import SampleTableBoardDetail from '@/components/view/admin/pgBoard/SampleTableBoardDetail.vue';
// 샘플 테이블 게시판 부모 페이지를 정의한다.
export default {
// 컴포넌트 이름을 정의한다.
name: 'SampleTableBoardPage',
// 하위 컴포넌트를 등록한다.
components: {
// 목록 자식 컴포넌트이다.
SampleTableBoardList,
// 상세 자식 컴포넌트이다.
SampleTableBoardDetail,
},
// 라우트 기반 파생 상태를 계산한다.
computed: {
// 현재 상세 식별자를 반환한다.
detailId() {
// 상세 식별자가 없으면 null 을 반환한다.
return this.$route.query.detailId || null;
},
// 생성 화면 여부를 반환한다.
isCreateMode() {
// 생성 모드 쿼리 여부를 반환한다.
return this.$route.query.mode === 'create';
},
// 상세 화면 노출 여부를 반환한다.
isDetailView() {
// 상세 식별자 또는 생성 모드 여부를 반환한다.
return Boolean(this.detailId) || this.isCreateMode;
},
},
// 화면 동작 메서드를 정의한다.
methods: {
// 신규 등록 화면으로 이동한다.
moveToCreate() {
// 현재 경로를 유지한 채 생성 모드 쿼리로 이동한다.
this.$router.push({ path: this.$route.path, query: { mode: 'create' } });
},
// 목록 화면으로 이동한다.
moveToList() {
// 현재 경로를 유지한 채 목록 쿼리로 이동한다.
this.$router.push({ path: this.$route.path, query: {} });
},
// 상세 화면으로 이동한다.
openDetail(detailId) {
// 현재 경로를 유지한 채 상세 식별자 쿼리로 이동한다.
this.$router.push({ path: this.$route.path, query: { detailId } });
},
// 저장 완료 후 상세 화면 주소를 동기화한다.
handleSaved(detailId) {
// 저장된 식별자가 없으면 목록으로 이동한다.
if (!detailId) {
// 목록 화면으로 이동한다.
this.moveToList();
// 함수 실행을 종료한다.
return;
}
// 저장된 식별자로 현재 주소를 교체한다.
this.$router.replace({ path: this.$route.path, query: { detailId } });
},
// 삭제 완료 후 목록 화면으로 이동한다.
handleDeleted() {
// 목록 화면으로 이동한다.
this.moveToList();
},
},
};
</script>
@@ -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 까지 한 번에 바꾸면 메뉴/경로 체계 영향으로 변경 비용이 커질 수 있다.
즉, 현재 기준 최적해는 **"컴포넌트 분리 우선, 라우트 분리는 필요 시 후속 적용"** 이다.