본문 바로가기

체크박스·라디오·셀렉트

게시판 공개 여부와 분류·태그 선택을 통해 체크박스 hidden 표시, 단일·복수 선택, 열거형 변환 실패와 선택지 소유권을 검증합니다.

선택 컨트롤은 화면에서는 비슷해 보여도 전송하는 규칙이 다릅니다. 선택하지 않은 체크박스는 파라미터 자체를 보내지 않고, 라디오 버튼은 같은 이름 가운데 하나만 보내며, 다중 선택 목록은 한 이름을 여러 번 보냅니다. Spring과 Thymeleaf의 편의 기능을 쓰더라도 실제 HTTP 파라미터가 무엇인지 알아야, 수정 화면에서 이전 값이 남는 오류를 막을 수 있습니다.

checkbox는 marker만 전송되면 이전 true를 false로 바꾸고, tagCodes marker는 기존 선택을 빈 목록으로 바꿉니다. category QUESTION은 enum, categoryCode NOTICE는 String이며 알 수 없는 enum EXTREME은 단위 바인더의 typeMismatch입니다. 반복 tagCodes는 순서와 중복을 보존합니다. 실제 MVC POST는 바인딩 뒤 화면만 다시 그리며 현재 활성 옵션·소유권 조회와 정책/command 실행은 추가 제품 설계입니다.

PROCESS · WIRE SHAPE · CURRENT POLICY

선택 입력의 전송 모양과 실제 바인딩 결과를 비교한다

checkbox marker·단일 값·반복 값은 서로 다른 상태를 표현합니다. 이 교재가 실행한 바인딩·재표시와 추가로 설계해야 할 현재 옵션·소유권 정책을 나누어 읽습니다.

원 POST의 바인딩·재표시·오류 경계문자열·marker·반복값을 새 폼에 바인딩한 원 POST의 구현 범위이다. 변환 오류가 없을 때만 update가 같은 화면200을 반환한다. category의알수없는enum처럼바인딩오류가있으면인접BindingResult가없는현재핸들러에진입하지않고기본MVC400이다. tagCodesList의중복과categoryCodeString의허용옵션정책검증은이POST에연결되지않았다. marker는권한증거가아니며저장/현재활성조회는추가설계이다.오류 없음바인딩 오류POST 전송값marker · 반복 값WebDataBinder폼 속성 · 타입 변환update 진입정책 · 저장 호출 없음같은 화면 200checked · selected기본 MVC 400handler 진입 전같은 폼 반환 아님
원 POST의 바인딩·재표시·오류 경계문자열·marker·반복값을 새 폼에 바인딩한 원 POST의 구현 범위이다. 변환 오류가 없을 때만 update가 같은 화면200을 반환한다. category의알수없는enum처럼바인딩오류가있으면인접BindingResult가없는현재핸들러에진입하지않고기본MVC400이다. tagCodesList의중복과categoryCodeString의허용옵션정책검증은이POST에연결되지않았다. marker는권한증거가아니며저장/현재활성조회는추가설계이다.오류 없음바인딩 오류POST 전송값marker · 반복 값WebDataBinder폼 속성 · 타입 변환update 진입정책 · 저장 호출 없음같은 화면 200checked · selected기본 MVC 400handler 진입 전같은 폼 반환 아님

이 흐름은 현재 POST의 구현 경계입니다. 아래 전송 모양 비교와 단위 바인더의 기존 객체 조건을 함께 읽으세요. List의 중복·선택지 활성 상태·소유권과 저장 정책은 이 POST에 연결되어 있지 않습니다.

CHECKBOX · MARKER ONLY

이전 true라도 해제하면 false로

체크 상태는 publicVisible=true와 _publicVisible=on을 보냅니다. 해제하면 marker만 보냅니다. 단위 바인더는 기존 true를 false로 바꾸고, MVC marker-only POST는 unchecked HTML을 만듭니다. marker는 권한 증명이 아닙니다.

RADIO / SINGLE SELECT

같은 코드라도 대상 타입을 구분한다

category=QUESTION은 PostCategory enum, categoryCode=NOTICE는 String 속성입니다. GET fixture는 QUESTION radio checked와 NOTICE option selected를 확인합니다. category=EXTREME은 단위 바인더의 typeMismatch입니다.

REPEATED PARAMETERS

원본 List의 순서와 중복을 남긴다

tagCodes=java&tagCodes=spring처럼 같은 이름을 반복합니다. 단위 바인더가 java·spring·java 세 값을 같은 순서로 보존합니다. 처음부터 Set으로 받으면 중복 관찰을 잃습니다. 쉼표 연결은 브라우저 폼의 기본 전송 규칙이 아닙니다.

COLLECTION MARKER

모두 해제하면 빈 목록으로

_tagCodes=on만 보내면 단위 바인더는 기존 java 선택을 비웁니다. 실제 MVC marker-only POST의 폼과 HTML도 빈 tagCodes와 checked 없음, marker 존재를 확인합니다.

IMPLEMENTED FIXTURE

실제 POST는 바인딩 후 화면 재표시

컨트롤러는 고정 categories·tags를 모델에 넣고 바인딩된 form으로 같은 템플릿을 렌더링합니다. 서비스·repository 조회·정책 검증·command 실행은 이 POST에 연결되어 있지 않습니다. 5개 단위 바인더와 2개 MVC 렌더링 테스트의 범위를 구분합니다.

PRODUCT POLICY / EXERCISE

현재 활성·접근 범위 검사는 추가 설계

제품에서는 POST 시점의 존재·활성·사용자 접근 범위를 다시 조회하고 원본 List의 개수·중복을 검사해야 합니다. 연습의 TagSelectionPolicy는 최대3·중복·blank를 거부하지만 현재 POST에 연결되지 않았습니다. marker나 GET 옵션은 이 검사를 대신하지 않습니다.

선택 상태를 옮기는 바인더와 현재 권한·업무 정책을 검사하는 애플리케이션의 책임은 다릅니다. 이 그림의 wire 비교는 실행된 fixture를 설명하며 아래 제품 정책은 구현 범위를 확대하는 설계 과제입니다.

그림의 위쪽은 이 글의 POST가 실제로 하는 일입니다. 마커(marker)와 반복 값을 포함한 전송 값을 WebDataBinder가 새 폼에 바인딩하고, 변환 오류가 없으면 update가 같은 화면을 200으로 다시 그립니다. 알 수 없는 열거형 값처럼 바인딩 오류가 있으면, 인접한 BindingResult가 없는 이 핸들러에는 들어가지 못하고 기본 MVC의 400이 됩니다. 아래 칸들은 체크박스의 마커, 라디오와 단일 셀렉트의 대상 타입, 반복 파라미터, 컬렉션 마커의 전송 형태를 비교합니다. 이 POST는 저장이나 현재 활성 옵션, 소유권 조회를 실행하지 않으며, 그 정책과 명령 연결은 제품 설계와 연습 문제에서 다룰 범위입니다.


선택하지 않은 체크박스의 값

수정 폼에서 publicVisible=true인 객체를 열었다가 체크박스를 해제하면, 브라우저는 publicVisible 파라미터를 보내지 않습니다. 바인더가 값이 없는 것을 기존 값 유지로 해석하면 true가 계속 남습니다. Thymeleaf의 th:field는 숨은 표시 _publicVisible을 함께 만들어, 컨트롤은 화면에 있었지만 선택하지 않았다는 것을 알립니다.

src/main/resources/templates/controls/post-settings.html
<!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="@{/b88/controls/posts/{id}/settings(id=${postId})}"
          th:object="${form}" method="post">
      <label>
        <input type="checkbox" th:field="*{publicVisible}" />
        다른 회원에게 공개
      </label>
      <fieldset>
        <legend>게시글 분류</legend>
        <label th:each="category : ${categories}">
          <input type="radio" th:field="*{category}"
                 th:value="${category}" />
          <span th:text="${category.label}">질문</span>
        </label>
      </fieldset>
      <div th:replace="~{controls/_post-settings-options :: options}"></div>
      <button type="submit">설정 저장</button>
    </form>
  </main>
</body>
</html>

렌더링된 체크박스에는 name="publicVisible" value="true"와 name="_publicVisible" value="on"이 있습니다. 체크하면 둘 다 전송되고, 해제하면 표시만 전송됩니다. 공격자도 이 표시를 마음대로 만들 수 있으므로 이것은 보안 장치가 아니라 바인딩 상태를 나타내는 장치이며, 권한은 따로 검사해야 합니다.

더 깊이: 마커가 채우는 빈 값과 기본값 접두사 !

WebDataBinder API 문서에 따르면 _ 마커는 그 필드가 제출된 폼에 있었다는 표시이며, 값은 무엇이든 상관없고 존재만 확인합니다. 대응하는 값 파라미터가 없을 때 필드에 넣는 빈 값은 타입마다 다릅니다. 불리언은 false, 배열은 빈 배열, Collection과 Map은 빈 구현을 넣고, 그 밖의 타입은 null을 넣습니다. 그래서 마커만 온 String 필드는 빈 문자열이 아니라 null이 됩니다.

빈 값 대신 정해진 값을 넣고 싶을 때 쓰는 접두사도 있습니다. !publicVisible처럼 !로 시작하는 파라미터는 같은 이름의 값 파라미터가 오지 않았을 때 그 값을 기본값으로 씁니다. Spring Framework 7.0.10의 소스에서 바인더는 기본값 접두사, 마커 접두사 순서로 처리한 뒤 일반 바인딩으로 넘어가며, 기본값은 허용된 필드이고 쓸 수 있는 속성이며 값이 오지 않았을 때만 더합니다. 따라서 Spring 폼 바인딩처럼 허용 목록을 둔 바인더라면 !approved=true를 보내도 목록 밖의 필드에는 값이 들어가지 않습니다. 두 접두사는 setFieldMarkerPrefix와 setFieldDefaultPrefix로 바꾸거나 null을 넘겨 끌 수 있습니다.


라디오와 단일 셀렉트

선택지를 템플릿에 문자열로 따로 적어 두면, Java 열거형이 바뀌었을 때 화면과 바인더가 어긋납니다. 컨트롤러가 허용 집합을 모델에 넣고, 폼은 열거형을 받습니다.

src/main/java/board/controls/PostSettingsForm.java
package board.controls;

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 getCategory() {
        return category;
    }

    public void setCategory(PostCategory category) {
        this.category = category;
    }

    public String getCategoryCode() {
        return categoryCode;
    }

    public void setCategoryCode(String categoryCode) {
        this.categoryCode = categoryCode;
    }

    public List<String> getTagCodes() {
        return List.copyOf(tagCodes);
    }

    public void setTagCodes(List<String> tagCodes) {
        this.tagCodes = tagCodes == null
                ? new ArrayList<>()
                : new ArrayList<>(tagCodes);
    }

    public enum PostCategory {
        GENERAL("일반"), QUESTION("질문"), NOTICE("공지");

        private final String label;

        PostCategory(String label) {
            this.label = label;
        }

        public String getLabel() {
            return label;
        }
    }
}

열거형의 이름은 저장하고 전송하는 값이 되므로 함부로 이름을 바꾸지 않습니다. 외부에 노출되는 값을 안정시키려면 NOTICE 대신 별도의 코드를 두고, Converter<String, PostCategory>에서 허용된 코드를 해석합니다. 레이블은 로케일별 메시지로 따로 둘 수 있습니다.


여러 개를 선택하는 바인딩

태그 java와 spring을 선택하면 본문은 tagCodes=java&tagCodes=spring 형태가 됩니다. 쉼표 하나로 합쳐 보내는 것은 별도의 컨버터를 써야 하는 방식이며 HTML 폼의 기본 동작이 아닙니다. 컬렉션 속성은 null 대신 빈 컬렉션으로 초기화해 두어, 아무것도 선택하지 않은 상태를 안정적으로 다룹니다.

src/main/resources/templates/controls/_post-settings-options.html
<div xmlns:th="http://www.thymeleaf.org" th:fragment="options">
  <label for="categoryCode">분류</label>
  <select id="categoryCode" th:field="*{categoryCode}">
    <option value="">분류를 선택하세요</option>
    <option th:each="category : ${categories}"
            th:value="${category.name()}"
            th:text="${category.label}">일반</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="*{tagCodes}"가 붙은 체크박스는 현재 폼의 tagCodes에 자신의 value가 들어 있으면 checked로 렌더링되고, 셀렉트도 categoryCode와 같은 값의 옵션을 선택 상태로 만듭니다. 그래서 검증 오류로 폼을 다시 보여 줄 때도 사용자가 고른 값이 유지됩니다. 체크박스에는 이름이 _tagCodes인 숨은 입력도 함께 렌더링되어, 아무것도 고르지 않고 제출해도 Spring이 이 필드가 비었다는 것을 알 수 있습니다. 완성된 템플릿은 이 조각을 폼 안에서 교체하므로, 부모의 th:object가 셀렉트와 태그 체크박스에도 이어집니다.

src/main/java/board/controls/PostSettingsController.java
package board.controls;

import java.util.List;

import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;

@Controller
@RequestMapping("/b88/controls/posts/{postId}/settings")
public final class PostSettingsController {
    @ModelAttribute("categories")
    List<PostSettingsForm.PostCategory> categories() {
        return List.of(PostSettingsForm.PostCategory.values());
    }

    @ModelAttribute("tags")
    List<TagOption> tags() {
        return List.of(
                new TagOption("java", "Java"),
                new TagOption("spring", "Spring"));
    }

    @GetMapping
    String edit(@PathVariable long postId, Model model) {
        var form = new PostSettingsForm();
        form.setPublicVisible(true);
        form.setCategory(PostSettingsForm.PostCategory.QUESTION);
        form.setCategoryCode("NOTICE");
        form.setTagCodes(List.of("spring"));
        model.addAttribute("postId", postId);
        model.addAttribute("form", form);
        return "controls/post-settings";
    }

    @PostMapping
    String update(
            @PathVariable long postId,
            @ModelAttribute("form") PostSettingsForm form,
            Model model
    ) {
        model.addAttribute("postId", postId);
        return "controls/post-settings";
    }

    public record TagOption(String code, String name) {}
}

예를 들어 GET의 edit()가 만든, 공개가 참이고 태그가 spring인 폼은 그 응답의 모델일 뿐입니다. POST에는 그 폼을 세션이나 조회로 다시 가져오는 코드가 없으므로, 새 PostSettingsForm에 전송 값을 바인딩합니다. 앞에서 본 이전 true가 남는 사례는 뒤의 체크박스 마커 단위 테스트처럼 이미 true인 객체에 바인딩한다는 전제에서 나옵니다. 두 전제를 섞으면 마커 없는 POST에서도 GET의 선택이 저절로 이어진다고 오해할 수 있습니다.

POST의 categoryCode는 문자열이라 EXTREME도 바인딩되지만, category는 열거형이라 같은 값이 typeMismatch가 됩니다. 이 핸들러에는 인접한 BindingResult가 없으므로 그 변환 오류는 메서드에 들어가기 전에 예외 경로로 빠집니다. 기본 MVC 처리는 400이며, 같은 폼을 돌려주는 update()의 정상 재표시와 다릅니다. 오류 값과 선택지를 같은 화면에 돌려주려면 앞 글처럼 오류 객체를 받고 표시 모델을 완성하는 구현이 따로 필요합니다. Spring의 모델 속성 오류 처리와 기본 예외 상태를 함께 확인할 수 있습니다.

등록에 실패한 뒤 다시 렌더링할 때 th:field는 바인딩된 컬렉션과 옵션 값을 비교해 checked와 selected를 되살립니다. 문자열의 대소문자나 앞뒤 공백은 몰래 보정하지 않으며, 코드를 정규화해야 한다면 입력을 받는 곳에서 한 번만 하고 DB의 정규 코드와 비교합니다.

선택지 목록은 화면 표시와 입력 검증이라는 두 가지 역할을 합니다. 컨트롤러가 보여 준 선택지가 요청 사이에 비활성화될 수 있으므로, POST 시점에 저장소에서 다시 조회해 활성 상태와 현재 사용자의 접근 범위를 확인합니다. 클라이언트가 보낸 코드가 옵션 목록에 있었을 것이라고 가정하는 것은 권한 검사가 아닙니다.


알 수 없는 선택 값과 테스트

src/test/java/board/controls/PostSettingsBindingTest.java
package board.controls;

import static org.assertj.core.api.Assertions.assertThat;

import java.util.List;
import java.util.Map;

import org.junit.jupiter.api.Test;
import org.springframework.beans.MutablePropertyValues;
import org.springframework.web.bind.WebDataBinder;

class PostSettingsBindingTest {
    @Test
    void checkbox_marker는_기존_true를_false로_바꾼다() {
        var form = new PostSettingsForm();
        form.setPublicVisible(true);
        var binder = new WebDataBinder(form, "form");

        binder.bind(new MutablePropertyValues(
                Map.of("_publicVisible", "on")));

        assertThat(form.isPublicVisible()).isFalse();
        assertThat(binder.getBindingResult().hasErrors()).isFalse();
    }

    @Test
    void category_속성들은_같은_이름의_accessor로_바인딩된다() {
        var form = new PostSettingsForm();
        var binder = new WebDataBinder(form, "form");

        binder.bind(new MutablePropertyValues(
                Map.of(
                        "category", "QUESTION",
                        "categoryCode", "NOTICE")));

        assertThat(form.getCategory())
                .isEqualTo(PostSettingsForm.PostCategory.QUESTION);
        assertThat(form.getCategoryCode()).isEqualTo("NOTICE");
        assertThat(binder.getBindingResult().hasErrors()).isFalse();
    }

    @Test
    void 존재하지_않는_enum은_typeMismatch로_남는다() {
        var form = new PostSettingsForm();
        var binder = new WebDataBinder(form, "form");

        binder.bind(new MutablePropertyValues(
                Map.of("category", "EXTREME")));

        assertThat(binder.getBindingResult()
                .getFieldError("category")
                .getCode()).isEqualTo("typeMismatch");
    }

    @Test
    void 반복된_tagCodes는_원본_순서와_중복을_보존한다() {
        var form = new PostSettingsForm();
        var binder = new WebDataBinder(form, "form");
        var values = new MutablePropertyValues();
        values.add("tagCodes", new String[]{"java", "spring", "java"});

        binder.bind(values);

        assertThat(form.getTagCodes())
                .containsExactly("java", "spring", "java");
    }

    @Test
    void tagCodes_marker는_기존_선택을_빈_목록으로_바꾼다() {
        var form = new PostSettingsForm();
        form.setTagCodes(List.of("java"));
        var binder = new WebDataBinder(form, "form");

        binder.bind(new MutablePropertyValues(
                Map.of("_tagCodes", "on")));

        assertThat(form.getTagCodes()).isEmpty();
        assertThat(binder.getBindingResult().hasErrors()).isFalse();
    }
}
src/test/java/board/controls/ControlsTestApplication.java
package board.controls;

import org.springframework.boot.SpringBootConfiguration;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.context.annotation.Import;

@SpringBootConfiguration(proxyBeanMethods = false)
@EnableAutoConfiguration
@Import(PostSettingsController.class)
public class ControlsTestApplication {}
src/test/java/board/controls/PostSettingsMvcRenderTest.java
package board.controls;

import static java.nio.charset.StandardCharsets.UTF_8;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view;

import org.jsoup.Jsoup;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest(classes = ControlsTestApplication.class)
@AutoConfigureMockMvc
class PostSettingsMvcRenderTest {
    @Autowired
    MockMvc mvc;

    @Test
    void 수정_화면은_marker와_저장된_선택을_fragment까지_렌더링한다()
            throws Exception {
        var response = mvc.perform(get(
                        "/b88/controls/posts/42/settings"))
                .andExpect(status().isOk())
                .andExpect(view().name("controls/post-settings"))
                .andReturn()
                .getResponse();
        var document = Jsoup.parse(response.getContentAsString(UTF_8));

        assertThat(document.select(
                "form[action='/b88/controls/posts/42/settings']"))
                .hasSize(1);
        assertThat(document.select(
                "input[name=_publicVisible][value=on]")).hasSize(1);
        assertThat(document.select(
                "input[name=publicVisible][checked]")).hasSize(1);
        assertThat(document.select(
                "input[name=category][value=QUESTION][checked]")).hasSize(1);
        assertThat(document.select(
                "select[name=categoryCode] option[value=NOTICE][selected]"))
                .hasSize(1);
        assertThat(document.select(
                "input[name=_tagCodes][value=on]")).isNotEmpty();
        assertThat(document.select(
                "input[name=tagCodes][value=spring][checked]")).hasSize(1);
    }

    @Test
    void marker만_POST하면_체크박스_선택을_비운_상태로_다시_그린다()
            throws Exception {
        var result = mvc.perform(post(
                        "/b88/controls/posts/42/settings")
                        .param("_publicVisible", "on")
                        .param("_tagCodes", "on"))
                .andExpect(status().isOk())
                .andExpect(view().name("controls/post-settings"))
                .andReturn();
        var form = (PostSettingsForm) result.getModelAndView()
                .getModel().get("form");
        var document = Jsoup.parse(
                result.getResponse().getContentAsString(UTF_8));

        assertThat(form.isPublicVisible()).isFalse();
        assertThat(form.getTagCodes()).isEmpty();
        assertThat(document.select(
                "input[name=publicVisible][checked]")).isEmpty();
        assertThat(document.select(
                "input[name=tagCodes][checked]")).isEmpty();
        assertThat(document.select(
                "input[name=_tagCodes][value=on]")).isNotEmpty();
    }
}

단위 바인더 테스트는 HTML 렌더링을 대신하지 않습니다. MVC 테스트에서는 실제 폼과 마커만 보내는 POST를 렌더링하고, 단위 WebDataBinder 테스트에서는 반복 파라미터와 알 수 없는 열거형 값을 보냅니다. 두 단계는 전송 값과 바인딩, 재표시 템플릿이 제대로 이어지는지 확인합니다. 실제 브라우저에서의 사용자 조작과 전송, 현재 옵션의 활성 상태와 권한, 저장 결과는 따로 확인해야 합니다.


입력 컨트롤 선택 기준

질문체크박스라디오 버튼선택 목록
선택 개수불리언이나 여러 개최대 하나하나나 multiple
선택하지 않았을 때 전송파라미터 없음이름 없음빈 옵션이나 이름 없음
다시 표시하는 기준값이 포함되었는지값이 같은지옵션 값이 같은지
대표적인 위험표시에 대한 오해허용 밖의 열거형비활성 옵션의 변조

옵션이 세 개니까 라디오 버튼을 쓰는 식으로 모양부터 고르지 않습니다. 사용자가 반드시 하나를 골라야 하는지, 선택하지 않은 것이 올바른 상태인지, 선택지가 얼마나 자주 바뀌는지를 먼저 정한 뒤 컨트롤을 고릅니다. 선택지가 수십 개라면 검색할 수 있는 콤보 상자를 생각해 볼 수 있지만, 키보드와 화면 낭독기 동작도 함께 구현해야 합니다.

민감한 불리언은 체크박스 하나로 바로 바꾸지 않습니다. 공개 전환처럼 영향이 큰 기능은 현재 상태와 변경 결과, CSRF 보호, 소유권을 모두 확인합니다. Spring의 데이터 바인딩은 값을 옮길 뿐, 업무의 승인 절차를 제공하지 않습니다.


정리

  • 선택하지 않은 체크박스는 파라미터를 보내지 않으므로, th:field가 만드는 _publicVisible 같은 마커가 선택하지 않았다는 것을 알립니다.
  • 마커는 대응하는 값이 없을 때만 필드를 거짓이나 빈 목록으로 초기화하며, 보안 장치가 아니라 바인딩 상태의 표시입니다.
  • 선택지는 컨트롤러가 허용 집합으로 모델에 넣고, 열거형 이름은 외부에 노출되는 값이므로 함부로 바꾸지 않습니다.
  • 여러 개를 고르면 같은 이름이 반복 전송되며, List로 받아야 순서와 중복을 그대로 볼 수 있습니다.
  • 인접한 BindingResult가 없으면 열거형 변환 실패는 핸들러 전에 400이 되며, 선택지의 활성 상태와 권한은 POST 때 서버가 다시 확인합니다.

연습 문제

1. 태그는 최대 세 개까지

게시글에 태그를 최대 세 개까지만 고를 수 있게 하려고 합니다. 화면에서는 네 번째 선택을 안내하되, 서버는 tagCodes가 네 번 전송되거나, 같은 코드가 반복되거나, 비활성 태그가 들어 있는 경우를 각각 거부해야 합니다. 실패한 뒤에는 사용자가 보낸 선택을 안전하게 다시 보여 줘야 합니다. 검사를 어디에 두고 무엇으로 테스트하나요?

해설 보기

DTO 검증에서 개수와 중복을 확인하고, 애플리케이션 서비스에서 활성 태그를 조회합니다. 컬렉션을 처음부터 Set으로 받으면 중복 공격을 조용히 지워 버리므로, 원본 List에서 중복 여부를 먼저 검사합니다.

src/main/java/board/controls/TagSelectionPolicy.java
package board.controls;

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에서 태그 하나가 비활성화된 시점을 재현해, 오래된 화면에서 보낸 요청도 통과하지 못하게 합니다.

2. 기존 객체와 마커, 연결된 정책

단위 바인더 세 개는 각각 공개가 참이고 태그가 [java]인 새 객체로 시작합니다. A는 두 필드와 마커를 모두 생략하고, B는 _publicVisible=on과 _tagCodes=on만 보내며, C는 두 마커와 publicVisible=true, 반복된 tagCodes=java&tagCodes=spring&tagCodes=java를 함께 보냅니다. 각각의 공개 여부와 태그 목록을 정하세요. 별도의 MVC 요청으로 POST에 categoryCode=EXTREME과 위의 반복 태그를 보낸다면, 바인딩과 화면 반환, 중복 거부, 저장이 실제로 어디까지 일어나나요? 이름을 category로 바꿔 EXTREME을 보내면 같은 결과인가요?

해설 보기

A는 값과 마커가 없으므로 기존의 참과 [java]를 유지합니다. B는 컨트롤이 있었지만 값이 없다는 마커를 바인더가 처리해 거짓과 빈 목록이 됩니다. C는 실제 값이 있으므로 마커가 그것을 덮어 비우지 않아, 참과 [java, spring, java]이며 순서와 중복을 그대로 보존합니다. WebDataBinder의 마커 규칙은 대응하는 값이 없을 때만 초기화한다고 정합니다.

MVC 요청의 categoryCode는 문자열 EXTREME이 되고 반복 태그도 그대로 바인딩됩니다. 핸들러는 같은 폼을 200으로 돌려주지만 TagSelectionPolicy를 호출하지도, 저장하지도 않습니다. 그 정책을 따로 호출하면 목록 크기는 3이라 첫 검사는 통과하고 중복 검사에서 거부됩니다. 코드가 글에 있다는 것과 요청 경로에 연결되어 있다는 것은 다른 일입니다.

category=EXTREME으로 바꾸면 열거형 변환 오류가 납니다. 지금의 MVC 경로에는 인접한 오류 객체가 없으므로 update()의 재표시까지 가지 못하고 기본 400으로 처리됩니다. 정확한 오류 화면이나 본문은 추가한 어드바이스와 설정이 정합니다. 이 결과들은 코드와 기본 MVC 규칙에서 끌어낸 예상이므로 테스트를 실행해 확인합니다.

다음 글 메시지·국제화에서는 화면 문구를 코드와 로케일로 나누고, 번들의 탐색과 대체가 예측할 수 있게 동작하도록 만듭니다.