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 존재 여부 확인

주의사항

클라이언트만 의존 금지: 서버 검증은 항상 필요합니다. 클라이언트 검증은 우회될 수 있습니다.
모호한 메시지: “유효하지 않음” 같은 추상 표현을 지양하고, 수정 방법을 안내하세요.
포커스 관리: 에러 발생 시 첫 번째 오류 필드로 포커스를 이동하면 재입력 시간이 줄어듭니다.

다음으로