Validation
올바른 검증(Validation)은 정확한 데이터와 좋은 사용자 경험을 동시에 보장합니다. Kosmos DSL은 표준 Bean Validation(JSR-380)과 일관된 UI 상태(valid/invalid/feedback)를 쉽게 연결하도록 돕습니다.
검증 레이어 개요
입력 힌트(클라이언트)
- placeholder/도움말 텍스트
- 패턴·최대길이·필수 표기
- 가벼운 즉시 피드백
서버 검증(필수)
- Bean Validation 어노테이션
- Cross-field/도메인 규칙
- 신뢰 경계에서 최종 방어
UI 상태 연동
is-valid/is-invalid클래스.valid-feedback/.invalid-feedback- 에러 요약/포커스 관리
UI 상태 매핑
| 상태 | 컨트롤 클래스 | 피드백 영역 | 비고 |
|---|---|---|---|
| 유효(Valid) | form-control is-valid |
.valid-feedback |
선택적 사용 |
| 오류(Invalid) | form-control is-invalid |
.invalid-feedback |
오류 메시지 필수 |
| 도움말 | 없음 | .form-text |
규칙/예시 설명 |
DTO: Bean Validation 어노테이션
import jakarta.validation.constraints.*;
public class UserForm {
@NotBlank(message = "아이디는 필수입니다.")
@Size(min = 6, max = 20, message = "아이디는 6~20자입니다.")
private String username;
@NotBlank(message = "이메일은 필수입니다.")
@Email(message = "이메일 형식이 올바르지 않습니다.")
private String email;
@NotBlank(message = "비밀번호는 필수입니다.")
@Size(min = 8, message = "비밀번호는 8자 이상입니다.")
private String password;
@NotBlank(message = "비밀번호 확인을 입력하세요.")
private String confirmPassword;
// getter/setter...
}
Controller: BindingResult 처리
@PostMapping("/users")
public String save(@Valid @ModelAttribute("form") UserForm form,
BindingResult br,
Model model) {
// Cross-field 검증 (예: 비밀번호 일치)
if (!br.hasFieldErrors("password") && !br.hasFieldErrors("confirmPassword")) {
if (!form.getPassword().equals(form.getConfirmPassword())) {
br.rejectValue("confirmPassword", "password.mismatch", "비밀번호가 일치하지 않습니다.");
}
}
if (br.hasErrors()) {
model.addAttribute("errors", br.getFieldErrors().stream()
.collect(Collectors.toMap(FieldError::getField, DefaultMessageSourceResolvable::getDefaultMessage,
(a,b) -> a, LinkedHashMap::new)));
return "users/form"; // 다시 렌더
}
service.save(form);
return "redirect:/users";
}
렌더링 연동: is-invalid / invalid-feedback
// 에러 맵 취득(컨텍스트/모델에서)
@SuppressWarnings("unchecked")
Map<String,String> errs = (Map<String,String>) ctx.model("errors");
// 입력 컨트롤 생성 시 상태 클래스 부여
HtmlComponent usernameInput = El.input()
.css("form-control" + (errs != null && errs.containsKey("username") ? " is-invalid" : ""))
.attr("type","text").attr("id","username").attr("name","username")
.attr("value", form.getUsername());
// 그룹 렌더
El.div().css("mb-3").children(
El.label().css("form-label").attr("for","username").text("아이디"),
usernameInput,
// 도움말
El.div().css("form-text").text("6~20자 영문/숫자 조합"),
// 에러
errs != null && errs.get("username") != null
? El.div().css("invalid-feedback d-block").text(errs.get("username"))
: El.span().css("d-none").text("")
);
에러 요약 & 포커스 관리
// Error Summary (폼 상단)
public class CompErrorSummary implements HtmlComponent {
private final Map<String,String> errors;
public CompErrorSummary(Map<String,String> errors) { this.errors = errors; }
@Override public String render(RenderContext ctx) {
if (errors == null || errors.isEmpty()) return "";
var list = El.ul().css("mb-0");
errors.forEach((field, msg) -> {
// 같은 페이지 내 필드로 이동(anchor)
list.child(El.li().child(El.a().href("#" + field).text(msg)));
});
return El.div().css("alert alert-danger").attr("role","alert")
.children(El.h3().css("h6 mb-2").text("입력값을 확인하세요."), list).render(ctx);
}
}
<script>
// 첫 번째 에러 필드로 포커스 이동(선택 사항)
document.addEventListener("DOMContentLoaded", function () {
const errorField = document.querySelector(".is-invalid");
if (errorField) errorField.focus({ preventScroll: false });
});
</script>
비동기 검증(중복 체크)
서버 최종 검증은 필수입니다. 비동기는 UX 보조 용도로만 사용하세요.
// 간단한 비동기 유저명 중복 확인 버튼
El.div().css("input-group").children(
El.input().css("form-control").attr("type","text").attr("id","username").attr("name","username"),
El.button().css("btn btn-outline-secondary").attr("type","button").attr("id","checkUsernameBtn").text("중복 확인")
);
<script>
document.addEventListener("click", async (e) => {
if (e.target.id !== "checkUsernameBtn") return;
const input = document.getElementById("username");
const res = await fetch("/api/users/check-username?u=" + encodeURIComponent(input.value));
const json = await res.json(); // { available: true/false }
input.classList.remove("is-valid", "is-invalid");
if (json.available) {
input.classList.add("is-valid");
} else {
input.classList.add("is-invalid");
}
});
</script>
Cross-field 검증(예: 비밀번호 일치)
// 어노테이션
@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = PasswordMatchesValidator.class)
public @interface PasswordMatches {
String message() default "비밀번호가 일치하지 않습니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
// 검증기
public class PasswordMatchesValidator implements ConstraintValidator<PasswordMatches, UserForm> {
@Override public boolean isValid(UserForm value, ConstraintValidatorContext ctx) {
if (value == null) return true;
return Objects.equals(value.getPassword(), value.getConfirmPassword());
}
}
// DTO에 적용
@PasswordMatches
public class UserForm { /* ...위의 필드들... */ }
메시지 국제화(i18n)
# messages.properties
NotBlank.userForm.username=아이디는 필수입니다.
Size.userForm.username=아이디는 {min}~{max}자입니다.
Email.userForm.email=이메일 형식이 올바르지 않습니다.
password.mismatch=비밀번호가 일치하지 않습니다.
메시지 키 전략: 제약명.DTO명.필드명 또는 커스텀 코드 사용.
테스트 전략
- DTO 단위 테스트: Validator로 필드/교차 제약 검증
- 컨트롤러 통합 테스트: 잘못된 입력 →
BindingResult에러 발생/뷰 렌더 확인 - HTML 스냅샷: is-invalid/invalid-feedback 존재 여부 확인
주의사항
클라이언트만 의존 금지: 서버 검증은 항상 필요합니다. 클라이언트 검증은 우회될 수 있습니다.
모호한 메시지: “유효하지 않음” 같은 추상 표현을 지양하고, 수정 방법을 안내하세요.
포커스 관리: 에러 발생 시 첫 번째 오류 필드로 포커스를 이동하면 재입력 시간이 줄어듭니다.
다음으로
- Input Types — 텍스트/숫자/날짜/선택 컨트롤 심화
- File Upload — 업로드 검증/보안
- Alerts — 오류/성공 피드백 UX
- Page Template & Slots — 폼 레이아웃/에러 배치