GenericCrudController
목록·상세·등록·수정·삭제를 하나의 제네릭 컨트롤러로 표준화합니다. Kosmos DSL의 페이지 템플릿과 결합해, 테이블/폼/밸리데이션/페이징을 일관된 구조로 제공합니다.
핵심 목표: 중복 제거, 도메인 간 UX 일관성, 보안 방어선(권한/검증/트랜잭션) 강화
개념 개요
| 요소 | 역할 | 비고 |
|---|---|---|
GenericCrudController<ID, Form, View> |
표준 CRUD 엔드포인트 제공 | 템플릿 메서드 패턴 |
CrudService<ID, Form, View> |
도메인 로직/트랜잭션 | 리포지토리 어댑터 |
Form |
입력 DTO (Bean Validation) | 서버 검증 일급 |
View |
목록/상세 뷰 모델 | 표시 전용 |
PageMeta |
page/size/total/sort/q | 테이블/페이징 결합 |
| DSL 컴포넌트 | 테이블/폼/알럿/페이지 템플릿 | UI 표준화 |
엔드포인트 규약
| HTTP | 경로 | 역할 | 파라미터 |
|---|---|---|---|
| GET | /{base} |
목록 | page,size,q,sort,필터... |
| GET | /{base}/{id} |
상세 | — |
| GET | /{base}/new |
등록 폼 | — |
| POST | /{base} |
등록 | Form |
| GET | /{base}/{id}/edit |
수정 폼 | — |
| POST | /{base}/{id} |
수정 | Form + _method=PUT (또는 PUT) |
| POST | /{base}/{id}/delete |
삭제 | _method=DELETE (또는 DELETE) |
코드 심화: CrudService 인터페이스
public interface CrudService<ID, Form, View> {
ItemsResult list(int page, int size, String q, String sort, Map<String,String> filters);
View get(ID id);
ID create(Form form);
void update(ID id, Form form);
void delete(ID id);
}
코드 심화: GenericCrudController 기본형
@Controller
public abstract class GenericCrudController<ID, Form, View> {
protected abstract String basePath(); // "/items"
protected abstract CrudService<ID, Form, View> service();
protected abstract HtmlComponent listView(ItemsResult result, RenderContext ctx);
protected abstract HtmlComponent detailView(View view, RenderContext ctx);
protected abstract HtmlComponent formView(Form form, Map<String,String> errors, RenderContext ctx);
@GetMapping
public ResponseEntity<String> list(@RequestParam(defaultValue="1") int page,
@RequestParam(defaultValue="20") int size,
@RequestParam(required=false) String q,
@RequestParam(required=false) String sort,
HttpServletRequest request,
RenderContext ctx) {
Map<String,String> filters = request.getParameterMap().entrySet().stream()
.filter(e -> !List.of("page","size","q","sort").contains(e.getKey()))
.collect(Collectors.toMap(Map.Entry::getKey, e -> e.getValue()[0]));
ItemsResult result = service().list(page, size, q, sort, filters);
return ResponseEntity.ok(pageTemplate().content(listView(result, ctx)).render(ctx));
}
@GetMapping("/{id}")
public ResponseEntity<String> detail(@PathVariable ID id, RenderContext ctx) {
return ResponseEntity.ok(pageTemplate().content(detailView(service().get(id), ctx)).render(ctx));
}
@GetMapping("/new")
public ResponseEntity<String> formNew(RenderContext ctx) {
return ResponseEntity.ok(pageTemplate().content(formView(emptyForm(), Map.of(), ctx)).render(ctx));
}
@PostMapping
public ResponseEntity<String> create(@Valid @ModelAttribute("form") Form form,
BindingResult br, RenderContext ctx) {
if (br.hasErrors()) return ResponseEntity.badRequest()
.body(pageTemplate().content(formView(form, toErrorMap(br), ctx)).render(ctx));
ID id = service().create(form);
return ResponseEntity.status(302).header("Location", basePath() + "/" + id).build();
}
@GetMapping("/{id}/edit")
public ResponseEntity<String> formEdit(@PathVariable ID id, RenderContext ctx) {
View view = service().get(id);
return ResponseEntity.ok(pageTemplate().content(formView(toForm(view), Map.of(), ctx)).render(ctx));
}
@PostMapping("/{id}")
public ResponseEntity<String> update(@PathVariable ID id,
@Valid @ModelAttribute("form") Form form,
BindingResult br, RenderContext ctx) {
if (br.hasErrors()) return ResponseEntity.badRequest()
.body(pageTemplate().content(formView(form, toErrorMap(br), ctx)).render(ctx));
service().update(id, form);
return ResponseEntity.status(302).header("Location", basePath() + "/" + id).build();
}
@PostMapping("/{id}/delete")
public ResponseEntity<String> delete(@PathVariable ID id) {
service().delete(id);
return ResponseEntity.status(302).header("Location", basePath()).build();
}
// --- Hooks / 템플릿 메서드 ---
protected PageTemplate pageTemplate() { return new PageTemplate(); }
protected Map<String,String> toErrorMap(BindingResult br) {
return br.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage, (a,b)->a, LinkedHashMap::new));
}
protected Form emptyForm() { return null; } // 도메인에서 오버라이드
protected Form toForm(View view) { return null; } // 도메인에서 오버라이드
}
샘플 도메인: Item
public record ItemView(Long id, String name, String category, String description, String updatedAt) {}
public class ItemForm {
@NotBlank @Size(max=120) private String name;
@NotBlank @Size(max=40) private String category;
@Size(max=1000) private String description;
// 게터/세터...
}
Service 구현
@Service
public class ItemService implements CrudService<Long, ItemForm, ItemView> {
private final ItemRepository repo;
public ItemsResult list(int page, int size, String q, String sort, Map<String,String> filters) {
SortSpec spec = SortSpec.parse(sort, "updatedAt,desc");
int pageSafe = Math.max(page, 1);
int sizeSafe = List.of(10,20,50,100).contains(size) ? size : 20;
List<Item> found = repo.find(q, filters, spec, pageSafe, sizeSafe);
long total = repo.count(q, filters);
List<ItemsTable.Row> rows = found.stream()
.map(it -> new ItemsTable.Row(it.getId(), it.getName(), it.getCategory(), it.getUpdatedAt().toString()))
.toList();
int totalPages = (int) Math.ceil((double) total / sizeSafe);
PageMeta meta = new PageMeta(pageSafe, sizeSafe, total, totalPages, pageSafe>1, pageSafe<totalPages, spec.field()+","+spec.dir(), q);
return new ItemsResult(rows, meta);
}
public ItemView get(Long id) {
Item it = repo.findById(id).orElseThrow(() -> new IllegalArgumentException("Not found"));
return new ItemView(it.getId(), it.getName(), it.getCategory(), it.getDescription(), it.getUpdatedAt().toString());
}
@Transactional
public Long create(ItemForm form) {
Item it = new Item(form.getName(), form.getCategory(), form.getDescription());
repo.save(it);
return it.getId();
}
@Transactional
public void update(Long id, ItemForm form) {
Item it = repo.findByIdForUpdate(id).orElseThrow(() -> new IllegalArgumentException("Not found"));
it.setName(form.getName());
it.setCategory(form.getCategory());
it.setDescription(form.getDescription());
}
@Transactional
public void delete(Long id) {
repo.deleteById(id);
}
}
View 조립: 목록/상세/폼
// 목록: 검색바 + 테이블 + 페이징
@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());
return El.div().children(
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)
)
);
}
// 상세
@Override protected HtmlComponent detailView(ItemView v, 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.div().css("mt-3 d-flex gap-2").children(
El.a().css("btn btn-secondary").href(basePath()).text("목록"),
El.a().css("btn btn-primary").href(basePath()+"/"+v.id()+"/edit").text("수정")
)
)
);
}
// 폼
@Override protected HtmlComponent formView(ItemForm form, Map<String,String> errors, RenderContext ctx) {
return El.form().attr("method","POST").attr("action", basePath())
.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(basePath()).text("취소")
)
);
}
보안 · 권한 · 트랜잭션
| 주제 | 권장 | 지양 |
|---|---|---|
| 권한 | @PreAuthorize 또는 서비스 레벨 가드 |
UI 숨김만으로 권한 통제 |
| 검증 | Bean Validation + 도메인 규칙 | 클라이언트 검증만 신뢰 |
| 트랜잭션 | @Transactional on create/update/delete |
읽기-수정 경쟁 상태 방치 |
| 오류 처리 | 검증 오류 → 폼 재표시 + .invalid-feedback |
에러 페이지로 단절 |
확장 포인트
- 전처리/후처리:
beforeCreate(form),afterUpdate(id)같은 훅을 서비스에 추가 - 필터 슬롯: 목록 상단에 도메인 전용 필터(상태/기간/태그)를 배치
- 파일 업로드: File Upload 패턴을 폼에 통합
- 조건 렌더: Conditional Rendering으로 권한별 버튼 토글
테스트 전략
- 컨트롤러 스냅샷: 목록/상세/폼 HTML을 시나리오별로 스냅샷 비교
- 검증 케이스: 빈 값/길이 초과/부적절 문자 → 오류 메시지 기대
- 권한: ADMIN/USER/익명 각 케이스 접근 제어
- 동시성: 수정 중 삭제, 마지막 페이지 삭제 후 리다이렉트
주의사항
제네릭 남용: 모든 도메인을 억지로 맞추지 말고, 특수 케이스는 별도 컨트롤러로 분리하세요.
N+1 주의: 목록에 표시할 파생 정보는 페치 조인/조회 최적화를 고려하세요.
정렬/필터 보존: 저장/삭제 후에도 사용자가 보던 맥락(정렬/검색/페이지)을 유지하면 UX가 좋아집니다.
다음으로
- RenderContext & Auth — 권한/플래그를 뷰에 안전하게 전달
- CRUD 3종 — 리스트/상세/폼을 한 번에 조립하는 레시피
- Table Setup — 목록 테이블 표준 구성
- Sorting & Filters — 쿼리 파라미터 기반 상태 관리
- Validation — 서버 검증과 UI 피드백 연동
- Alerts — 성공/오류 메시지 UX