본문으로 건너뛰기

안동민 개발노트

본문 시작

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

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

선택 제어는 화면에서는 비슷해 보여도 전송 규칙이 다릅니다.

선택하지 않은 체크박스는 파라미터 자체를 보내지 않고, 라디오 버튼은 같은 이름 중 하나만 보내며, 다중 선택 목록은 한 이름을 여러 번 보냅니다.

Spring과 Thymeleaf의 편의 기능을 쓰더라도 실제 HTTP 파라미터가 무엇인지 알아야 수정 화면에서 이전 값이 남는 오류를 막을 수 있습니다.

선택 입력은 전송 모양을 보존한 뒤 현재 허용 집합에서 다시 검증한다

PROCESS · WIRE SHAPE · CURRENT POLICY

선택 입력은 전송 모양을 보존한 뒤 현재 허용 집합에서 다시 검증한다

checkbox marker는 WebDataBinder에서 해제 의도를 복원하고, radio·select·반복 parameter는 원형을 유지한 채 POST 시점의 활성 옵션과 소유권을 다시 확인한다.

선택 입력은 전송 모양을 보존한 뒤 현재 허용 집합에서 다시 검증한다 GET에서 현재 선택지와 기존 폼 상태를 렌더링한 뒤 브라우저가 checkbox marker, 단일 category, 반복 tagCodes를 전송한다. WebDataBinder가 이를 폼 상태로 바꾸고 서버가 현재 활성 옵션, 소유권, 개수와 중복을 다시 확인한다. 통과한 코드만 command로 전달하고 실패한 원문은 BindingResult와 함께 안전하게 재표시한다. RENDER SUBMIT BIND PRESERVE YES · USE NO GET · MODEL 현재 허용 옵션 + 기존 form state RAW HTTP PARAMETERS marker · 단일 값 · 반복 값 순서와 중복을 먼저 보존한다 WEB DATA BINDER _marker · enum · List 바인딩 checkbox marker 복원은 WebDataBinder 책임 BOUND FORM STATE boolean · category · categoryCode · List 알 수 없는 enum은 typeMismatch로 남는다 CURRENT POLICY 활성·소유권·개수·중복을 통과했는가? GET 때 보였다는 사실은 권한 증명이 아니다 COMMAND 검증된 code만 전달 BINDING RESULT 원문을 안전하게 재표시 WIRE SHAPES unchecked: _publicVisible=on radio: category=QUESTION multi: tagCodes=java&tagCodes=spring TWO CHECKPOINTS GET: 현재 label과 option을 표시 POST: repository의 현재 상태를 재조회 marker는 보안 장치가 아니다 LEGEND WebDataBinder boundary current-policy decision rejected input
  1. STEP 1 · RENDER

    현재 옵션과 기존 폼 상태를 함께 렌더링한다

    checkbox는 미선택 parameter가 없으므로 _publicVisible marker를 만들고, radio와 select는 안정적인 code를 value로 사용합니다.

  2. STEP 2 · SUBMIT

    브라우저가 보낸 원래 wire shape를 보존한다

    radio는 category=QUESTION, 복수 선택은 같은 tagCodes 이름을 여러 번 보냅니다. 중복과 순서를 Set으로 먼저 지우지 않습니다.

  3. STEP 3 · BIND

    WebDataBinder가 marker와 변환 실패를 기록한다

    marker만 있으면 기존 truefalse로 바꾸고, 허용 밖 enum은 typeMismatch와 거부된 값을 BindingResult에 남깁니다.

  4. STEP 4 · RECHECK

    POST 시점의 활성 옵션과 소유권을 다시 조회한다

    GET에서 보인 옵션도 제출 전 바뀔 수 있습니다. 현재 repository 상태에서 존재·활성·접근 범위와 선택 수·중복을 다시 검사합니다.

  5. STEP 5 · COMMIT OR REDISPLAY

    통과한 code만 command로 만들고 실패 원문은 안전하게 돌려준다

    marker는 상태 표현일 뿐 권한 증명이 아닙니다. 정책을 통과하지 못하면 서비스를 호출하지 않고 수정 가능한 값만 재표시합니다.

  • WebDataBinder 경계
  • wire shape 보존
  • 현재 정책 거부

checkbox·radio·select의 비교는 본문의 native 표가 맡는다. 이 흐름은 marker를 보안 장치로 오해하지 않고, 원본 List에서 개수와 중복을 확인한 뒤 현재 활성 옵션과 소유권을 다시 증명하는 경계를 고정한다.


체크박스 미선택 값

수정 폼에서 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"이 존재합니다.

체크박스가 체크되면 둘 다 전송되고, 해제되면 표시만 전송됩니다.

공격자가 표시를 임의로 만들 수 있으므로 이것은 보안 장치가 아니라 바인딩 상태 표현입니다.

권한은 별도로 검사해야 합니다.


라디오·단일 셀렉트

선택지를 템플릿에 문자열로 중복하면 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>

완성 템플릿은 이 fragment를 폼 안에서 교체하므로 부모의 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) {}
}

등록 실패 후 다시 렌더링할 때 th:field는 결합된 컬렉션과 옵션 값을 비교해 checkedselected를 복원합니다.

문자열 대소문자나 앞뒤 공백을 암묵적으로 보정하지 않습니다.

코드 정규화가 필요하면 입력 경계에서 한 번만 수행하고 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 테스트에서는 실제 폼과 marker-only POST를 렌더링하고, 단위 WebDataBinder 테스트에서는 반복 파라미터와 알 수 없는 열거형 값을 보냅니다.

두 단계가 만나야 브라우저와 서버 계약 전체를 검증합니다.


입력 컨트롤 선택 기준

질문체크박스라디오 버튼선택 목록
선택 개수불리언 또는 복수정확히 하나하나 또는 multiple
미선택 전송파라미터 없음이름 없음빈 옵션 또는 이름 없음
재표시 기준값 포함 여부값 동일성옵션 값 동일성
대표 위험표시 오해허용 밖 열거형비활성 옵션 변조

“옵션이 세 개니까 라디오 버튼”처럼 모양으로 먼저 고르지 않습니다.

사용자가 반드시 하나를 선택해야 하는지, 선택하지 않음이 유효한 상태인지, 선택지가 얼마나 자주 바뀌는지 결정한 뒤 제어를 고릅니다.

선택지가 수십 개라면 검색 가능한 콤보 상자를 고려하되 키보드와 화면 낭독기 동작을 함께 구현합니다.

민감한 불리언은 체크박스 하나로 즉시 변경하지 않습니다.

공개 전환처럼 영향이 큰 기능은 현재 상태, 변경 결과, CSRF 보호, 소유권을 모두 확인합니다.

Spring의 데이터 바인딩은 값을 옮길 뿐 업무 승인 절차를 제공하지 않습니다.


연습 문제

게시글에 최대 세 개의 태그만 선택할 수 있게 만드세요.

화면에서는 네 번째 선택을 안내하되, 서버는 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에서 하나가 비활성화된 시점을 재현해 오래된 화면도 통과하지 못하게 합니다.

다음 문서에서는 화면 문구를 코드와 로케일로 분리하고, 번들 탐색과 대체가 예측 가능하게 작동하도록 만듭니다.