체크박스·라디오·셀렉트
게시판 공개 여부와 제목·태그 선택을 통해 체크박스 hidden 표시, 단일·복수 선택, 열거형 변환 실패와 선택지 소유권을 검증합니다.
선택 제어는 화면에서는 비슷해 보여도 전송 규칙이 다릅니다.
선택하지 않은 체크박스는 파라미터 자체를 보내지 않고, 라디오 버튼은 같은 이름 중 하나만 보내며, 다중 선택 목록은 한 이름을 여러 번 보냅니다.
Spring과 Thymeleaf의 편의 기능을 쓰더라도 실제 HTTP 파라미터가 무엇인지 알아야 수정 화면에서 이전 값이 남는 오류를 막을 수 있습니다.
체크박스 미선택 값
수정 폼에서 public=true인 객체를 열었다가 체크박스를 해제하면 브라우저는 public 파라미터를 보내지 않습니다.
바인더가 “값 없음”을 기존 값 유지로 해석하면 true가 계속 남습니다.
Thymeleaf의 th:field는 _public 숨은 표시를 함께 만들어 “제어는 화면에 있었지만 선택하지 않았다”를 알립니다.
<!doctype html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="utf-8" />
<title>게시글 표시 설정</title>
</head>
<body>
<main>
<h1>게시글 표시 설정</h1>
<form th:action="@{/posts/{id}/settings(id=${postId})}"
th:object="${form}" method="post">
<label>
<input type="checkbox" th:field="*{publicVisible}" />
다른 회원에게 공개
</label>
<fieldset>
<legend>게시글 분류</legend>
<label th:each="level : ${categorys}">
<input type="radio" th:field="*{category}"
th:value="${level}" />
<span th:text="${level.label}">질문</span>
</label>
</fieldset>
<button type="submit">설정 저장</button>
</form>
</main>
</body>
</html>렌더링된 체크박스에는 name="publicVisible" value="true"와 name="_publicVisible" value="on"이 존재합니다.
체크박스가 체크되면 둘 다 전송되고, 해제되면 표시만 전송됩니다.
공격자가 표시를 임의로 만들 수 있으므로 이것은 보안 장치가 아니라 바인딩 상태 표현입니다.
권한은 별도로 검사해야 합니다.
라디오·단일 셀렉트
선택지를 템플릿에 문자열로 중복하면 Java 열거형이 바뀌었을 때 화면과 바인더가 어긋납니다.
컨트롤러가 허용 집합을 모델에 넣고 폼은 열거형을 받습니다.
package board.web;
import java.util.ArrayList;
import java.util.List;
public final class PostSettingsForm {
private boolean publicVisible;
private PostCategory category;
private String categoryCode = "";
private List<String> tagCodes = new ArrayList<>();
public boolean isPublicVisible() {
return publicVisible;
}
public void setPublicVisible(boolean publicVisible) {
this.publicVisible = publicVisible;
}
public PostCategory getPostCategory() {
return category;
}
public void setPostCategory(PostCategory category) {
this.category = category;
}
public String getTitleCode() {
return categoryCode;
}
public void setTitleCode(String categoryCode) {
this.categoryCode = categoryCode;
}
public List<String> getTagCodes() {
return List.copyOf(tagCodes);
}
public void setTagCodes(List<String> tagCodes) {
this.tagCodes = new ArrayList<>(tagCodes);
}
public enum PostCategory {
GENERAL("일반"), QUESTION("질문"), NOTICE("공지");
public final String label;
PostCategory(String label) {
this.label = label;
}
}
}열거형 이름은 저장·전송 계약이 되므로 무심코 이름 변경하지 않습니다.
외부 계약을 안정시키려면 NOTICE 대신 별도의 코드를 두고 Converter<String, PostCategory>에서 허용 코드를 해석합니다.
레이블은 로케일별 메시지로 분리할 수 있습니다.
복수 선택 바인딩
태그 java, spring을 선택하면 본문은 tagCodes=java&tagCodes=spring 모양입니다.
쉼표 하나로 합쳐 보내는 것은 별도 컨버터 계약이며 HTML 폼 기본 동작이 아닙니다.
컬렉션 속성은 null보다 빈 컬렉션으로 초기화하여 미선택 상태를 안정적으로 다룹니다.
<div xmlns:th="http://www.thymeleaf.org">
<label for="categoryCode">제목</label>
<select th:field="*{categoryCode}">
<option value="">분류를 선택하세요</option>
<option th:each="category : ${categories}"
th:value="${category.code}"
th:text="${category.name}">Java</option>
</select>
<fieldset>
<legend>태그</legend>
<label th:each="tag : ${tags}">
<input type="checkbox" th:field="*{tagCodes}"
th:value="${tag.code}" />
<span th:text="${tag.name}">Backend</span>
</label>
</fieldset>
</div>등록 실패 후 다시 렌더링할 때 th:field는 결합된 컬렉션과 옵션 값을 비교해 checked와 selected를 복원합니다.
문자열 대소문자나 앞뒤 공백을 암묵적으로 보정하지 않습니다.
코드 정규화가 필요하면 입력 경계에서 한 번만 수행하고 DB의 정규 코드와 비교합니다.
선택지 목록은 화면 표시와 입력 검증이라는 두 역할을 갖습니다.
컨트롤러가 보여 준 선택지가 요청 사이에 비활성화될 수 있으므로 POST 시점에 리포지토리에서 다시 조회해 활성 상태와 현재 사용자의 접근 범위를 확인합니다.
클라이언트가 보낸 코드가 옵션에 있었을 것이라는 가정은 권한 검사가 아닙니다.
알 수 없는 선택 값
package board.web;
import static org.assertj.core.api.Assertions.assertThat;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.validation.DataBinder;
class PostSettingsBindingTest {
@Test
void checkbox_marker는_기존_true를_false로_바꾼다() {
var form = new PostSettingsForm();
form.setPublicVisible(true);
var binder = new DataBinder(form, "form");
binder.bind(new MutablePropertyValues(
Map.of("_publicVisible", "on")));
assertThat(form.isPublicVisible()).isFalse();
assertThat(binder.getBindingResult().hasErrors()).isFalse();
}
@Test
void 존재하지_않는_enum은_typeMismatch로_남는다() {
var form = new PostSettingsForm();
var binder = new DataBinder(form, "form");
binder.bind(new MutablePropertyValues(
Map.of("category", "EXTREME")));
assertThat(binder.getBindingResult()
.getFieldError("category")
.getCode()).isEqualTo("typeMismatch");
}
}PostSettingsBindingTest
> checkbox_marker는_기존_true를_false로_바꾼다() PASSED
> 존재하지_않는_enum은_typeMismatch로_남는다() PASSED
publicVisible = false
category rejected value = EXTREME
error code = typeMismatch단위 바인더 테스트는 HTML 렌더링을 대신하지 않습니다.
MVC 테스트에서는 실제 폼을 렌더링해 숨은 표시와 옵션 값을 확인하고, POST 테스트에서는 반복 파라미터와 변조 값을 보냅니다.
두 단계가 만나야 브라우저와 서버 계약 전체를 검증합니다.
입력 컨트롤 선택 기준
| 질문 | 체크박스 | 라디오 버튼 | 선택 목록 |
|---|---|---|---|
| 선택 개수 | 불리언 또는 복수 | 정확히 하나 | 하나 또는 multiple |
| 미선택 전송 | 파라미터 없음 | 이름 없음 | 빈 옵션 또는 이름 없음 |
| 재표시 기준 | 값 포함 여부 | 값 동일성 | 옵션 값 동일성 |
| 대표 위험 | 표시 오해 | 허용 밖 열거형 | 비활성 옵션 변조 |
“옵션이 세 개니까 라디오 버튼”처럼 모양으로 먼저 고르지 않습니다.
사용자가 반드시 하나를 선택해야 하는지, 선택하지 않음이 유효한 상태인지, 선택지가 얼마나 자주 바뀌는지 결정한 뒤 제어를 고릅니다.
선택지가 수십 개라면 검색 가능한 콤보 상자를 고려하되 키보드와 화면 낭독기 동작을 함께 구현합니다.
민감한 불리언은 체크박스 하나로 즉시 변경하지 않습니다.
공개 전환처럼 영향이 큰 기능은 현재 상태, 변경 결과, CSRF 보호, 소유권을 모두 확인합니다.
Spring의 데이터 바인딩은 값을 옮길 뿐 업무 승인 절차를 제공하지 않습니다.
연습 문제
게시글에 최대 세 개의 태그만 선택할 수 있게 만드세요.
화면에서는 네 번째 선택을 안내하되, 서버는 tagCodes가 네 번 전송되거나 같은 코드가 반복되거나 비활성 태그가 포함된 경우를 각각 거부해야 합니다.
실패 후 사용자가 보낸 선택을 안전하게 다시 보여 주세요.
해설 보기
DTO 검증에서 크기와 중복을 확인하고, 애플리케이션 서비스에서 활성 태그를 조회합니다.
컬렉션을 Set으로 바로 받으면 중복 공격을 조용히 지우므로 원본 List에서 중복 여부를 먼저 검사합니다.
package board.web;
import java.util.HashSet;
import java.util.List;
public final class TagSelectionPolicy {
public void validate(List<String> codes) {
if (codes.size() > 3) {
throw new IllegalArgumentException("at most three tags");
}
if (new HashSet<>(codes).size() != codes.size()) {
throw new IllegalArgumentException("duplicate tag code");
}
if (codes.stream().anyMatch(String::isBlank)) {
throw new IllegalArgumentException("blank tag code");
}
}
}MVC 테스트는 param("tagCodes", "java", "spring", "java")처럼 반복 값을 그대로 보냅니다.
서비스 테스트에서는 DB에서 하나가 비활성화된 시점을 재현해 오래된 화면도 통과하지 못하게 합니다.
다음 문서에서는 화면 문구를 코드와 로케일로 분리하고, 번들 탐색과 대체가 예측 가능하게 작동하도록 만듭니다.