CRUD 3종: 리스트 / 상세 / 폼

Kosmos의 GenericCrudController, Page Template & Slots, Forms/Validation, Data & Pagination을 결합해 도메인 전반에 재사용 가능한 CRUD 화면 세트를 만듭니다.

전체 흐름

[목록]  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 UploadFile 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)
  • 등록/수정: 필수/길이 초과/도메인 오류 케이스
  • 삭제: 마지막 페이지 삭제 시 페이지 보정

다음으로