Basic Forms
폼은 데이터 입력의 출발점입니다. Kosmos DSL로 레이블/컨트롤/헬프/에러가 일관된 패턴으로 조합되도록 표준 컴포넌트를 정의하고, 접근성(ARIA)·검증·레이아웃 규칙을 소개합니다.
폼 구성 원칙
- 1 필드 = 1 그룹(Form Group):
label·control·help·error로 구성 - 레이블은 항상 명시:
for↔id연결로 접근성 보장 - 검증 메시지 표준화: 성공/경고/오류 색은 팀 공통 유틸 클래스 사용
- 단일 책임: 폼 그룹 컴포넌트는 표현만, 값 검증/바인딩은 서비스/컨트롤러
코드 심화: 표준 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 남용: 레이블을 대체하지 않습니다. 레이블은 항상 보이게 유지하세요.
에러 처리 부재: 서버 검증 실패 시 사용자가 무엇을 고쳐야 하는지 명확히 안내해야 합니다.
길이/패턴 안내: 입력 전에 규칙을 보여주면 오류가 크게 줄어듭니다.
다음으로
- Validation — 검증 메시지/상태 표시 표준
- Input Types — 텍스트/숫자/날짜/선택 컴포넌트
- File Upload — 업로드 컨트롤/보안 가이드
- Alerts — 피드백/상태 메시지 연계
- Page Template & Slots — 폼 레이아웃 배치