Bean Validation

검증 기능을 지금처럼 매번 코드로 작성하는 것은 상당히 번거롭다.

또한 특정 필드에 대한 검증 로직은 대부분 빈 값인지 아닌지,

특정 크기를 넘는지 아닌지와 같이 매우 일반적인 로직이다.

public class Item {
	private Long id;

	@NotBlank
	private String itemName;

	@NotNull
	@Range(min = 1000, max = 1000000)
	private Integer price;
    
	@NotNull
	@Max(9999)
	private Integer quantity;
	//...
}

이런 검증 로직을 모든 프로젝트에 적용할 수 있게 공통화하고, 표준화 한 것이 바로 Bean Validation 이다.

Bean Validation을 잘 활용하면, 애노테이션 하나로 검증 로직을 매우 편리하게 적용할 수 있다.

 

검증 애노테이션

  • @NotBlank : 빈값 + 공백만 있는 경우를 허용하지 않는다.
  • @NotNull : null 을 허용하지 않는다.
  • @Range(min = 1000, max = 1000000) : 범위 안의 값이어야 한다.
  • @Max(9999) : 최대 9999까지만 허용한다.

 

스프링 MVC는 어떻게 Bean Validator를 사용?

스프링 부트가 spring-boot-starter-validation 라이브러리를 넣으면

자동으로 Bean Validator를 인지하고 스프링에 통합한다.

 

스프링 부트는 자동으로 글로벌 Validator로 등록한다.

LocalValidatorFactoryBean 을 글로벌 Validator로 등록한다.

이 Validator는 @NotNull 같은 애노테이션을 보고 검증을 수행한다.

이렇게 글로벌 Validator가 적용되어 있기 때문에, @Valid , @Validated 만 적용하면 된다.

검증 오류가 발생하면, FieldError , ObjectError 를 생성해서 BindingResult 에 담아준다.

 

Bean Validation - 에러 코드

Bean Validation이 기본으로 제공하는 오류 메시지를 좀 더 자세히 변경하고 싶으면 어떻게 하면 될까?

Bean Validation을 적용하고 bindingResult 에 등록된 검증 오류 코드를 보면

NotBlank, Range와 같은 오류 코드를 기반으로 MessageCodesResolver 를 통해 메시지 코드가 순서대로 생성된다.

 

@NotBlank

  • NotBlank.item.itemName
  • NotBlank.itemName
  • NotBlank.java.lang.String
  • NotBlank

@Range

  • Range.item.price
  • Range.price
  • Range.java.lang.Integer
  • Range

 

따라서 이전 처럼 errors.properties 에 원하는 매시지를 넣으면 된다.

#Bean Validation 추가
NotBlank={0} 공백X
Range={0}, {2} ~ {1} 허용
Max={0}, 최대 {1}

 

BeanValidation 메시지 찾는 순서

  1. 생성된 메시지 코드 순서대로 messageSource 에서 메시지 찾기
  2. 애노테이션의 message 속성 사용 @NotBlank(message = "공백! {0}")
  3. 라이브러리가 제공하는 기본 값 사용 공백일 수 없습니다.

 

애노테이션의 message 사용 예

@NotBlank(message = "공백은 입력할 수 없습니다.")
private String itemName;

 

Bean Validation - 오브젝트 오류

Bean Validation에서 특정 필드( FieldError )가 아닌 해당 오브젝트 관련 오류( ObjectError )는 어떻게 처리할 수 있을까?

@Data
@ScriptAssert(
		lang = "javascript", 
		script = "_this.price * _this.quantity >= 10000",
		message = "총합이 10000원 넘게 설정해주세요"
        )
        
public class Item {
	//...
}

위 처럼 @ScreiptAssert() 를 사용 할 수 도 있지만 제약이 많고 복잡하므로 아래와 같이

오브젝트 오류 관련 부분만 직접 자바 코드로 작성하는 것을 권장한다.

 

ValidationItemControllerV3 - 글로벌 오류 추가

@PostMapping("/add")
public String addItem(@Validated @ModelAttribute Item item, 
            BindingResult bindingResult, 
            RedirectAttributes redirectAttributes) {
        
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.reject(
                          "totalPriceMin", 
                          new Object[]{10000, resultPrice}, 
                          null
                        );
		}
	}
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v3/addForm";
	}

	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v3/items/{itemId}";
}

 

Bean Validation - 한계

수정시 요구사항에 Id 값이 필수일 경우 다음과 같이 수정한다면 등록시 오류가 발생한다.

@Data
public class Item {

	@NotNull //수정 요구사항 추가
	private Long id;
    
    //...
}

등록시에는 Id 값이 정해지지 않기 때문에 @NoutNull 부분에서 검증 오류가 발생하는 것이다.

이처럼 등록과 수정할 때 각각 다르게 검증하는 방법을 알아보자.

  1. BeanValidation의 groups 기능을 사용한다.
  2. Item을 직접 사용하지 않고, ItemSaveForm, ItemUpdateForm 같은
    폼 전송을 위한 별도의 모델 객체를 만들어서 사용한다.

 

BeanValidation groups 기능 사용

이런 문제를 해결하기 위해 Bean Validation은 groups라는 기능을 제공한다.

예를 들어서 등록시에 검증할 기능과 수정시에 검증할 기능을 각각 그룹으로 나누어 적용할 수 있다

 

groups 적용

// 저장용 groups 생성
public interface SaveCheck {
}

// 수정용 groups 생성
public interface UpdateCheck {
}

 

Item - groups 적용

@Data
public class Item {

	@NotNull(groups = UpdateCheck.class) //수정시에만 적용
	private Long id;
    
	@NotBlank(groups = {SaveCheck.class, UpdateCheck.class})
	private String itemName;
    
	@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
	@Range(
    		min = 1000, 
        	max = 1000000, 
        	groups = {SaveCheck.class, UpdateCheck.class}
        )
	private Integer price;
    
	@NotNull(groups = {SaveCheck.class, UpdateCheck.class})
	@Max(value = 9999, groups = SaveCheck.class) //등록시에만 적용
	private Integer quantity;
    
	public Item() {
	}
    
	public Item(String itemName, Integer price, Integer quantity) {
	this.itemName = itemName;
	this.price = price;
	this.quantity = quantity;
	}
}

 

ValidationItemControllerV3 - 저장 로직에 SaveCheck Groups 적용

@PostMapping("/add")
public String addItemV2(@Validated(SaveCheck.class) @ModelAttribute Item item,
                BindingResult bindingResult, 
                RedirectAttributes redirectAttributes) {
	//...
}

 

ValidationItemControllerV3 - 수정 로직에 UpdateCheck Groups 적용

@PostMapping("/{itemId}/edit")
public String editV2(@PathVariable Long itemId, 
        @Validated(UpdateCheck.class) @ModelAttribute Item item, 
        BindingResult bindingResult) {
	//...
}

 

groups 기능을 사용해서 등록과 수정시에 각각 다르게 검증을 할 수 있었다.

그런데 groups 기능을 사용하니 Item 은 물론이고, 전반적으로 복잡도가 올라갔다.

사실 groups 기능은 실제 잘 사용되지는 않는데, 그 이유는 실무에서는

주로 다음에 등장하는 등록용 폼 객체와 수정용 폼 객체를 분리해서 사용하기 때문이다.

 

Form 전송 객체 분리

실무에서는 회원 등록시 회원과 관련된 데이터만 전달받는 것이 아니라,

약관 정보도 추가로 받는 등 Item 과 관계없는 수 많은 부가 데이터가 넘어온다.

그래서 보통 Item 을 직접 전달받는 대신, 복잡한 폼 데이터를 컨트롤러까지 전달할 별도의 객체를 만들어서 전달한다.

예를 들면 ItemSaveForm 이라는 폼을 전달받는 전용 객체를 만들어서 @ModelAttribute 로 사용한다.

이것을 통해 컨트롤러에서 폼 데이터를 전달 받고, 이후 컨트롤러에서 필요한 데이터를 사용해서 Item 을 생성한다.

 

폼 데이터 전달에 Item 도메인 객체 사용

HTML Form -> Item -> Controller -> Item -> Repository
  • 장점 : Item 도메인 객체를 컨트롤러, 리포지토리 까지 직접 전달해서 중간에 Item을 만드는 과정이 없어 간단하다.
  • 단점 : 간단한 경우에만 적용할 수 있다. 수정시 검증이 중복될 수 있고, groups를 사용해야 한다.

 

폼 데이터 전달을 위한 별도의 객체 사용

HTML Form -> ItemSaveForm -> Controller -> Item 생성 -> Repository
  • 장점 : 전송하는 폼 데이터가 복잡해도 거기에 맞춘 별도의 폼 객체를 사용해서 데이터를 전달 받을 수 있다.
              보통 등록과, 수정용으로 별도의 폼 객체를 만들기 때문에 검증이 중복되지 않는다.
  • 단점 : 폼 데이터를 기반으로 컨트롤러에서 Item 객체를 생성하는 변환 과정이 추가된다.

 

Bean Validation - HTTP 메시지 컨버터

@Valid , @Validated 는 HttpMessageConverter ( @RequestBody )에도 적용할 수 있다.

  • @ModelAttribute 는 HTTP 요청 파라미터(URL 쿼리 스트링, POST Form)를 다룰 때 사용한다. 
  • @RequestBody 는 HTTP Body의 데이터를 객체로 변환할 때 사용한다. 주로 API JSON 요청을 다룰 때 사용한다.

 

요구사항: 검증 로직 추가

컨트롤러의 중요한 역할중 하나는 HTTP 요청이 정상인지 검증하는 것이다.

그리고 정상 로직보다 이런 검증 로직을 잘 개발하는 것이 어쩌면 더 어려울 수 있다.

 

참고: 클라이언트 검증, 서버 검증

  • 클라이언트 검증은 조작할 수 있으므로 보안에 취약하다.
  • 서버만으로 검증하면, 즉각적인 고객 사용성이 부족해진다.
  • 둘을 적절히 섞어서 사용하되, 최종적으로 서버 검증은 필수
  • API 방식을 사용하면 API 스펙을 잘 정의해서 검증 오류를 API 응답 결과에 잘 남겨주어야 함

 

검증 직접 처리 - 소개

상품 저장 검증 실패

목표

  • 만약 검증 오류가 발생하면 입력 폼을 다시 보여준다.
  • 검증 오류들을 고객에게 친절하게 안내해서 다시 입력할 수 있게 한다.
  • 검증 오류가 발생해도 고객이 입력한 데이터가 유지된다.

 

ValidationItemControllerV1 - addItem() 수정

@PostMapping("/add")
public String addItem(@ModelAttribute Item item, 
		RedirectAttributes redirectAttributes, 
		Model model) {

	//검증 오류 결과를 보관
	Map<String, String> errors = new HashMap<>();
    
	//검증 로직
	if (!StringUtils.hasText(item.getItemName())) {
		errors.put("itemName", "상품 이름은 필수입니다.");
	}
	if (item.getPrice() == null || 
    	item.getPrice() < 1000 || 
        item.getPrice() > 1000000) {
		errors.put("price", "가격은 1,000 ~ 1,000,000 까지 허용합니다.");
	}
	if (item.getQuantity() == null || item.getQuantity() >= 9999) {
		errors.put("quantity", "수량은 최대 9,999 까지 허용합니다.");
	}
    
	//특정 필드가 아닌 복합 룰 검증
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			errors.put("globalError", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice);
		}
	}
    
	//검증에 실패하면 다시 입력 폼으로
	if (!errors.isEmpty()) {
		model.addAttribute("errors", errors);
		return "validation/v1/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v1/items/{itemId}";
}

검증 오류 보관

Map errors = new HashMap<>();

만약 검증시 오류가 발생하면 어떤 검증에서 오류가 발생했는지 정보를 담아둔다

 

검증 로직

if (!StringUtils.hasText(item.getItemName())) { 
	errors.put("itemName", "상품 이름은 필수입니다."); 
}

검증시 오류가 발생하면 errors 에 담아둔다.

이때 어떤 필드에서 오류가 발생했는지 구분하기 위해 오류가 발생한 필드명을 key 로 사용한다.

이후 뷰에서 이 데이터를 사용해서 고객에게 친절한 오류 메시지를 출력할 수 있다.

 

검증에 실패하면 다시 입력 폼으로

if (!errors.isEmpty()) {
	model.addAttribute("errors", errors);
	return "validation/v1/addForm";
}

만약 검증에서 오류 메시지가 하나라도 있으면 오류 메시지를 출력하기 위해

model 에 errors 를 담고 입력 폼이 있는 뷰 템플릿으로 보낸다.

 

남은 문제점

  • 타입 오류 처리가 안된다.
    이러한 오류는 스프링MVC에서 컨트롤러에 진입하기도 전에 예외가 발생하기 때문에
    컨트롤러가 호출되지도 않고, 400 예외가 발생하면서 오류 페이지를 띄워준다.
  • 타입 오류가 발생해도 고객이 입력한 문자를 화면에 남겨야 한다.
    만약 컨트롤러가 호출된다고 가정해도 결국 문자는 바인딩이 불가능하므로 고객이 입력한 문자가 사라지게 되고
    고객은 본인이 어떤 내용을 입력해서 오류가 발생했는지 이해하기 어렵다
  • 결국 고객이 입력한 값도 어딘가에 별도로 관리가 되어야 한다.

 


지금부터 스프링이 제공하는 검증 오류 처리 방법을 알아보자. 여기서 핵심은 BindingResult이다.

 

ValidationItemControllerV2 - addItemV1

@PostMapping("/add")
public String addItemV1(@ModelAttribute Item item, 
			BindingResult bindingResult,
			RedirectAttributes redirectAttributes) {
	if (!StringUtils.hasText(item.getItemName())) {
		bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
	}
	if (item.getPrice() == null || 
    	item.getPrice() < 1000 || 
    	item.getPrice() > 1000000) {
		bindingResult.addError(new FieldError("item", "price", "가격은 1,000 ~ 1,000,000 까지 허용합니다."));
	}
	if (item.getQuantity() == null || item.getQuantity() > 10000) {
		bindingResult.addError(new FieldError("item", "quantity", "수량은 최대 9,999 까지 허용합니다."));
	}
    
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));
		}
	}
    
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 

주의

BindingResult bindingResult 파라미터의 위치는 
@ModelAttribute Item item 다음에 와야 한다.

 

필드 오류 -  FieldError

// Field Error 생성자
// public FieldError(String objectName, String field, String defaultMessage) {}
if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.addError(new FieldError("item", "itemName", "상품 이름은 필수입니다."));
}

필드에 오류가 있으면 FieldError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 이름
  • field : 오류가 발생한 필드 이름
  • defaultMessage : 오류 기본 메시지

 

글로벌 오류 - ObjectError

//ObjectError 생성자
//public ObjectError(String objectName, String defaultMessage) {}
bindingResult.addError(new ObjectError("item", "가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice));

특정 필드를 넘어서는 오류가 있으면 ObjectError 객체를 생성해서 bindingResult 에 담아두면 된다.

  • objectName : @ModelAttribute 의 이름
  • defaultMessage : 오류 기본 메시지

 

BindingResult

  • 스프링이 제공하는 검증 오류를 보관하는 객체이다. 검증 오류가 발생하면 여기에 보관하면 된다.
  • BindingResult 가 있으면 @ModelAttribute 에 데이터 바인딩 시 오류가 발생해도 컨트롤러가 호출된다

 

만약 @ModelAttribute에 바인딩 시 타입 오류가 발생하면?

  • BindingResult 가 없으면 400 오류가 발생하면서 컨트롤러가 호출되지 않고 오류 페이지로 이동한다.
  • BindingResult 가 있으면 오류 정보( FieldError )를 BindingResult 에 담아서 컨트롤러를 정상 호출한다.

 

남은 문제점

BindingResult , FieldError , ObjectError 를 사용해서 오류 메시지를 처리하는 방법을 알아보았다.

그런데 오류가 발생하는 경우 고객이 입력한 내용이 모두 사라진다. 이 문제를 해결해보자.

 

 

@PostMapping("/add")
public String addItemV2(@ModelAttribute Item item, 
			BindingResult bindingResult, 
			RedirectAttributes redirectAttributes) {
	if (!StringUtils.hasText(item.getItemName())) {
		bindingResult.addError(
        	new FieldError("item", "itemName", item.getItemName(), false,
            	null, null, "상품 이름은 필수입니다.")
                );
	}
	if (item.getPrice() == null || 
    	item.getPrice() < 1000 || 
        item.getPrice() > 1000000) {
		bindingResult.addError(
        	new FieldError(
            	"item", "price", item.getPrice(), false, 
                null, null, "가격은 1,000 ~ 1,000,000 까지 허용합니다."
                )
            );
	}
	if (item.getQuantity() == null || item.getQuantity() > 10000) {
		bindingResult.addError(
        	new FieldError(
            	"item", "quantity", item.getQuantity(), false, 
                null, null, "수량은 최대 9,999 까지 허용합니다."
                )
            );
	}
    
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.addError(
				new ObjectError(
				"item", null, null, 
				"가격 * 수량의 합은 10,000원 이상이어야 합니다. 현재 값 = " + resultPrice
				)
			);
		}
	}
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 

FieldError 생성자

FieldError 는 두 가지 생성자를 제공한다.

public FieldError(
		String objectName, 
		String field, 
		String defaultMessage
    ); 

public FieldError(
        String objectName, 
        String field, 
        @Nullable Object rejectedValue, 
        boolean bindingFailure, 
        @Nullable String[] codes, 
        @Nullable Object[] arguments, 
        @Nullable String defaultMessage
    )

파라미터

  • objectName : 오류가 발생한 객체 이름
  • field : 오류 필드
  • rejectedValue : 사용자가 입력한 값(거절된 값)
  • bindingFailure : 타입 오류 같은 바인딩 실패인지, 검증 실패인지 구분 값
  • codes : 메시지 코드
  • arguments : 메시지에서 사용하는 인자
  • defaultMessage : 기본 오류 메시지

 

오류 발생시 사용자 입력 값 유지

new FieldError("item", "price", item.getPrice(), false, 
        null, null, "가격은 1,000 ~1,000,000 까지 허용합니다.")

여기서 rejectedValue 가 바로 오류 발생시 사용자 입력 값을 저장하는 필드다.

 

스프링의 바인딩 오류 처리

타입 오류로 바인딩에 실패하면 스프링은 FieldError 를 생성하면서 사용자가 입력한 값을 넣어둔다.

그리고 해당 오류를 BindingResult 에 담아서 컨트롤러를 호출한다.

따라서 타입 오류 같은 바인딩 실패시에도 사용자의 오류 메시지를 정상 출력할 수 있다.

 

오류 코드와 메시지 처리1

오류 메시지를 체계적으로 다루어보자.

ValidationItemControllerV2 - addItemV3() 추가

@PostMapping("/add")
public String addItemV3(@ModelAttribute Item item, BindingResult bindingResult, 
		RedirectAttributes redirectAttributes) {
	if (!StringUtils.hasText(item.getItemName())) {
		bindingResult.addError(new FieldError("item", "itemName",
        		item.getItemName(), false, new String[]{"required.item.itemName"}, null, null));
	}
	if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
		bindingResult.addError(new FieldError("item", "price", item.getPrice(), 
        		false, new String[]{"range.item.price"}, new Object[]{1000, 1000000}, null));
	}
	if (item.getQuantity() == null || item.getQuantity() > 10000) {
		bindingResult.addError(new FieldError("item", "quantity", 
        		item.getQuantity(), false, new String[]{"max.item.quantity"}, new Object[] {9999}, null));
    
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.addError(new ObjectError("item", new String[] {"totalPriceMin"}, new Object[]{10000, resultPrice}, null));
		}
	}
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

errors.properties 추가

required.item.itemName=상품 이름은 필수입니다.
range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
max.item.quantity=수량은 최대 {0} 까지 허용합니다.
totalPriceMin=가격 * 수량의 합은 {0}원 이상이어야 합니다. 현재 값 = {1}

 

FieldError 예시

//FieldError의 2번째 생성자
public FieldError(
            String objectName, 
            String field, 
            @Nullable Object rejectedValue, 
            boolean bindingFailure, 
            @Nullable String[] codes, 
            @Nullable Object[] arguments, 
            @Nullable String defaultMessage
		)

//range.item.price=가격은 {0} ~ {1} 까지 허용합니다.
new FieldError("item", "price", item.getPrice(), false, 
			new String[] {"range.item.price"}, new Object[]{1000, 1000000}
		)
  • codes : required.item.itemName 를 사용해서 메시지 코드를 지정한다.
    메시지 코드는 하나가 아니라 배열로 여러 값을 전달할 수 있는데
    순서대로 매칭해서 처음 매칭되는 메시지가 사용된다.
  • arguments : Object[]{1000, 1000000} 를 사용해서 코드의 {0} , {1} 로 치환할 값을 전달한다.

 

rejectValue() , reject()

BindingResult 가 제공하는 rejectValue() , reject() 를 사용하면

FieldError , ObjectError 를 직접 생성하지 않고, 깔끔하게 검증 오류를 다룰 수 있다

 

ValidationItemControllerV2 - addItemV4() 추가

@PostMapping("/add")
public String addItemV4(@ModelAttribute Item item, BindingResult bindingResult, 
		RedirectAttributes redirectAttributes) {
	log.info("objectName={}", bindingResult.getObjectName());
	log.info("target={}", bindingResult.getTarget());
	if (!StringUtils.hasText(item.getItemName())) {
		bindingResult.rejectValue("itemName", "required");
	}
	if (item.getPrice() == null || item.getPrice() < 1000 || item.getPrice() > 1000000) {
		bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
	}
	if (item.getQuantity() == null || item.getQuantity() > 10000) {
		bindingResult.rejectValue("quantity", "max", new Object[]{9999}, null);
	}
    
	//특정 필드 예외가 아닌 전체 예외
	if (item.getPrice() != null && item.getQuantity() != null) {
		int resultPrice = item.getPrice() * item.getQuantity();
		if (resultPrice < 10000) {
			bindingResult.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
		}
	}
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

 

rejectValue()

void rejectValue(@Nullable String field, String errorCode,
		@Nullable Object[] errorArgs, @Nullable String defaultMessage);

파라미터

  • field : 오류 필드명
  • errorCode : 오류 코드(이 오류 코드는 메시지에 등록된 코드가 아니다.
    뒤에서 설명할 messageResolver를 위한 오류 코드이다.)
  • errorArgs : 오류 메시지에서 {0} 을 치환하기 위한 값
  • defaultMessage : 오류 메시지를 찾을 수 없을 때 사용하는 기본 메시지

예시)

bindingResult.rejectValue("price", "range", new Object[]{1000, 1000000}, null)

BindingResult 는 어떤 객체를 대상으로 검증하는지 target을 이미 알고 있다.

따라서 target(item)에 대한 정보는 없어도 된다. 오류 필드명은 동일하게 price 를 사용한다.

 

reject()

void reject(String errorCode, @Nullable Object[] errorArgs, 
		@Nullable String defaultMessage);

rejectValue() 와 파라미터 설명은 같다.

 


MessageCodesResolver

  • 검증 오류 코드로 메시지 코드들을 생성한다.
  • MessageCodesResolver 인터페이스이고 DefaultMessageCodesResolver 는 기본 구현체이다.
  • 주로 다음과 함께 사용 : ObjectError , FieldError

 

DefaultMessageCodesResolver의 기본 메시지 생성 규칙

객체 오류

객체 오류의 경우 다음 순서로 2가지 생성
1.   code + "." + object name
2.   code

예) 오류 코드: required, object name: item
1.   required.item
2.   required

필드 오류

필드 오류의 경우 다음 순서로 4가지 메시지 코드 생성
1.   code + "." + object name + "." + field
2.   code + "." + field
3.   code + "." + field type
4.   code

예) 오류 코드: typeMismatch, object name "user", field "age", field type: int
1.   "typeMismatch.user.age"
2.   "typeMismatch.age"
3.   "typeMismatch.int"
4.   "typeMismatch"

동작 방식

  • rejectValue() , reject() 는 내부에서 MessageCodesResolver 를 사용하며 여기에서 메시지 코드들을 생성한다.
  • FieldError , ObjectError 의 생성자를 보면, 오류 코드를 하나가 아니라 여러 오류 코드를 가질 수 있다.
  • MessageCodesResolver 를 통해서 생성된 순서대로 오류 코드를 보관한다.

 

오류 코드 관리 전략

핵심은 구체적인 것에서! 덜 구체적인 것으로

MessageCodesResolver 는 required.item.itemName 처럼 구체적인 것을 먼저 만들어주고,

required 처럼 덜 구체적인 것을 가장 나중에 만든다

 

왜 이렇게 복잡하게 사용하는가?

모든 오류 코드에 대해서 메시지를 각각 다 정의하면 개발자 입장에서 관리하기 너무 힘들다.

크게 중요하지 않은 메시지는 범용성 있는 requried 같은 메시지로 끝내고,

정말 중요한 메시지는 꼭 필요할 때 구체적으로 적어서 사용하는 방식이 더 효과적이다.

 

ValidationUtils

스프링이 제공하는 ValidationUtils 클래스를 사용해보자.

ValidationUtils 사용 전

if (!StringUtils.hasText(item.getItemName())) {
	bindingResult.rejectValue("itemName", "required", "기본: 상품 이름은 필수입니다.");
}

ValidationUtils 사용 후

ValidationUtils.rejectIfEmptyOrWhitespace(bindingResult, "itemName", "required");

동작 방식

  • rejectValue() 호출
  • MessageCodesResolver 를 사용해서 검증 오류 코드로 메시지 코드들을 생성
  • new FieldError() 를 생성하면서 메시지 코드들을 보관

 

Validator 분리

컨트롤러에서 검증 로직이 차지하는 부분은 매우 크기 때문에 별도의 클래스로 역할을 분리하는 것이 좋다.

그리고 이렇게 분리한 검증 로직을 재사용 할 수도 있다.

 

ItemValidator 도입

@Component
public class ItemValidator implements Validator {
	@Override
	public boolean supports(Class<?> clazz) {
		return Item.class.isAssignableFrom(clazz);
	}
	@Override
	public void validate(Object target, Errors errors) {
		Item item = (Item) target;
        
		ValidationUtils.rejectIfEmptyOrWhitespace(errors, "itemName", "required");
		if (item.getPrice() == null || 
			item.getPrice() < 1000 || 
			item.getPrice() > 1000000) {
			errors.rejectValue("price", "range", new Object[]{1000, 1000000}, null);
		}
		if (item.getQuantity() == null || item.getQuantity() > 10000) {
			errors.rejectValue("quantity", "max", new Object[]{9999}, null);
		}
        
		//특정 필드 예외가 아닌 전체 예외
		if (item.getPrice() != null && item.getQuantity() != null) {
			int resultPrice = item.getPrice() * item.getQuantity();
			if (resultPrice < 10000) {
				errors.reject("totalPriceMin", new Object[]{10000, resultPrice}, null);
			}
		}
	}
}

스프링은 검증을 체계적으로 제공하기 위해 다음 인터페이스를 제공한다

public interface Validator {
	boolean supports(Class<?> clazz);
	void validate(Object target, Errors errors);
}
  • supports() {} : 해당 검증기를 지원하는 여부 확인
  • validate(Object target, Errors errors) : 검증 대상 객체와 BindingResult

 

ItemValidator 직접 호출하기

ValidationItemControllerV2 - addItemV5()

private final ItemValidator itemValidator;
@PostMapping("/add")
public String addItemV5(@ModelAttribute Item item, BindingResult bindingResult,
RedirectAttributes redirectAttributes) {
	itemValidator.validate(item, bindingResult);

	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
    
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

ItemValidator 를 스프링 빈으로 주입 받아서 직접 호출했다.

 

@Validated 적용

ValidationItemControllerV2 - addItemV6()

@PostMapping("/add")
public String addItemV6(@Validated @ModelAttribute Item item, 
		BindingResult bindingResult, RedirectAttributes redirectAttributes) {
	if (bindingResult.hasErrors()) {
		log.info("errors={}", bindingResult);
		return "validation/v2/addForm";
	}
	//성공 로직
	Item savedItem = itemRepository.save(item);
	redirectAttributes.addAttribute("itemId", savedItem.getId());
	redirectAttributes.addAttribute("status", true);
	return "redirect:/validation/v2/items/{itemId}";
}

동작 방식

@Validated 는 검증기를 실행하라는 애노테이션이다.

이 애노테이션이 붙으면 앞서 WebDataBinder 에 등록한 검증기를 찾아서 실행한다.

그런데 여러 검증기를 등록하면 그 중 어떤 검증기가 실행되어야 할지 구분이 필요하다.

이때 supports() 가 사용되고 여기서는 supports(Item.class) 호출되고,

결과가 true 이므로 ItemValidator 의 validate() 가 호출된다.

@RequiredArgsConstructor

Lombok의 애노테이션으로 final 이 붙은 멤버 변수만 사용해서 생성자를 자동으로 만들어준다

public BasicItemController(ItemRepository itemRepository) {
	this.itemRepository = itemRepository;
}

이렇게 생성자가 딱 1개만 있으면 스프링이 해당 생성자에 @Autowired 로 의존관계를 주입해준다.

final 키워드를 빼면 ItemRepository 의존관계 주입이 안되므로 꼭 붙여야한다.

 

@PostConstruct 

해당 빈의 의존관계가 모두 주입되고 나면 초기화 용도로 호출된다.

 

@Data

Lombok @Data  @Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor 

를 자동으로 적용해준다.

따라서 DTO 에는 써도 크게 상관은 없지만 @Setter 가 포함 되어 있어

엔티티에 사용할 경우 사이드 이펙트가 있을 수 있으므로 주의하여 사용한다.

 


PRG Post/Redirect/Get

아래와 같은 구조로의 컨트롤러는 심각한 문제를 야기할 수 있다.

브라우저의 새로 고침은 마지막에 서버에 전송한 데이터를 다시 전송한다.

상품 등록 폼에서 데이터를 입력하고 저장을 선택하면 POST /add + 상품 데이터를 서버로 전송한다.

이 상태에서 새로 고침하면 마지막에 전송한 POST /add + 상품 데이터를 서버로 다시 전송하게 된다.

그래서 내용은 같고, ID만 다른 상품 데이터가 계속 쌓이게 된다.

 

이 문제를 어떻게 해결할 수 있을까? 다음 그림을 보자.

위 그림과 같이 상품 저장 후에 뷰 템플릿으로 이동하는 것이 아니라,

상품 상세 화면으로 리다이렉트를 호출해주면 된다.

웹 브라우저는 리다이렉트의 영향으로 상품 저장 후에 실제 상품 상세 화면으로 다시 이동한다.

따라서 마지막에 호출한 내용이 상품 상세 화면인 GET /items/{id} 가 되는 것이다.

이후 새로고침을 해도 상품 상세 화면으로 이동하게 되므로 새로 고침 문제를 해결할 수 있다

 

Post -> Redirect -> Get 의 과정을 거치게 되므로 PRG 라고 부르기도 한다

 

로깅 간단히 알아보기

로깅 라이브러리

스프링 부트 라이브러리를 사용하면 스프링 부트 로깅 라이브러리( spring-boot-starter-logging )가 함께 포함된다.

스프링 부트 로깅 라이브러리는 기본으로 다음 로깅 라이브러리를 사용한다.

 

로그 라이브러리는 Logback, Log4J, Log4J2 등등 수 많은 라이브러리가 있는데,

그것을 통합해서 인터페이스로 제공하는 것이 바로 SLF4J 라이브러리다.

쉽게 이야기해서 SLF4J는 인터페이스이고, 그 구현체로 Logback 같은 로그 라이브러리를 선택하면 된다.

실무에서는 스프링 부트가 기본으로 제공하는 Logback을 대부분 사용한다.

 

로그 사용시 장점

  • 쓰레드 정보, 클래스 이름 같은 부가 정보를 함께 볼 수 있고, 출력 모양을 조정할 수 있다.
  • 로그 레벨에 따라 개발 서버에서는 모든 로그를 출력하고, 운영서버에서는 출력하지 않는 등 로그를 상황에 맞게 조절할 수 있다.
  • 시스템 아웃 콘솔에만 출력하는 것이 아니라, 파일이나 네트워크 등, 로그를 별도의 위치에 남길 수 있다.
    특히 파일로 남길 때는 일별, 특정 용량에 따라 로그를 분할하는 것도 가능하다.
  • 성능도 일반 System.out보다 좋다. (내부 버퍼링, 멀티 쓰레드 등등) 그래서 실무에서는 꼭 로그를 사용해야 한다.

 

@RestController

  • @Controller 는 반환 값이 String 이면 뷰 이름으로 인식된다. 그래서 뷰를 찾고 뷰가 랜더링 된다.
  • @RestController 는 반환 값으로 뷰를 찾는 것이 아니라, HTTP 메시지 바디에 바로 입력한다.
  • @ResponseBody 와 @Controller를 합친것과 동일하게 동작한다.

 


HTTP 요청 - 기본, 헤더 조회

애노테이션 기반의 스프링 컨트롤러는 다양한 파라미터를 지원한다.

@Slf4j
@RestController
public class RequestHeaderController {

	@RequestMapping("/headers")
	public String headers(HttpServletRequest request,
			HttpServletResponse response,
			HttpMethod httpMethod,
			Locale locale,
			@RequestHeader MultiValueMap<String, String> headerMap,
			@RequestHeader("host") String host,
			@CookieValue(value = "myCookie", required = false) String cookie
			) {
            
	log.info("request={}", request);
	log.info("response={}", response);
	log.info("httpMethod={}", httpMethod);
	log.info("locale={}", locale);
	log.info("headerMap={}", headerMap);
	log.info("header host={}", host);
	log.info("myCookie={}", cookie);
    
	return "ok";
	}
}

HttpMethod

HTTP 메서드를 조회한다.

Locale

Locale 정보를 조회한다.

@RequestHeader MultiValueMap<String, String> headerMap

모든 HTTP 헤더를 MultiValueMap 형식으로 조회한다.

@RequestHeader("host") String host

특정 HTTP 헤더를 조회한다.

  • 필수 값 여부: required
  • 기본 값 속성: defaultValue

@CookieValue(value = "myCookie", required = false) String cookie

특정 쿠키를 조회한다.

  • 필수 값 여부: required
  • 기본 값: defaultValue

 


HTTP 요청 파라미터 - 쿼리 파라미터, HTML Form

[MVC 1편] 섹션 2. 서블릿 에서 학습한 HTTP 요청 데이터에서 배운 내용과 이어진다.

 

1. HTTP 요청 파라미터 - @RequestParam

/**
 * @RequestParam 사용
 * - 파라미터 이름으로 바인딩
 * @ResponseBody 추가
 * - View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력
 */
@ResponseBody
@RequestMapping("/request-param-v2")
public String requestParamV2(
		@RequestParam("username") String memberName,
		@RequestParam("age") int memberAge) {
        
	log.info("username={}, age={}", memberName, memberAge);
	return "ok";
}

@RequestParam

파라미터 이름으로 바인딩한다.

여기서 @RequestParam 을 생략해도 동일하게 동작하지만 생략시 코드 가독성이 떨어질 수가 있다.

@RequestParam 이 있으면 명확하게 요청 파라미터에서 데이터를 읽는다는 것을 알 수 있기 때문

 

@ResponseBody 

View 조회를 무시하고, HTTP message body에 직접 해당 내용 입력

 

2. HTTP 요청 파라미터 - @ModelAttribute

실제 개발을 하면 요청 파라미터를 받아서 필요한 객체를 만들고 그 객체에 값을 넣어주어야 한다.

스프링은 이 과정을 완전히 자동화해주는 @ModelAttribute 기능을 제공한다

먼저 요청 파라미터를 바인딩 받을 객체를 만들자.

 

HelloData

@Data
public class HelloData {
	private String username;
	private int age;
}

Lombok@Data @Getter , @Setter , @ToString , @EqualsAndHashCode , @RequiredArgsConstructor

자동으로 적용해준다.

 

/**
 * @ModelAttribute 사용
 * 참고: model.addAttribute(helloData) 코드도 함께 자동 적용됨
 */
@ResponseBody
@RequestMapping("/model-attribute-v1")
public String modelAttributeV1(@ModelAttribute HelloData helloData) {

	log.info("username={}, age={}", helloData.getUsername(), helloData.getAge());
	return "ok";
}

위의 코드를 작동시켜보면 HelloData 객체가 생성되고, 요청 파라미터의 값도 모두 들어가 있다.

 

스프링MVC는 @ModelAttribute 가 있으면 다음을 실행한다.

 

  1. HelloData 객체를 생성한다.
  2. 요청 파라미터의 이름으로 HelloData 객체의 프로퍼티를 찾는다.
  3. 그리고 해당 프로퍼티의 setter를 호출해서 파라미터의 값을 입력(바인딩) 한다.
    예) 파라미터 이름이 username 이면 setUsername() 메서드를 찾아서 호출하면서 값을 입력한다.

 

@ModelAttribute 는 생략할 수 있다.

그런데 @RequestParam 도 생략할 수 있으니 혼란이 발생할 수 있으므로 생략 시 주의가 필요하다

 

3. HTTP 요청 메시지 - 단순 텍스트

요청 파라미터와 다르게, HTTP 메시지 바디를 통해 데이터가 직접 넘어오는 경우는

@RequestParam , @ModelAttribute 를 사용할 수 없다

/**
 * HttpEntity: HTTP header, body 정보를 편리하게 조회
 * - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
 *
 * 응답에서도 HttpEntity 사용 가능
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
 */
@PostMapping("/request-body-string-v3")
public HttpEntity<String> requestBodyStringV3(HttpEntity<String> httpEntity) {

	String messageBody = httpEntity.getBody();
	log.info("messageBody={}", messageBody);
    
	return new HttpEntity<>("ok");
}

스프링 MVC는 다음 파라미터를 지원한다.

HttpEntity

  • HTTP header, body 정보를 편리하게 조회
  • 메시지 바디 정보를 직접 조회
  • 요청 파라미터를 조회하는 기능과 관계 없음 @RequestParam X, @ModelAttribute X

 

HttpEntity는 응답에도 사용 가능

  • 메시지 바디 정보 직접 반환
  • 헤더 정보 포함 가능
  • view 조회X

 

HttpEntity 를 상속받은 다음 객체들도 같은 기능을 제공한다.

RequestEntity

  • HttpMethod, url 정보 추가
  • 요청에서 사용

 

ResponseEntity

  • HTTP 상태 코드 설정 가능
  • 응답에서 사용
return new ResponseEntity("Hello World", responseHeaders, HttpStatus.CREATED)

스프링MVC 내부에서 HTTP 메시지 바디를 읽어서 문자나 객체로 변환해서 전달해주는데,

이때 HTTP 메시지 컨버터( HttpMessageConverter )라는 기능을 사용한다.

 

/**
 * @RequestBody
 * - 메시지 바디 정보를 직접 조회(@RequestParam X, @ModelAttribute X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
 *
 * @ResponseBody
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> StringHttpMessageConverter 적용
 */
@ResponseBody
@PostMapping("/request-body-string-v4")
public String requestBodyStringV4(@RequestBody String messageBody) {

	log.info("messageBody={}", messageBody);
	return "ok";
}

@RequestBody

@RequestBody 를 사용하면 HTTP 메시지 바디 정보를 편리하게 조회할 수 있다.

참고로 헤더 정보가 필요하다면 HttpEntity 를 사용하거나 @RequestHeader 를 사용하면 된다.

이렇게 메시지 바디를 직접 조회하는 기능은

요청 파라미터를 조회하는 @RequestParam , @ModelAttribute 와는 전혀 관계가 없다.

 

요청 파라미터 vs HTTP 메시지 바디

요청 파라미터를 조회하는 기능
 - @RequestParam , @ModelAttribute

HTTP 메시지 바디를 직접 조회하는 기능
 - @RequestBody

 

@ResponseBody

@ResponseBody 를 사용하면 응답 결과를 HTTP 메시지 바디에 직접 담아서 전달할 수 있다.

물론 이 경우에도 view를 사용하지 않는다.

 

4. HTTP 요청 메시지 - JSON

/**
 * @RequestBody 생략 불가능(@ModelAttribute 가 적용되어 버림)
 * HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter (contenttype: application/json)
 *
 */
@ResponseBody
@PostMapping("/request-body-json-v3")
public String requestBodyJsonV3(@RequestBody HelloData data) {

	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return "ok";
}

@RequestBody 객체 파라미터

@RequestBody 에 직접 만든 객체를 지정할 수 있다.

 

@RequestBody는 생략 불가능

@ModelAttribute 에서 학습한 내용을 떠올려보자.

 

스프링은 @ModelAttribute , @RequestParam 과 같은 해당 애노테이션을 생략시 다음과 같은 규칙을 적용한다.

  • String , int , Integer 같은 단순 타입 = @RequestParam
  • 나머지 = @ModelAttribute (argument resolver 로 지정해둔 타입 외)

 

따라서 이 경우 HelloData에 @RequestBody 를 생략하면 @ModelAttribute 가 적용되어버린다.

HelloData data -> @ModelAttribute HelloData data

 

따라서 생략하면 HTTP 메시지 바디가 아니라 요청 파라미터를 처리하게 된다.

 

물론 앞서 배운 것과 같이 HttpEntity를 사용해도 된다.

@ResponseBody
@PostMapping("/request-body-json-v4")
public String requestBodyJsonV4(HttpEntity<HelloData> httpEntity) {

	HelloData data = httpEntity.getBody();
	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return "ok";
}

 

/**
 * @RequestBody 생략 불가능(@ModelAttribute 가 적용되어 버림)
 * HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter (contenttype: application/json)
 *
 * @ResponseBody 적용
 * - 메시지 바디 정보 직접 반환(view 조회X)
 * - HttpMessageConverter 사용 -> MappingJackson2HttpMessageConverter 적용
(Accept: application/json)
 */
@ResponseBody
@PostMapping("/request-body-json-v5")
public HelloData requestBodyJsonV5(@RequestBody HelloData data) {

	log.info("username={}, age={}", data.getUsername(), data.getAge());
	return data;
}

@ResponseBody

응답의 경우에도 @ResponseBody 를 사용하면 해당 객체를 HTTP 메시지 바디에 직접 넣어줄 수 있다.

물론 이 경우에도 HttpEntity 를 사용해도 된다.

@RequestBody 요청

JSON 요청 -> HTTP 메시지 컨버터 -> 객체

 

@ResponseBody 응답

객체 -> HTTP 메시지 컨버터 -> JSON 응답

 


HTTP 응답 - 정적 리소스, 뷰 템플릿

스프링(서버)에서 응답 데이터를 만드는 방법은 크게 3가지이다.

1. 정적 리소스

예) 웹 브라우저에 정적인 HTML, css, js를 제공할 때는, 정적 리소스를 사용한다.

2. 뷰 템플릿 사용

예) 웹 브라우저에 동적인 HTML을 제공할 때는 뷰 템플릿을 사용한다.

3. HTTP 메시지 사용

HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로,

HTTP 메시지 바디에 JSON 같은 형식으로 데이터를 실어 보낸다.

 

HTTP 응답 - HTTP API, 메시지 바디에 직접 입력

HTTP API를 제공하는 경우에는 HTML이 아니라 데이터를 전달해야 하므로,

HTTP 메시지 바디에 JSON 같은 형식으로 데이터를 실어 보낸다.

@Slf4j
@Controller
//@RestController
public class ResponseBodyController {

	@GetMapping("/response-body-string-v1")
	public void responseBodyV1(HttpServletResponse response) throws IOException{
		response.getWriter().write("ok");
	}
    
	/**
	* HttpEntity, ResponseEntity(Http Status 추가)
	* @return
	*/
	@GetMapping("/response-body-string-v2")
	public ResponseEntity<String> responseBodyV2() {
		return new ResponseEntity<>("ok", HttpStatus.OK);
	}
    
	@ResponseBody
	@GetMapping("/response-body-string-v3")
	public String responseBodyV3() {
		return "ok";
	}
    
	@GetMapping("/response-body-json-v1")
	public ResponseEntity<HelloData> responseBodyJsonV1() {
		HelloData helloData = new HelloData();
		helloData.setUsername("userA");
		helloData.setAge(20);
        
		return new ResponseEntity<>(helloData, HttpStatus.OK);
	}
    
	@ResponseStatus(HttpStatus.OK)
	@ResponseBody
	@GetMapping("/response-body-json-v2")
	public HelloData responseBodyJsonV2() {
		HelloData helloData = new HelloData();
		helloData.setUsername("userA");
		helloData.setAge(20);
        
		return helloData;
	}
}

responseBodyV1

서블릿을 직접 다룰 때 처럼 HttpServletResponse 객체를 통해서

HTTP 메시지 바디에 직접 ok 응답 메시지를 전달한다.

 

responseBodyV2

ResponseEntity 는 HttpEntity 를 상속 받았는데, HttpEntity는 HTTP 메시지의 헤더, 바디 정보를 가지고 있다.

ResponseEntity 는 여기에 더해서 HTTP 응답 코드를 설정할 수 있다.

HttpStatus.CREATED 로 변경하면 201 응답이 나가는 것을 확인할 수 있다.

 

responseBodyV3

@ResponseBody 를 사용하면 view를 사용하지 않고,

HTTP 메시지 컨버터를 통해서 HTTP 메시지를 직접 입력할 수 있다.

ResponseEntity 도 동일한 방식으로 동작한다.

 

responseBodyJsonV1

ResponseEntity 를 반환한다.

HTTP 메시지 컨버터를 통해서 JSON 형식으로 변환되어서 반환된다.

 

responseBodyJsonV2

ResponseEntity 는 HTTP 응답 코드를 설정할 수 있는데,

@ResponseBody 를 사용하면 이런 것을 설정하기 까다롭다.

@ResponseStatus(HttpStatus.OK) 애노테이션을 사용하면 응답 코드도 설정할 수 있다.

물론 애노테이션이기 때문에 응답 코드를 동적으로 변경할 수는 없다.

프로그램 조건에 따라서 동적으로 변경하려면 ResponseEntity 를 사용하면 된다.

 

@RestController

@Controller 대신에 @RestController 애노테이션을 사용하면,

해당 컨트롤러에 모두 @ResponseBody 가 적용되는 효과가 있다.

따라서 뷰 템플릿을 사용하는 것이 아니라, HTTP 메시지 바디에 직접 데이터를 입력한다.

이름 그대로 Rest API(HTTP API)를 만들 때 사용하는 컨트롤러이다.

참고로 @ResponseBody 는 클래스 레벨에 두면 전체 메서드에 적용되는데,

@RestController 에노테이션 안에 @ResponseBody 가 적용되어 있다.

 


HTTP 메시지 컨버터

뷰 템플릿으로 HTML을 생성해서 응답하는 것이 아니라,

HTTP API처럼 JSON 데이터를 HTTP 메시지 바디에서 직접 읽거나 쓰는 경우

HTTP 메시지 컨버터를 사용하면 편리하다.

 

@ResponseBody 사용 원리

 

@ResponseBody 를 사용

  • HTTP의 BODY에 문자 내용을 직접 반환
  • viewResolver 대신에 HttpMessageConverter 가 동작
  • 기본 문자처리: StringHttpMessageConverter
  • 기본 객체처리: MappingJackson2HttpMessageConverter
  • byte 처리 등등 기타 여러 HttpMessageConverter가 기본으로 등록되어 있음

 

스프링 MVC는 다음의 경우에 HTTP 메시지 컨버터를 적용한다.

  • HTTP 요청: @RequestBody , HttpEntity(RequestEntity) 
  • HTTP 응답: @ResponseBody , HttpEntity(ResponseEntity)

 

HTTP 요청 데이터 읽기

  • HTTP 요청이 오고, 컨트롤러에서 @RequestBody , HttpEntity 파라미터를 사용한다.
  • 메시지 컨버터가 메시지를 읽을 수 있는지 확인하기 위해 canRead() 를 호출한다.
    • 대상 클래스 타입을 지원하는가.
      예) @RequestBody 의 대상 클래스 ( byte[] , String , HelloData )
    • HTTP 요청의 Content-Type 미디어 타입을 지원하는가.
      예) text/plain , application/json , */*
  • canRead() 조건을 만족하면 read() 를 호출해서 객체 생성하고, 반환한다.

 

HTTP 응답 데이터 생성

  • 컨트롤러에서 @ResponseBody , HttpEntity 로 값이 반환된다.
  • 메시지 컨버터가 메시지를 쓸 수 있는지 확인하기 위해 canWrite() 를 호출한다.
    • 대상 클래스 타입을 지원하는가.
      예) return의 대상 클래스 ( byte[] , String , HelloData )
    • HTTP 요청의 Accept 미디어 타입을 지원하는가.(정확히는 @RequestMapping 의 produces )
      예) text/plain , application/json , */*
  • canWrite() 조건을 만족하면 write() 를 호출해서 HTTP 응답 메시지 바디에 데이터를 생성한다.

 

요청 매핑 헨들러 어뎁터 구조

RequestMappingHandlerAdapter 동작 방식

 

ArgumentResolver

애노테이션 기반의 컨트롤러는 매우 다양한 파라미터를 사용할 수 있었다.

HttpServletRequest , Model 은 물론이고, @RequestParam , @ModelAttribute 같은 애노테이션

그리고 @RequestBody , HttpEntity 같은 HTTP 메시지를 처리하는 부분까지 매우 큰 유연함을 보여주었다.

이렇게 파라미터를 유연하게 처리할 수 있는 이유가 바로 ArgumentResolver 덕분이다.

애노테이션 기반 컨트롤러를 처리하는 RequestMappingHandlerAdapter

ArgumentResolver 를 호출해서 컨트롤러(핸들러)가 필요로 하는 다양한 파라미터의 값(객체)을 생성한다.

그리고 이렇게 파리미터의 값이 모두 준비되면 컨트롤러를 호출하면서 값을 넘겨준다.

그렇다면 HTTP 메시지 컨버터는 어디쯤 있을까?

 

HTTP 메시지 컨버터 위치

HTTP 메시지 컨버터를 사용하는 @RequestBody 도 컨트롤러가 필요로 하는 파라미터의 값에 사용된다. @ResponseBody 의 경우도 컨트롤러의 반환 값을 이용한다.

 

요청의 경우

@RequestBody 와 HttpEntity 를 처리하는 ArgumentResolver 가 있다.

이 ArgumentResolver 들이 HTTP 메시지 컨버터를 사용해서 필요한 객체를 생성한다.

 

응답의 경우

@ResponseBody 와 HttpEntity 를 처리하는 ReturnValueHandler 가 있다.

그리고 여기에서 HTTP 메시지 컨버터를 호출해서 응답 결과를 만든다.

 

스프링 MVC는 @RequestBody@ResponseBody 가 있으면

RequestResponseBodyMethodProcessor (ArgumentResolver)를 사용하고

 

HttpEntity 가 있으면 HttpEntityMethodProcessor (ArgumentResolver)를 사용한다.

스프링 MVC 전체 구조

직접 만든 MVC 프레임워크 구조

 

Spring MVC 구조

 

사실상 명칭만 조금씩 다를 뿐 같은 구조이다

 

스프링 MVC의 핵심 - DispatcherServlet

DispatcherServlet 의 요청 흐름과 동작 순서

 

스프링 MVC - 시작하기

스프링이 제공하는 컨트롤러는 애노테이션 기반으로 동작해서, 매우 유연하고 실용적이다.

과거에는 자바 언어에 애노테이션이 없기도 했고, 스프링도 처음부터 이런 유연한 컨트롤러를 제공한 것은 아니다.

 

@RequestMapping

스프링은 애노테이션을 활용한 매우 유연하고, 실용적인 컨트롤러를 만들었는데

이것이 바로 @RequestMapping 애노테이션을 사용하는 컨트롤러이다.

@Controller
public class SpringMemberFormControllerV1 {
	@RequestMapping("/springmvc/v1/members/new-form")
	public ModelAndView process() {
		return new ModelAndView("new-form");
	}
}

 

스프링 MVC - 컨트롤러 통합

@RequestMapping 은 클래스 단위 뿐 아니라 메서드 단위로도 적용할 수 있다.

따라서 컨트롤러 클래스를 유연하게 하나로 통합할 수 있다.

/**
 * 클래스 단위 -> 메서드 단위
 * @RequestMapping 클래스 레벨과 메서드 레벨 조합
 */
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {

	private MemberRepository memberRepository = MemberRepository.getInstance();
    
	@RequestMapping("/new-form")
	public ModelAndView newForm() {
		return new ModelAndView("new-form");
	}
    
	@RequestMapping("/save")
	public ModelAndView save(HttpServletRequest request, HttpServletResponse response) {
		String username = request.getParameter("username");
		int age = Integer.parseInt(request.getParameter("age"));
        
		Member member = new Member(username, age);
		memberRepository.save(member);
        
		ModelAndView mav = new ModelAndView("save-result");
		mav.addObject("member", member);
		return mav;
	}
    
	@RequestMapping
	public ModelAndView members() {
		List<Member> members = memberRepository.findAll();
        
		ModelAndView mav = new ModelAndView("members");
		mav.addObject("members", members);
		return mav;
	}
}

위와 같은 컨트롤러에서는 아래와 같이 조합 된다

 

클래스 레벨 @RequestMapping("/springmvc/v2/members")

  • 메서드 레벨 @RequestMapping("/new-form")
    -> /springmvc/v2/members/new-form
  • 메서드 레벨 @RequestMapping("/save")
    -> /springmvc/v2/members/save
  • 메서드 레벨 @RequestMapping
    -> /springmvc/v2/members

 

스프링 MVC - 실용적인 방식

스프링 MVC는 개발자가 편리하게 개발할 수 있도록 수 많은 편의 기능을 제공한다.

실무에서는 지금부터 설명하는 방식을 주로 사용한다.

/**
 * v3
 * Model 도입
 * ViewName 직접 반환
 * @RequestParam 사용
 * @RequestMapping -> @GetMapping, @PostMapping
 */
@Controller
@RequestMapping("/springmvc/v2/members")
public class SpringMemberControllerV2 {

	private MemberRepository memberRepository = MemberRepository.getInstance();
    
	@RequestMapping("/new-form")
	public String newForm() {
		return "new-form";
	}
    
	@RequestMapping("/save")
	public String save(
 			@RequestParam("username") String username,
			@RequestParam("age") int age,
 			Model model) {
        
		Member member = new Member(username, age);
		memberRepository.save(member);
        
		model.addAttribute("member", member);
		return "save-result";
	}
    
	@GetMapping
	public String members(Model model) {
		List<Member> members = memberRepository.findAll();
		model.addAttribute("members", members);
		return "members";
	}
}

 

Model 파라미터

save() , members() 를 보면 Model을 파라미터로 받는 것을 확인할 수 있다.

스프링 MVC도 이런 편의 기능을 제공한다.

ViewName 직접 반환

뷰의 논리 이름을 반환할 수 있다.

@RequestParam 사용

스프링은 HTTP 요청 파라미터를 @RequestParam 으로 받을 수 있다.

@RequestParam("username") 은 request.getParameter("username") 와 거의 같은 코드라 생각하면 된다.

물론 GET 쿼리 파라미터, POST Form 방식을 모두 지원한다.

@RequestMapping -> @GetMapping, @PostMapping

@RequestMapping 은 URL만 매칭하는 것이 아니라, HTTP Method도 함께 구분할 수 있다

프론트 컨트롤러 패턴 소개

프론트 컨트롤러 도입 전

 

프론트 컨트롤러 도입 후

 

FrontController 패턴 특징

  • 프론트 컨트롤러 서블릿 하나로 클라이언트의 요청을 받음
  • 프론트 컨트롤러가 요청에 맞는 컨트롤러를 찾아서 호출
  • 입구를 하나로!
  • 공통 처리 가능
  • 프론트 컨트롤러를 제외한 나머지 컨트롤러는 서블릿을 사용하지 않아도 됨

 

스프링 웹 MVC와 프론트 컨트롤러

스프링 웹 MVC의 핵심도 바로 FrontController !

스프링 웹 MVC의 DispatcherServlet이 FrontController 패턴으로 구현되어 있음

 

프론트 컨트롤러 도입 과정

v1

프론트 컨트롤러를 도입

v2

View 분류 단순 반복 되는 뷰 로직 분리

v3

Model 추가, 서블릿 종속성 제거, 뷰 이름 중복 제거

 

v4

단순하고 실용적인 컨트롤러 v3와 거의 비슷

구현 입장에서 ModelView를 직접 생성해서 반환하지 않도록 편리한 인터페이스 제공

 

v5

유연한 컨트롤러 어댑터 도입 어댑터를 추가해서 프레임워크를 유연하고 확장성 있게 설계

 

서블릿으로 회원 관리 웹 애플리케이션 만들기

서블릿으로 회원 정보를 입력할 수 있는 HTML Form을 만들면 아래와 같다.

@WebServlet(name = "memberFormServlet", urlPatterns = "/servlet/members/newform")
public class MemberFormServlet extends HttpServlet {

	@Override
	protected void service(HttpServletRequest request, HttpServletResponse response) 
    		throws ServletException, IOException {
            
		response.setContentType("text/html");
		response.setCharacterEncoding("utf-8");
		PrintWriter w = response.getWriter();
		w.write("<!DOCTYPE html>\n" +
			"<html>\n" +
			"<head>\n" +
			" <meta charset=\"UTF-8\">\n" +
			" <title>Title</title>\n" +
			"</head>\n" +
			"<body>\n" +
			"<form action=\"/servlet/members/save\" method=\"post\">\n" +
			" username: <input type=\"text\" name=\"username\" />\n" +
			" age: <input type=\"text\" name=\"age\" />\n" +
			" <button type=\"submit\">전송</button>\n" +
			"</form>\n" +
			"</body>\n" +
			"</html>\n");
	}
}

이처럼 자바 코드로 HTML을 만들어야하므로 쉽지 않은 작업이다.

 

플릿 엔진으로

위와 같이 서블릿과 자바 코드만으로 HTML을 만들어보았다.

서블릿 덕분에 동적으로 원하는 HTML을 마음껏 만들 수 있지만 코드에서 보듯 이는 매우 복잡하고 비효율 적이다.

자바 코드로 HTML을 만들어 내는 것 보다 차라리 HTML 문서에 동적으로 변경해야 하는 부분만

자바 코드를 넣을 수 있다면 더 편리할 것이다. 이것이 바로 템플릿 엔진이 나온 이유이다.

템플릿 엔진을 사용하면 HTML 문서에서 필요한 곳만 코드를 적용해서 동적으로 변경할 수 있다.

템플릿 엔진에는 JSP, Thymeleaf, Freemarker, Velocity등이 있다.

 

회원 등록 폼 JSP

<%@ page contentType="text/html;charset=UTF-8" language="java" %>
<html>
<head>
	<title>Title</title>
</head>
<body>

<form action="/jsp/members/save.jsp" method="post">
	username: <input type="text" name="username" />
	age: <input type="text" name="age" />
	<button type="submit">전송</button>
</form>

</body>
</html>

회원 등록 폼 JSP를 보면 첫 줄을 제외하고는 완전히 HTML와 똑같다.

JSP는 서버 내부에서 서블릿으로 변환되는데, 위에서 만들었던 MemberFormServlet과

거의 비슷한 모습으로 변환된다.

 

서블릿과 JSP의 한계

서블릿으로 개발할 때는 뷰(View)화면을 위한 HTML을 만드는 작업이 자바 코드에 섞여서 지저분하고 복잡했다.

JSP를 사용하면 뷰를 생성하는 HTML 작업을 깔끔하게 가져가고,

중간중간 동적으로 변경이 필요한 부분에만 자바 코드를 적용했다.

 

그런데 이렇게 해도 해결되지 않는 몇가지 고민이 남는다.

코드를 보면, JAVA 코드, 데이터를 조회하는 리포지토리 등등 다양한 코드가 모두 JSP에 노출되어 있다.

JSP가 너무 많은 역할을 한다. 이렇게 작은 프로젝트도 벌써 머리가 아파오는데,

수백 수천줄이 넘어가는 JSP를 떠올려보면 정말 지옥과 같을 것이다. (유지보수 지옥 썰)

 


 

MVC 패턴 - 개요

너무 많은 역할

하나의 서블릿이나 JSP만으로 비즈니스 로직과 뷰 렌더링까지 모두 처리하게 되면,

너무 많은 역할을 하게되고, 결과적으로 유지보수가 어려워진다.

비즈니스 로직을 호출하는 부분에 변경이 발생해도 해당 코드를 손대야 하고,

UI를 변경할 일이 있어도 비즈니스 로직이 함께 있는 해당 파일을 수정해야 한다.

 

변경의 라이프 사이클

사실 이게 정말 중요한데, 진짜 문제는 둘 사이에 변경의 라이프 사이클이 다르다는 점이다.

UI 를 일부 수정하는 일과 비즈니스 로직을 수정하는 일은 각각 다르게 발생할 가능성이 매우 높고 대부분 서로에게 영향을 주지 않는다.

이렇게 변경의 라이프 사이클이 다른 부분을 하나의 코드로 관리하는 것은 유지보수하기 좋지 않다.

 

기능 특화

JSP 같은 뷰 템플릿은 화면을 렌더링 하는데 최적화 되어 있기 때문에 이 업무만 담당하는 것이 가장 효과적이다.

 

Model View Controller

MVC 패턴은 지금까지 학습한 것 처럼 하나의 서블릿이나, JSP로 처리하던 것을

컨트롤러(Controller)와 뷰(View)라는 영역으로 서로 역할을 나눈 것을 말한다

 

컨트롤러

HTTP 요청을 받아서 파라미터를 검증하고, 비즈니스 로직을 실행한다.

그리고 뷰에 전달할 결과 데이터를 조회해서 모델에 담는다.

모델

뷰에 출력할 데이터를 담아둔다. 뷰가 필요한 데이터를 모두 모델에 담아서 전달해주는 덕분에

뷰는 비즈니스 로직이나 데이터 접근을 몰라도 되고, 화면을 렌더링 하는 일에 집중할 수 있다.

모델에 담겨있는 데이터를 사용해서 화면을 그리는 일에 집중한다. 여기서는 HTML을 생성하는 부분을 말한다.

 

MVC 패턴 적용 전

 

MVC 패턴 적용 후

 

MVC 패턴 - 한계

MVC 패턴을 적용한 덕분에 컨트롤러의 역할과 뷰를 렌더링 하는 역할을 명확하게 구분할 수 있다.

특히 뷰는 화면을 그리는 역할에 충실한 덕분에, 코드가 깔끔하고 직관적이다.

단순하게 모델에서 필요한 데이터를 꺼내고, 화면을 만들면 된다.

그런데 컨트롤러는 중복이 많고, 필요하지 않는 코드들도 있을 수 있다.

 

MVC 패턴 모형

 

정리하면 공통 처리가 어렵다는 문제가 있다

이 문제를 해결하려면 컨트롤러 호출 전에 먼저 공통 기능을 처리하는  소위 수문장 역할을 하는 기능이 필요하다.

프론트 컨트롤러(Front Controller) 패턴을 도입하면 이런 문제를 깔끔하게 해결할 수 있다. (입구를 하나로!)

스프링 MVC의 핵심도 바로 이 프론트 컨트롤러에 있다.

+ Recent posts