CRUD 3종: 리스트 / 상세 / 폼
Kosmos의 GenericCrudController, Page Template & Slots, Forms/Validation, Data & Pagination을 결합해 도메인 전반에 재사용 가능한 CRUD 화면 세트를 만듭니다.
목표: 일관 UX, 중복 최소화, 테이블/폼/검증의 결합을 표준화합니다.
전체 흐름
[목록] GET /items?page=1&size=20&sort=updatedAt,desc&q=...
└ 클릭 → [상세] GET /items/{id}
└ 수정 → [수정 폼] GET /items/{id}/edit
└ 저장(POST /items/{id}?_method=PUT) → [상세로 302]
└ 삭제(POST /items/{id}/delete) → [목록로 302]
└ 새로 만들기 → [등록 폼] GET /items/new
└ 저장(POST /items) → [상세로 302]
권장 파일 구조
/app/items ├─ ItemsController.java // GenericCrudController 구현 ├─ ItemsService.java // CrudService 구현 ├─ ItemsRepository.java ├─ ItemsView.java / ItemsForm.java ├─ ui │ ├─ ItemsTable.java // 목록 Table 컴포넌트 │ ├─ ItemsSearchBar.java // 검색/필터 바 │ ├─ ItemsDetailCard.java // 상세 카드 │ ├─ ItemsFormView.java // 폼 구성 (CompFormGroup 등) │ └─ ItemsActions.java // 액션 영역(권한별) └─ items-routes.http // 개발용 HTTP 스크립트
1) 목록 페이지
- 검색/필터:
ItemsSearchBar(q, size, 상태 등) - 정렬/표:
ItemsTable(정렬 가능한 thead 링크) - 페이징:
CompPagination+CompPageSizer - 액션: 새로 만들기, 일괄 작업 버튼
// ItemsController.listView (발췌)
@Override protected HtmlComponent listView(ItemsResult result, RenderContext ctx) {
Map<String,String> params = new LinkedHashMap<>();
if (result.meta().q() != null) params.put("q", result.meta().q());
if (result.meta().sort() != null) params.put("sort", result.meta().sort());
HtmlComponent toolbar = El.div().css("d-flex justify-content-between align-items-center mb-2").children(
El.h2().css("h5 mb-0").text("Items"),
ctx.hasRole("ADMIN")
? El.a().css("btn btn-primary").href(basePath()+"/new").text("새로 만들기")
: El.span().css("text-muted").text("읽기 전용")
);
return El.div().children(
toolbar,
new ItemsSearchBar(result.meta(), basePath(), result.meta().q()),
new ItemsTable(new ItemsTable.Model(result.rows(), result.meta()), basePath(), params),
El.div().css("d-flex justify-content-between align-items-center mt-3").children(
new CompPageSizer(result.meta(), basePath(), params),
new CompPagination(result.meta(), basePath(), params)
)
);
}
2) 상세 페이지
도메인에 맞는 라벨/메타를 ItemsDetailCard로 캡슐화합니다.
public class ItemsDetailCard implements HtmlComponent {
private final ItemView v; private final boolean editable;
public ItemsDetailCard(ItemView v, boolean editable) { this.v = v; this.editable = editable; }
@Override public String render(RenderContext ctx) {
return El.div().css("card border-0 shadow-sm")
.child(El.div().css("card-body").children(
El.h2().css("h4 mb-3").text(v.name()),
El.dl().css("row").children(
El.dt().css("col-sm-3").text("분류"),
El.dd().css("col-sm-9").text(v.category()),
El.dt().css("col-sm-3").text("설명"),
El.dd().css("col-sm-9").text(v.description()),
El.dt().css("col-sm-3").text("수정일"),
El.dd().css("col-sm-9").text(v.updatedAt())
),
El.div().css("mt-3 d-flex gap-2").children(
El.a().css("btn btn-secondary").href("/items").text("목록"),
editable ? El.a().css("btn btn-primary").href("/items/"+v.id()+"/edit").text("수정") : El.span(),
editable ? El.form().attr("method","POST").attr("action","/items/"+v.id()+"/delete")
.child(El.button().css("btn btn-danger").attr("type","submit").text("삭제")) : El.span()
)
));
}
}
3) 폼 페이지
- FormInputs + CompFormGroup로 입력/에러/도움을 일관화
- Validation: Bean Validation → 오류 시 동일 화면 재표시
- File Upload는 File Upload 패턴 참조
public class ItemsFormView implements HtmlComponent {
private final String action; private final String method; // "/items" / "POST" | "/items/{id}" / "POST(_method=PUT)"
private final ItemForm form; private final Map<String,String> errors;
public ItemsFormView(String action, String method, ItemForm form, Map<String,String> errors) {
this.action=action; this.method=method; this.form=form; this.errors=errors;
}
@Override public String render(RenderContext ctx) {
var f = El.form().attr("method","POST").attr("action", action);
if ("PUT".equalsIgnoreCase(method)) {
f.child(El.input().attr("type","hidden").attr("name","_method").attr("value","PUT"));
}
f.children(
new CompFormGroup("name", "이름",
FormInputs.text("name", form.getName(), "예: 노트북"), "120자 이내", errors.get("name")),
new CompFormGroup("category", "분류",
FormInputs.text("category", form.getCategory(), "예: 전자제품"), "40자 이내", errors.get("category")),
new CompFormGroup("description", "설명",
FormInputs.textarea("description", form.getDescription(), 5), "선택 입력", errors.get("description")),
El.div().css("d-flex gap-2 mt-3").children(
El.button().css("btn btn-primary").attr("type","submit").text("저장"),
El.a().css("btn btn-secondary").href("/items").text("취소")
)
);
return f.render(ctx);
}
}
템플릿 결합
// 공통 템플릿 (브레드크럼/사이드바 포함)
private PageTemplate docsPage(HtmlComponent content) {
return new PageTemplate()
.header(new GlobalHeader())
.breadcrumbs(new Breadcrumbs(List.of(
new String[]{"Docs","/docs/kosmos/overview"},
new String[]{"CRUD 3종","/docs/kosmos/recipes/crud-3set"}
)))
.sidebar(new DocsSidebar(loadSideTree()))
.content(content);
}
// 목록/상세/폼 조립 (컨트롤러 각 메서드에서)
return ResponseEntity.ok(docsPage(listView(result, ctx)).render(ctx));
return ResponseEntity.ok(docsPage(new ItemsDetailCard(v, ctx.hasRole("ADMIN"))).render(ctx));
return ResponseEntity.ok(docsPage(new ItemsFormView(action, method, form, errors)).render(ctx));
상태 전이 요약
| 행동 | 입력 | 성공 | 실패 |
|---|---|---|---|
| 등록 | POST /items (Form) | 302 → /items/{id} | 400 → 폼 재표시 + 오류 |
| 수정 | POST /items/{id} (_method=PUT) | 302 → /items/{id} | 400 → 폼 재표시 + 오류 |
| 삭제 | POST /items/{id}/delete | 302 → /items | 404/409 → Alert + 동일 페이지 |
보안/검증/트랜잭션 체크리스트
- 권한: RenderContext & Auth와
@PreAuthorize동시 사용 - 유효성: Bean Validation(+ 커스텀 도메인 규칙)로 서버 검증
- 트랜잭션: create/update/delete에
@Transactional적용 - 중복 제출 방지: 성공 시 302 리다이렉트(P/R/G 패턴)
성능 팁
| 주제 | 권장 | 지양 |
|---|---|---|
| N+1 | 목록 표시 필드는 한 번에 조회 | 행별 추가 쿼리 |
| 정렬 | 인덱스 기반 정렬 필드 사용 | 함수형/계산식 정렬 남용 |
| 페이지 이동 | URL 파라미터로 상태 보존 | 세션에만 의존 |
주의사항
제네릭 강박: 모든 도메인을 한 컨트롤러로 우겨 넣지 말고, 특수 흐름은 별도 컨트롤러로 분리하세요.
권한은 UI만으로 제어 금지: 서버 가드(@PreAuthorize/서비스 가드)를 반드시 병행하세요.
검증 메시지 일관성: Validation 가이드의 메시지 규칙을 재사용하세요.
테스트 시나리오
- 목록: 검색/정렬/페이징 조합 스냅샷
- 상세: 권한별 버튼 노출(ADMIN vs USER)
- 등록/수정: 필수/길이 초과/도메인 오류 케이스
- 삭제: 마지막 페이지 삭제 시 페이지 보정
다음으로
- Docs 시스템 구현 — 지금 보고 있는 구조를 그대로 만드는 레시피
- GenericCrudController — CRUD 표준 컨트롤러 상세
- Validation — 서버 검증과 UI 피드백
- Table Setup — 목록 테이블 표준 구성
- Page Template & Slots — 레이아웃/슬롯 통합