Basic Forms

폼은 데이터 입력의 출발점입니다. Kosmos DSL로 레이블/컨트롤/헬프/에러가 일관된 패턴으로 조합되도록 표준 컴포넌트를 정의하고, 접근성(ARIA)·검증·레이아웃 규칙을 소개합니다.

폼 구성 원칙

  • 1 필드 = 1 그룹(Form Group): label · control · help · error로 구성
  • 레이블은 항상 명시: forid 연결로 접근성 보장
  • 검증 메시지 표준화: 성공/경고/오류 색은 팀 공통 유틸 클래스 사용
  • 단일 책임: 폼 그룹 컴포넌트는 표현만, 값 검증/바인딩은 서비스/컨트롤러

코드 심화: 표준 Form Group

레이블/컨트롤/헬프/에러를 캡슐화한 재사용 가능한 기본 단위입니다.

public class CompFormGroup implements HtmlComponent {
  private final String id;          // input id
  private final String label;       // 레이블 텍스트
  private final HtmlComponent control; // input/select/textarea 등
  private final String help;        // 선택(없으면 null)
  private final String error;       // 선택(없으면 null)

  public CompFormGroup(String id, String label, HtmlComponent control, String help, String error) {
    this.id = id; this.label = label; this.control = control; this.help = help; this.error = error;
  }

  @Override public String render(RenderContext ctx) {
    var group = El.div().css("mb-3");

    // label
    group.child(El.label().css("form-label").attr("for", id).text(label));

    // control
    group.child(control);

    // help text
    if (help != null && !help.isBlank()) {
      group.child(El.div().css("form-text").text(help));
    }

    // error text
    if (error != null && !error.isBlank()) {
      group.child(El.div().css("invalid-feedback d-block").text(error)); // d-block로 항상 표시(예시)
    }
    return group.render(ctx);
  }

  // 헬퍼: 텍스트 입력 컨트롤 생성
  public static HtmlComponent textInput(String id, String value, String placeholder, boolean readonly, boolean disabled) {
    var input = El.input().css("form-control").attr("type", "text").attr("id", id)
      .attr("name", id).attr("placeholder", placeholder == null ? "" : placeholder)
      .attr("value", value == null ? "" : value);
    if (readonly) input.attr("readonly", "readonly");
    if (disabled) input.attr("disabled", "disabled");
    return input;
  }
}

예시: 회원 정보 폼

// Controller에서 바인딩한 dto 가정: UserDto dto
El.form().attr("method","POST").attr("action","/users")
  .children(
    new CompFormGroup("username", "아이디",
      CompFormGroup.textInput("username", dto.getUsername(), "예: kosmos", false, false),
      "로그인에 사용됩니다.", null
    ),
    new CompFormGroup("email", "이메일",
      El.input().css("form-control").attr("type","email").attr("id","email").attr("name","email")
        .attr("value", dto.getEmail()).attr("placeholder","you@example.com"),
      "알림 및 비밀번호 찾기에 사용됩니다.", null
    ),
    new CompFormGroup("phone", "전화번호",
      El.input().css("form-control").attr("type","tel").attr("id","phone").attr("name","phone")
        .attr("value", dto.getPhone()).attr("inputmode","numeric"),
      "숫자만 입력하세요.", null
    ),
    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("/users").text("취소")
    )
  );

레이아웃: 세로 / 가로 / 그리드

세로 폼(기본)

모바일 친화적인 기본 형태.

  • 그룹마다 mb-3
  • 라벨 위, 컨트롤 아래

가로 폼(Horizontal)

레이블/컨트롤을 한 줄에 배치.

El.div().css("row mb-3").children(
  El.label().css("col-sm-3 col-form-label").attr("for","email").text("이메일"),
  El.div().css("col-sm-9").child(
    El.input().css("form-control").attr("type","email").attr("id","email").attr("name","email")
  )
);

그리드 폼(Grid)

여러 필드를 한 행에 조합.

El.div().css("row g-3").children(
  El.div().css("col-md-6").child( /* CompFormGroup A */ ),
  El.div().css("col-md-6").child( /* CompFormGroup B */ )
);

입력형 매핑 표

유형 권장 컨트롤 설명/주의
텍스트 input[type="text"] placeholder는 예시만, 의미 전달은 label
이메일 input[type="email"] 모바일 키보드 최적화
숫자 input[type="number"] 또는 tel 정확한 유효성은 서버에서 재검증
선택 <select> 옵션이 많으면 검색형으로(고급)
장문 <textarea> 행 수 조절 및 최대 길이 안내
체크/라디오 form-check 그룹 그룹 레이블 제공

체크박스/라디오 패턴

// 체크박스 그룹
El.div().css("mb-3").children(
  El.span().css("form-label d-block").text("수신 동의"),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","checkbox").attr("id","agreeEmail").attr("name","agreeEmail"),
    El.label().css("form-check-label").attr("for","agreeEmail").text("이메일")
  ),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","checkbox").attr("id","agreeSms").attr("name","agreeSms"),
    El.label().css("form-check-label").attr("for","agreeSms").text("SMS")
  )
);

// 라디오 그룹
El.div().css("mb-3").children(
  El.span().css("form-label d-block").text("알림 빈도"),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","radio").attr("name","freq").attr("id","freqDaily").attr("value","daily"),
    El.label().css("form-check-label").attr("for","freqDaily").text("매일")
  ),
  El.div().css("form-check").children(
    El.input().css("form-check-input").attr("type","radio").attr("name","freq").attr("id","freqWeekly").attr("value","weekly"),
    El.label().css("form-check-label").attr("for","freqWeekly").text("매주")
  )
);

접근성 체크리스트

  • 레이블 연결: label[for]input[id] 일치
  • 필수 표시: 시각적 *뿐 아니라 aria-required="true" 고려
  • 에러 힌트: 에러 영역에 id 부여 후 aria-describedby로 input과 연결
  • 키보드: 포커스 경로가 논리적이며 숨은 요소로 막히지 않도록

서버 & 보안 팁

주제 권장 지양
CSRF 폼마다 토큰 히든 필드 GET으로 상태 변경
입력 검증 서버/클라이언트 이중 검사 클라이언트만 신뢰
XSS 출력 시 이스케이프/화이트리스트 신뢰 안 된 HTML 그대로 출력
파일 업로드 확장자/크기/콘텐츠 타입 검사 원본 파일명/경로에 직접 저장

샘플: 에러 바인딩

// Controller 예시 (Spring)
@PostMapping("/users")
public String save(@Valid @ModelAttribute UserForm form, BindingResult br, Model model) {
  if (br.hasErrors()) {
    model.addAttribute("errors", Map.of(
      "username", br.getFieldError("username") != null ? br.getFieldError("username").getDefaultMessage() : null,
      "email",    br.getFieldError("email")    != null ? br.getFieldError("email").getDefaultMessage()    : null
    ));
    return "users/form"; // 다시 렌더
  }
  service.save(form);
  return "redirect:/users";
}
// 렌더 시 에러 주입 (예시)
String errUser = (String) ctx.model("errors.username");
String errMail = (String) ctx.model("errors.email");

El.form().attr("method","POST").attr("action","/users").children(
  new CompFormGroup("username", "아이디",
    CompFormGroup.textInput("username", form.getUsername(), "예: kosmos", false, false)
      .attr("aria-describedby", "usernameHelp" + (errUser != null ? " usernameError" : "")),
    "6~20자 영문/숫자 조합", errUser
  ),
  new CompFormGroup("email", "이메일",
    El.input().css("form-control").attr("type","email").attr("id","email").attr("name","email")
      .attr("value", form.getEmail()).attr("placeholder","you@example.com")
      .attr("aria-describedby", errMail != null ? "emailError" : null),
    null, errMail
  ),
  El.button().css("btn btn-primary").attr("type","submit").text("저장")
);

주의사항

placeholder 남용: 레이블을 대체하지 않습니다. 레이블은 항상 보이게 유지하세요.
에러 처리 부재: 서버 검증 실패 시 사용자가 무엇을 고쳐야 하는지 명확히 안내해야 합니다.
길이/패턴 안내: 입력 전에 규칙을 보여주면 오류가 크게 줄어듭니다.

다음으로