GenericCrudController

목록·상세·등록·수정·삭제를 하나의 제네릭 컨트롤러로 표준화합니다. Kosmos DSL의 페이지 템플릿과 결합해, 테이블/폼/밸리데이션/페이징을 일관된 구조로 제공합니다.

개념 개요

요소 역할 비고
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가 좋아집니다.

다음으로