[Spring] HTML 화면에서 POST 바인딩 실습하기: ViewController로 화면 라우팅

이지연·2026년 1월 19일

이전 글에서 @ResponseBody 유무에 따라 text/json/html 응답이 갈리고, GET/POST에서 @PathVariable, @RequestParam, @ModelAttribute, @RequestBody, @RequestPart로 바인딩이 이뤄진다는 걸 정리했다.
이번 글은 그걸 “Postman”이 아니라 브라우저 HTML 화면에서 직접 요청을 날려서 확인하는 파트다.

CORS 때문에 실습용 HTML을 스프링 서버가 직접 내려주고(같은 Origin), 그 HTML에서 axios/fetch를 호출하게 만드는 형태로 실습을 진행할 예정이다.


왜 View 컨트롤러를 따로 두나

브라우저는 기본적으로 Origin이 다르면 요청을 막거나(또는 preflight가 터지거나) 해서 CORS 에러가 쉽게 난다.
그래서 실습에서는 프론트 서버(예: 3000/5173)를 따로 띄우는 대신, 스프링(8080)에서 HTML을 내려준다. 그러면 JS 요청도 같은 Origin에서 나가니까 CORS 이슈를 크게 줄일 수 있다.

보충: 그래도 CORS가 나면 그때는 @CrossOrigin이나 전역 CORS 설정으로 풀어야 한다.


실습 화면 라우팅 코드

실습 화면 진입용 URL을 아래처럼 잡아뒀다.

// CORS 에러 방지를 위해 view 컨트롤러 생성
@Controller
@RequestMapping("/post-view")
public class ViewController {

    @GetMapping("/1_url-encoded")
    static String url(){
        return "1_url-encoded";
    }

    @GetMapping("/2-1_multipart-form-data")
    static String formData1(){
        return "2-1_multipart-form-data";
    }

    @GetMapping("/3-1_json")
    static String json1(){
        return "3-1_json";
    }

    @GetMapping("/3-2_json-nested")
    static String json2(){
        return "3-2_json-nested";
    }

    @GetMapping("/3-3_json-with-file")
    static String json3(){
        return "3-3_json-with-file";
    }
}

ViewController가 하는 일

@Controller에서 String을 반환하면(그리고 @ResponseBody가 없으면) 그 문자열은 “데이터”가 아니라 “뷰 이름”으로 해석된다.
return "1_url-encoded";templates/1_url-encoded.html을 렌더링해서 내려주겠다는 뜻이다.

  • /post-view/1_url-encoded1_url-encoded.html
  • /post-view/2-1_multipart-form-data2-1_multipart-form-data.html
  • /post-view/3-1_json3-1_json.html
  • /post-view/3-2_json-nested3-2_json-nested.html
  • /post-view/3-3_json-with-file3-3_json-with-file.html

이제 각 화면에서 버튼 클릭으로 POST 요청을 날리고, Network 탭 + 서버 콘솔 로그로 바인딩 결과를 확인하면 된다.


1) url-encoded 화면: “가장 기본 폼 전송”

url-encoded는 HTML <form>이 POST로 제출될 때 기본값으로 잡히는 인코딩 타입이다.
그래서 사실 파일이 없는 단순 폼이면 enctype="application/x-www-form-urlencoded"는 명시 안 해도 동작한다.
이 페이지의 목적은 “HTML 폼 submit → 서버에서 @ModelAttribute로 객체 바인딩”이 어떻게 되는지 눈으로 확인하는 것이다.

<form action="http://localhost:8081/member/url-encoded"
      method="post"
      enctype="application/x-www-form-urlencoded">
  이름: <input type="text" name="name">
  이메일: <input type="text" name="email">
  <input type="submit" value="제출">
</form>

체크 포인트

  • input의 name="name", name="email"이 서버 바인딩의 “키”가 된다.
  • DevTools → Network에서 Request Payload(또는 Form Data)가 name=...&email=... 형태로 전송되는지 확인하면 된다.

2) multipart/form-data(단일 파일): “FormData + 파일 1개”

파일이 포함되면 multipart/form-data로 보내는 게 정석이다.[1]
브라우저에서는 <input type="file">이 기본적으로 files 컬렉션을 가지므로, 단일 파일만 보낼 거면 files[0]으로 하나를 꺼내야 한다.
그리고 FormData.append()로 키/값을 쌓아서 axios로 보내면 multipart 요청이 만들어진다.

프로필: <input type="file" id="fileList">
<button type="button" onClick="postData()">제출</button>
<script>
  const postData = async() => {
    const fileList = document.getElementById("fileList");
    const file = fileList.files[0];

    const serverData = new FormData();
    serverData.append('name', name);
    serverData.append('email', email);
    serverData.append('profileImage', file);

    await axios.post("http://localhost:8081/member/multipart-formdata", serverData);
  }
</script>

체크 포인트

  • 서버에서 받는 파라미터 이름이 profileImage면, append('profileImage', file)로 키를 정확히 맞춰야 한다.
  • multipart는 boundary가 필요한데, 브라우저 환경에서는 보통 axios/fetch가 알아서 처리하므로 임의로 Content-Type을 덮어쓰는 건 오히려 문제를 만들 수 있다.

3) multipart/form-data(다중 파일): “같은 키로 여러 번 append”

multiple을 주면 사용자가 여러 파일을 선택할 수 있고, 그 결과는 input.files에 여러 개가 들어온다.
이때 FormData.append()를 같은 키로 여러 번 호출하면 “그 키에 값이 여러 개”인 형태로 전송된다.
즉 아래 코드처럼 profileImages를 반복 append하면 서버에서는 List<MultipartFile> 같은 형태로 받는 구성이 자연스럽다.

프로필: <input type="file" id="fileList" multiple>
<button type="button" onClick="postData()">제출</button>

<script>
  const postData = async() => {
    const fileList = document.getElementById("fileList");

    const serverData = new FormData();
    serverData.append('name', name);
    serverData.append('email', email);

    for (let i = 0; i < fileList.files.length; i++) {
      serverData.append('profileImages', fileList.files[i]);
    }

    await axios.post("http://localhost:8081/member/multipart-formdata-plura", serverData);
  }
</script>

체크 포인트

  • Network 탭에서 profileImages가 여러 줄(여러 파트)로 들어갔는지 확인한다.
  • 서버에서는 profileImages라는 같은 이름으로 여러 파일이 들어오므로 리스트로 매핑되는 흐름을 확인하면 된다.

4) JSON 단일 데이터: @RequestBody 바인딩 확인

axios에서 객체를 그대로 post()에 넣으면 JSON으로 직렬화해서 보내는 게 일반적인 사용 패턴이다.
그래서 아래 코드는 “폼 submit”이 아니라 “JS가 JSON 바디를 만들어서 API로 던지는 방식”이고, 서버에서는 보통 @RequestBody Member member로 받는 흐름을 확인하면 된다.

const serverData = {name, email}; // 단축속성명으로 JSON 직렬화
await axios.post("http://localhost:8081/member/json", serverData);

체크 포인트

  • Network 탭에서 Request Payload가 JSON 형태로 찍히는지 확인한다.
  • url-encoded처럼 name=...&email=...이 아니라, JSON body로 들어간다는 게 핵심이다.

5) JSON 중첩 데이터: “배열/객체 조립해서 보내기”

중첩 JSON은 “요청 데이터 구조”만 서버 DTO/객체 구조랑 맞으면 그대로 바인딩된다.
그래서 이 페이지의 핵심은 input이 여러 개 있을 때 값을 뽑아서 scores: [] 배열로 조립하는 과정이다.
grade가 여러 개라서 class로 묶고 반복문으로 각각 {subject, point} 객체를 만들어 push한 방식은 의도가 명확하다.

// 조립해야할 데이터 형태 : {"name": String, "email": String, "scores":[{"subject": String, "point": int}, ...]}
const grades = document.getElementsByClassName('grade');
for (let i = 0; i < grades.length; i++) {
  const subject = document.getElementsByClassName('subjects')[i].value;
  const point = document.getElementsByClassName('points')[i].value;
  scores.push({subject, point});
}
await axios.post("http://localhost:8081/member/json-nested", {name, email, scores});

체크 포인트

  • point는 input에서 꺼내면 문자열로 들어오니까, 서버에서 int로 받고 싶으면 변환/검증 포인트가 생긴다(바인딩/검증에서 자주 체감).
  • 제출 후 reload() 해버리면 Network 확인하기 전에 화면이 새로고침될 수 있으니, 처음엔 reload를 잠깐 빼고 보는 것도 방법이다.

6) JSON + 파일: @RequestPart로 쪼개서 받기

“JSON + 파일”을 한 번에 보내려면 요청 자체는 보통 multipart/form-data가 된다.
이때 JSON 파트와 파일 파트를 각각 part로 분리해서 받으려고 @RequestPart를 쓰는 흐름이 가장 깔끔하다.
그래서 프론트에서도 FormData에 JSON을 그냥 문자열로 넣기보다, Blob으로 type: "application/json"을 명시해서 “이 파트는 JSON이다”를 분명히 해주는 패턴을 많이 쓴다

const member = {name, email};
const memberJson = JSON.stringify(member);

const serverData = new FormData();
serverData.append('member', new Blob([memberJson], { type: "application/json" }));
serverData.append('profileImg', file);

await axios.post("http://localhost:8081/member/json-file", serverData);

체크 포인트

  • append('member', ...), append('profileImg', ...)의 키 이름이 서버 @RequestPart("member"), @RequestPart("profileImg")랑 정확히 일치해야 한다.
  • FormData.append()는 value로 Blob/File을 받을 수 있다(그래서 위 방식이 가능한 것).
profile
Eazy하게

0개의 댓글