Model (dto) <-> html 뷰 데이터 매핑
- 스프링의 데이터 바인딩(@ModelAttribute)은 html의 name 속성 값과 dto 객체의 필드(변수) 이름을 비교해서 값을 채워 넣는다. 따라서 변수명을 동일하게 해주어야 한다.
public class MemberForm {
private String name; // 이 필드 이름
private String city;
// ...
}
<form action="/members/new" method="post">
<div>
<label for="user-name">이름</label>
<input type="text" id="user-name" name="name" placeholder="이름을 입력하세요">
</div>
<div>
<label for="user-city">도시</label>
<input type="text" id="user-city" name="city" placeholder="도시를 입력하세요">
</div>
<button type="submit">등록</button>
</form>
또한 DTO는 화면에 맞게(뷰가 필요로 하는 데이터만 담고, 보여주면 안되는 데이터는 숨기는) 설계하는 것이 좋다.
SSR 환경에서의 입력값 예외 처리 (Validation)
@Getter
@Setter
public class MemberForm {
@NotEmpty(message = "회원 이름은 필수로 입력되어야 합니다.")
private String name;
private String city;
private String street;
private String zipcode;
}
@Controller
@RequiredArgsConstructor
public class MemberController {
private final MemberService memberService;
@GetMapping("/members/new")
public String createForm(Model model) {
model.addAttribute("memberForm", new MemberForm());
return "members/createMemberForm";
}
@PostMapping("/members/new")
public String create(@Valid MemberForm memberForm, BindingResult result) {
if (result.hasErrors()) {
return "members/createMemberForm";
}
Address address = new Address(memberForm.getCity(), memberForm.getStreet(), memberForm.getZipcode());
Member member = new Member();
member.setAddress(address);
member.setName(memberForm.getName());
memberService.join(member);
return "redirect:/";
}
}
- 속성에 NotEmpty 어노테이션을 통해 제약조건을 걸어주고, 컨트롤러에서 Valid 어노테이션을 이용하면, 해당 제약조건에 대한 검증을 할 수 있다.
- 그리고 Valid에 의해 오류가 검출되면, BindingResult 패러미터에 그 오류 메시지(어노테이션의 message)가 전달되고, 해당 BindingResult 객체와 입력 폼 객체를 리턴해주는 view에 전달해준다.
- 따라서 오류에 걸린 그 항목 외에 다른 항목들은 데이터가 그대로 유지된 채로 다시 뷰로 반환되기에, 입력한 그 값이 유지된다.
vs REST API 일 때
- 위에서는 프론트가 없는 SSR 환경에서 클라이언트 측 입력값 오류를 처리하는 방식이다.
- 프론트가 있는 환경에서는 REST API로 구축하기에, 컨트롤러에서 뷰를 반환하지 않고 JSON 형태의 데이터를 반환해준다. 따라서 에러를 controller에서 처리하는 것이 아닌, 전역 예외 처리로 빼주는 것이 좋은 구조이다.
- Valid에 실패하면 MethodArgumentNotValidException 라는 예외가 터지고, 이를 전역 예외 처리기에서 처리해주면 된다.
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/members")
public class MemberApiController {
private final MemberService memberService;
/**
* @RequestBody: JSON 데이터를 MemberForm 객체로 바인딩
* BindingResult가 없이, 검증 실패 시 MethodArgumentNotValidException 예외 던짐
*/
@PostMapping("/new")
public ResponseEntity<String> create(@Valid @RequestBody MemberForm memberForm) {
// 1. (오류가 없어야 여기로 옴) 로직 수행
Address address = new Address(memberForm.getCity(), memberForm.getStreet(), memberForm.getZipcode());
Member member = new Member();
member.setAddress(address);
member.setName(memberForm.getName());
memberService.join(member);
// 2. 성공 응답 반환
return ResponseEntity.ok("회원가입 성공");
}
}
// @RestController에서 발생하는 예외를 가로챔
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public Map<String, String> handleValidationExceptions(MethodArgumentNotValidException ex) {
// 1. 오류가 난 필드와 메시지를 담을 Map 생성
Map<String, String> errors = new HashMap<>();
// 2. 예외 객체에서 모든 필드 오류를 꺼내서 Map에 담음
ex.getBindingResult().getAllErrors().forEach((error) -> {
String fieldName = ((FieldError) error).getField();
String errorMessage = error.getDefaultMessage();
errors.put(fieldName, errorMessage);
});
// 3. 클라이언트(프론트엔드)에게 JSON 형태로 오류 Map을 반환
// 예: { "name": "이름은 필수입니다" }
return errors;
}
}
Entity 생성은 정적 메서드를 이용
@PostMapping("/items/new")
public String create(BookForm form) {
Book book = new Book();
// setter가 많아서 상당히 번잡하다
book.setName(form.getName());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
itemService.save(book);
return "redirect:/";
}
아래처럼 Book 엔티티에 로직을 중앙화하고, 캡슐화와 일관성을 높이면서 의도를 명확하게 한다.
@Entity
@Getter
// @Setter 제거
public class Book extends Item {
private String author;
private String isbn;
// 1. JPA를 위한 'protected' 기본 생성자
// (JPA는 리플렉션으로 객체를 만들어야 하므로,
// 단, public이 아닌 protected로 막아서 'new Book()' 사용 방지)
protected Book() {
}
// 2. === 정적 팩토리 메서드 ===
public static Book createBook(BookForm form) {
Book book = new Book();
// 3. 생성 시점의 로직을 여기에 중앙화
// (예: if (form.getPrice() < 0) { ... })
book.setName(form.getName());
book.setAuthor(form.getAuthor());
book.setIsbn(form.getIsbn());
book.setPrice(form.getPrice());
book.setStockQuantity(form.getStockQuantity());
return book;
}
// 3. 만약 재고처럼 변경이 필요하다면,
// public setter 대신 의도가 명확한 '비즈니스 메서드'를 제공
/** 재고 증가 */
public void addStock(int quantity) {
this.setStockQuantity(this.getStockQuantity() + quantity);
}
/** 재고 감소 */
public void removeStock(int quantity) {
int restStock = this.getStockQuantity() - quantity;
if (restStock < 0) {
throw new IllegalStateException("재고가 부족합니다.");
}
this.setStockQuantity(restStock);
}
}
@PostMapping("/items/new")
public String create(BookForm form) {
// 컨트롤러는 생성 팩토리 메서드만 호출
Book book = Book.createBook(form);
itemService.save(book);
return "redirect:/";
}
'공부 > Spring' 카테고리의 다른 글
| [인프런 김영한] DTO를 이용한 데이터 전달 (0) | 2025.11.19 |
|---|---|
| [인프런 김영한] 준영속 엔티티 그리고 변경 감지와 병합 (0) | 2025.11.18 |
| [인프런 김영한] 실전! 스프링 부트와 JPA 활용1 - 1 (6) | 2025.08.12 |
| [스프링 MVC 7 / 인프런 김영한] 웹 페이지 만들기 (0) | 2025.03.31 |
| [스프링 MVC 6 / 인프런 김영한] 스프링 MVC - 기본 기능 (0) | 2025.03.30 |
