[Spring] 20. @ModelAttribute와 DTO 바인딩

[Spring] 20. @ModelAttribute와 DTO 바인딩

이전 글에서는 @PathVariable에 대해 살펴봤습니다. @PathVariable은 URL 경로에 포함된 값을 Controller 메서드에서 받을 때 사용하는 어노테이션입니다.

이번 글에서는 폼 입력값을 객체로 받는 방법인 @ModelAttribute와 DTO 바인딩에 대해 정리하겠습니다. 게시글 작성, 회원가입, 로그인처럼 입력값이 여러 개 있는 경우에는 값을 하나씩 받는 것보다 DTO로 묶어서 받는 것이 더 깔끔합니다.

핵심은 간단합니다. @ModelAttribute는 요청 파라미터 값을 객체에 담아 Controller 메서드에서 사용할 수 있게 해주는 방법입니다.


1. DTO로 값을 받는 이유

사용자가 게시글 작성 폼에서 제목, 내용, 카테고리를 입력한다고 생각해보겠습니다.

<form action="/board/write" method="post">
    <input type="text" name="title">
    <textarea name="content"></textarea>
    <input type="text" name="category">
    <button type="submit">등록</button>
</form>

이 값들을 @RequestParam으로 하나씩 받을 수도 있습니다.

@PostMapping("/board/write")
public String write(
        @RequestParam String title,
        @RequestParam String content,
        @RequestParam String category
) {
    return "redirect:/board/list";
}

값이 1~2개라면 괜찮습니다. 하지만 입력값이 많아지면 Controller 메서드가 길어지고 관리하기 어려워집니다.

이럴 때 DTO를 사용하면 여러 값을 하나의 객체로 받을 수 있습니다.

@PostMapping("/board/write")
public String write(BoardDto boardDto) {
    return "redirect:/board/list";
}

이렇게 작성하면 폼에서 넘어온 값이 BoardDto 객체에 담깁니다. 이 과정을 DTO 바인딩이라고 부를 수 있습니다.


2. DTO란?

DTO는 Data Transfer Object의 줄임말입니다. 계층 사이에서 데이터를 전달할 때 사용하는 객체입니다.

Controller에서 Service로 데이터를 넘기거나, 폼 입력값을 하나의 객체로 받을 때 자주 사용합니다.

public class BoardDto {

    private String title;
    private String content;
    private String category;

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public String getContent() {
        return content;
    }

    public void setContent(String content) {
        this.content = content;
    }

    public String getCategory() {
        return category;
    }

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

DTO는 데이터를 담는 객체입니다. 보통 Controller, Service, Mapper처럼 Spring Bean으로 등록하지 않습니다. 요청이 들어올 때 값이 담기는 객체로 사용합니다.


3. DTO 바인딩이란?

DTO 바인딩은 요청 파라미터 값을 DTO 객체의 필드에 자동으로 연결하는 과정입니다.

HTML form의 name 값과 DTO의 필드명이 같으면 Spring이 값을 담아줄 수 있습니다.

<input type="text" name="title">
<textarea name="content"></textarea>
<input type="text" name="category">
public class BoardDto {

    private String title;
    private String content;
    private String category;
}

연결 흐름은 다음과 같습니다.

input name="title"
 → BoardDto.title

textarea name="content"
 → BoardDto.content

input name="category"
 → BoardDto.category

즉, 폼의 name과 DTO의 필드명이 맞아야 값이 자연스럽게 담깁니다.


4. @ModelAttribute 기본 사용법

@ModelAttribute를 사용하면 요청 파라미터를 객체에 담아 받을 수 있습니다.

@Controller
@RequestMapping("/board")
public class BoardController {

    @PostMapping("/write")
    public String write(@ModelAttribute BoardDto boardDto) {
        System.out.println(boardDto.getTitle());
        System.out.println(boardDto.getContent());
        System.out.println(boardDto.getCategory());

        return "redirect:/board/list";
    }
}

사용자가 글쓰기 폼을 제출하면 요청 파라미터가 BoardDto에 담깁니다.

POST /board/write

title=Spring
content=Spring MVC 공부
category=backend

→ BoardDto 객체에 값이 담김

이 구조를 사용하면 Controller 메서드가 훨씬 깔끔해집니다.


5. @ModelAttribute 생략 가능

Spring MVC에서는 단순 객체 타입의 파라미터에 대해 @ModelAttribute를 생략할 수 있습니다.

@PostMapping("/write")
public String write(BoardDto boardDto) {
    return "redirect:/board/list";
}

위 코드는 다음 코드와 비슷한 의미로 볼 수 있습니다.

@PostMapping("/write")
public String write(@ModelAttribute BoardDto boardDto) {
    return "redirect:/board/list";
}

처음 공부할 때는 값이 객체에 바인딩된다는 흐름을 이해하기 위해 @ModelAttribute를 붙여보는 것이 좋습니다. 익숙해지면 생략된 형태도 자연스럽게 읽을 수 있습니다.


6. 게시글 작성 전체 예시

게시글 작성 화면과 등록 처리를 함께 보겠습니다.

@Controller
@RequestMapping("/board")
public class BoardController {

    private final BoardService boardService;

    public BoardController(BoardService boardService) {
        this.boardService = boardService;
    }

    @GetMapping("/write")
    public String writeForm() {
        return "board/write";
    }

    @PostMapping("/write")
    public String write(@ModelAttribute BoardDto boardDto) {
        boardService.write(boardDto);
        return "redirect:/board/list";
    }
}

글쓰기 화면은 GET 요청으로 보여주고, 사용자가 작성한 값은 POST 요청으로 처리합니다.

GET /board/write
 → 글쓰기 화면 보여주기

POST /board/write
 → 폼 입력값을 BoardDto로 받기
 → Service 호출
 → 목록으로 redirect

Service에서는 DTO를 받아 필요한 처리를 이어갈 수 있습니다.

@Service
public class BoardService {

    private final BoardMapper boardMapper;

    public BoardService(BoardMapper boardMapper) {
        this.boardMapper = boardMapper;
    }

    public void write(BoardDto boardDto) {
        boardMapper.insertBoard(boardDto);
    }
}

7. 회원가입 예시

회원가입처럼 입력값이 많은 기능도 DTO로 받는 것이 좋습니다.

<form action="/member/join" method="post">
    <input type="text" name="memberId">
    <input type="password" name="memberPw">
    <input type="text" name="memberName">
    <input type="email" name="email">
    <button type="submit">가입</button>
</form>
public class MemberJoinDto {

    private String memberId;
    private String memberPw;
    private String memberName;
    private String email;

    // getter, setter
}
@Controller
@RequestMapping("/member")
public class MemberController {

    @GetMapping("/join")
    public String joinForm() {
        return "member/join";
    }

    @PostMapping("/join")
    public String join(@ModelAttribute MemberJoinDto memberJoinDto) {
        return "redirect:/member/login";
    }
}

폼의 name과 DTO 필드명이 같기 때문에 값이 DTO에 담길 수 있습니다.

name="memberId"   → memberJoinDto.memberId
name="memberPw"   → memberJoinDto.memberPw
name="memberName" → memberJoinDto.memberName
name="email"      → memberJoinDto.email

8. @ModelAttribute 이름 지정

@ModelAttribute에는 이름을 지정할 수도 있습니다. 이 이름은 View에서 사용할 Model 이름이 될 수 있습니다.

@PostMapping("/write")
public String write(@ModelAttribute("board") BoardDto boardDto) {
    return "board/result";
}

이 경우 boardDto 객체는 board라는 이름으로 Model에 담길 수 있습니다. View에서는 board라는 이름으로 접근할 수 있습니다.

@ModelAttribute("board")
 → BoardDto 객체를 board라는 이름으로 사용 가능

다만 단순히 입력값을 받아 Service로 넘기는 경우에는 이름을 지정하지 않아도 충분한 경우가 많습니다.


9. @ModelAttribute 메서드

@ModelAttribute는 메서드에도 사용할 수 있습니다. 메서드에 붙이면 Controller의 요청 처리 메서드가 실행되기 전에 Model에 공통 데이터를 담을 수 있습니다.

@Controller
@RequestMapping("/board")
public class BoardController {

    @ModelAttribute("categories")
    public List<String> categories() {
        return List.of("공지", "자유", "질문");
    }

    @GetMapping("/write")
    public String writeForm() {
        return "board/write";
    }
}

위 코드에서 categories() 메서드는 board/write 화면을 반환하기 전에 Model에 categories라는 이름으로 값을 담을 수 있습니다.

요청 처리 전
 → categories() 실행
 → Model에 categories 추가
 → writeForm() 실행
 → View 반환

처음에는 파라미터에 붙는 @ModelAttribute를 먼저 이해하고, 이후 공통 Model 데이터를 준비하는 메서드 방식도 함께 알아두면 됩니다.


10. @RequestParam과 DTO 바인딩 차이

요청 값을 받을 때 @RequestParam을 쓸지 DTO로 받을지 헷갈릴 수 있습니다. 기준은 입력값의 개수와 의미입니다.

상황 추천 방식 예시
값이 1~2개 @RequestParam boardNo, keyword, page
입력값이 여러 개 DTO 회원가입, 게시글 작성
검색 조건이 많음 SearchDto 검색어, 카테고리, 날짜, 정렬

예를 들어 게시글 상세 번호 하나만 필요하다면 @RequestParam이나 @PathVariable이 간단합니다.

@GetMapping("/detail")
public String detail(@RequestParam Long boardNo) {
    return "board/detail";
}

반대로 게시글 작성처럼 제목, 내용, 카테고리처럼 여러 입력값이 있다면 DTO가 더 깔끔합니다.

@PostMapping("/write")
public String write(BoardDto boardDto) {
    return "redirect:/board/list";
}

11. @ModelAttribute와 @RequestBody 차이

@ModelAttribute와 자주 헷갈리는 어노테이션이 @RequestBody입니다. 둘 다 요청 데이터를 객체로 받을 수 있지만, 주로 받는 데이터 형태가 다릅니다.

구분 @ModelAttribute @RequestBody
주 사용 데이터 폼 데이터, 요청 파라미터 JSON 요청 Body
주 사용 화면 JSP, Thymeleaf form REST API
예시 title=Spring&content=내용 {"title":"Spring","content":"내용"}

JSP나 Thymeleaf에서 form을 전송하는 구조라면 보통 @ModelAttribute 또는 생략된 DTO 바인딩을 사용합니다. JSON 데이터를 API로 받을 때는 @RequestBody를 사용합니다.

@PostMapping("/api/boards")
@ResponseBody
public String writeApi(@RequestBody BoardDto boardDto) {
    return "ok";
}

처음에는 이렇게 구분하면 됩니다.

HTML form 데이터
 → @ModelAttribute 또는 DTO 바인딩

JSON 데이터
 → @RequestBody

12. DTO 바인딩 조건

DTO 바인딩이 자연스럽게 동작하려면 몇 가지 조건을 맞추는 것이 좋습니다.

조건 설명
폼 name과 DTO 필드명 일치 name="title"이면 DTO에도 title 필드가 있어야 합니다.
기본 생성자 Spring이 객체를 만들 수 있도록 기본 생성자가 필요할 수 있습니다.
setter 또는 접근 가능한 바인딩 방식 요청 값을 필드에 넣을 수 있어야 합니다.
타입 변환 가능 문자열 값을 숫자, 날짜 등으로 바꿀 수 있어야 합니다.

예를 들어 name="price" 값이 숫자로 들어오면 DTO의 price 필드 타입이 숫자여야 자연스럽게 변환될 수 있습니다.

<input type="number" name="price">
private Integer price;

13. 자주 하는 실수

1) form name과 DTO 필드명이 다른 경우

<input type="text" name="boardTitle">
private String title;

폼에서는 boardTitle로 보냈는데 DTO 필드명은 title입니다. 이름이 다르면 값이 담기지 않을 수 있습니다.

이럴 때는 이름을 맞추는 것이 가장 간단합니다.

<input type="text" name="title">
private String title;

2) DTO를 Bean으로 등록하려는 경우

DTO는 요청 데이터를 담는 객체입니다. 보통 @Component, @Service 같은 어노테이션을 붙여 Bean으로 등록하지 않습니다.

@Component
public class BoardDto {
}

이런 방식은 일반적으로 피하는 것이 좋습니다. DTO는 요청마다 값이 달라지는 데이터 객체입니다.

3) JSON 요청을 @ModelAttribute로 받으려는 경우

JSON 요청 Body를 받을 때는 @ModelAttribute가 아니라 @RequestBody를 사용해야 합니다.

{
  "title": "Spring",
  "content": "DTO 바인딩"
}
@PostMapping("/api/boards")
@ResponseBody
public String write(@RequestBody BoardDto boardDto) {
    return "ok";
}

4) 숫자 필드에 문자가 들어오는 경우

DTO 필드가 숫자 타입인데 요청 값이 문자라면 타입 변환 오류가 발생할 수 있습니다.

private Integer price;
price=abc
 → Integer로 변환하기 어려움

입력 화면에서 숫자만 입력되도록 제한하거나, 검증 처리를 추가하는 것이 좋습니다. 검증은 이후 글에서 따로 정리하겠습니다.

5) DTO에 너무 많은 책임을 넣는 경우

DTO는 데이터를 전달하기 위한 객체입니다. 기능 흐름이나 DB 처리 로직을 DTO 안에 넣는 것은 피하는 것이 좋습니다.

DTO
 → 데이터 전달 역할

Service
 → 기능 흐름 처리

Mapper / Repository
 → DB 접근 처리

14. 전체 흐름 정리

게시글 작성 폼을 기준으로 DTO 바인딩 흐름을 정리하면 다음과 같습니다.

1. 사용자가 글쓰기 화면에서 값을 입력한다.
2. 등록 버튼을 누른다.
3. form이 POST /board/write 요청을 보낸다.
4. input name 값과 DTO 필드명이 매칭된다.
5. Spring이 BoardDto 객체를 만들고 값을 담는다.
6. Controller 메서드가 BoardDto를 받는다.
7. Controller가 Service에 BoardDto를 전달한다.
8. Service가 기능 흐름을 처리한다.

이 흐름을 이해하면 게시글 작성, 회원가입, 로그인, 검색 조건 처리 같은 기능을 훨씬 깔끔하게 구성할 수 있습니다.


정리

@ModelAttribute는 요청 파라미터 값을 객체에 바인딩할 때 사용하는 어노테이션입니다. Spring MVC에서는 단순 객체 타입 파라미터의 경우 @ModelAttribute를 생략해도 DTO 바인딩이 동작할 수 있습니다.

@RequestParam 값이 1~2개일 때 간단하게 받기 좋습니다.
@ModelAttribute 요청 파라미터를 DTO 객체에 바인딩합니다.
DTO 여러 입력값을 하나의 객체로 묶어 전달합니다.
@RequestBody JSON 요청 Body를 객체로 받을 때 사용합니다.

처음에는 다음 기준으로 기억하면 좋습니다.

쿼리 파라미터 한두 개
 → @RequestParam

폼 입력값 여러 개
 → DTO 바인딩 또는 @ModelAttribute

JSON 요청
 → @RequestBody

연습해보기

아래 폼 데이터를 받을 DTO와 Controller 메서드를 직접 생각해보세요.

<form action="/review/write" method="post">
    <input type="text" name="title">
    <textarea name="content"></textarea>
    <input type="number" name="score">
    <button type="submit">등록</button>
</form>

예시는 다음과 같습니다.

public class ReviewDto {

    private String title;
    private String content;
    private Integer score;

    // getter, setter
}
@PostMapping("/review/write")
public String write(@ModelAttribute ReviewDto reviewDto) {
    return "redirect:/review/list";
}

다음 글에서는 JSON 요청 데이터를 객체로 받는 @RequestBody에 대해 정리하겠습니다. 폼 데이터와 JSON 데이터가 Controller에서 어떻게 다르게 처리되는지 비교해보면 이해하기 쉽습니다.