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:/";
}