반응형

스프링 MVC

7. 뷰와 Content Negotiation 활용

클라이언트가 보내는 웹 요청에는 스프링 MVC 가 클라이언트에게 다시 반환할 콘텐트 및 타입을 정확히 파악하는 데 필요한 프로퍼티가 여럿 포함돼 있다. 그 중 중요한 두 가지 단서는 요청 URL 의 일부인 확장자와 HTTP Accept 헤더이다. 예를 들어, /reservationSummary.xml URL 로 요청이 접수되면 컨트롤러는 URL 의 확장자 (.xml) 을 보고 XML 뷰를 표현하는 논리 뷰로 넘길 것이다. 그런데 확장자가 빠진 /reservationSummary 로 요청이 오면 어떻게 판단을 해야 할까? URL 만 봐서는 XML 뷰로 넘길지, HTML 뷰로 넘길지, 아니면 전혀 다른 타입의 뷰로 넘길지 판단하기 어렵다. 이럴 때 HTTP Accept 헤더를 보면 적절한 뷰 타입을 결정할 수 있다.

 

컨트롤러가 직접 HTTP Accept 헤더를 조사하는 방식은 코드가 복잡해질 수 있으므로 스프링 MVC 가 지원하는 ContentNegotiatingViewResolver 로 헤더를 살펴보고 URL 파일 확장자 또는 HTTP Accept 헤더값에 따라 뷰를 결정해 넘기는 것이 좋다.

 

스프링 MVC 의 Content Negotation 기능은 ContentNegotiatingViewResolver 클래스에 기반한 뷰 리졸버 형태로 구성한다.

@Configuration
public class ViewResolverConfiguration implements WebMvcConfigurer {
	
    @Override
    public void configureContentNegotiation(ContentNegotiationConfigurer configurer) {
        Map<String, MediaType> mediatypes = new HashMap<>();
        mediatypes.put("html", MediaType.TEXT_HTML);
        mediatypes.put("json", MediaType.APPLICATION_JSON);
        mediatypes.put("xml", MediaType.APPLICATION_XML);
        configurer.mediaTypes(mediatypes);
    }
    
    @Bean
    public ContentNegotiatingViewResolver contentNegotiatingViewResolver(
        ContentNegotiationManager contentNegotiationManager) {
        ContentNegotiatingViewResolver viewResolver = new ContentNegotiatingViewResolver();
        viewResolver.setContentNegotiationManager(contentNegotiationManager);
        return viewResolver;
    }
}

Content Negotiation 기능이 제대로 동작하려면 ContentNegotiatingViewResolver 의 우선순위가 가장 높게 설정되어야 한다. 이 리졸버는 스스로 뷰를 해석하지 않고 다른 리졸버에게 그 작업을 넘기기 때문이다.

예를 들어, 어떤 컨트롤러가 /reservaitionSummary.xml 요청을 받았고 핸들러 메소드는 처리를 마치고 reservation 이라는 논리 뷰로 제어권을 넘긴다고 하자. 바로 이 시점에 스프링 MVC 리졸버가 개입하는데, ContentNegotiatingViewResolver 가 우선순위가 가장 높아 1번 타자가 된다.

ContentNegotiatingViewResolver 가 미디어 타입을 결정하는 과정은 다음과 같다.

 

요청 경로에 포함된 확장자를 ContentNegotiationManager 빈 구성 시 지정한 mediaTypes 맵을 이용해 기본 미디어 타입과 비교한다.

  1. 요청 경로에 확장자는 있지만 기본 미디어 타입과 매칭되는 확장자가 없는 경우, JAF (JavaBeans Activation Framework) 의 FileTypeMap 을 이용해 확장자의 미디어 타입을 결정한다.
  2. 요청 경로에 확장자가 없으면 HTTP Accept 헤더를 활용한다.

그래서 /reservationSummary.xml 요청은 1. 에서 미디어 타입이 application/xml 로 결정되고, /reservationSummary 요청은 Accept 헤더값까지 확인되어야 한다.

 

이와 같은 프로세스 덕분에 ContentNegotiatingViewResolver 는 이름이 같은 논리 뷰가 여럿 있어도 제각기 지원하는 미디어 타입에 가장 부합하는 뷰를 해석할 수 있다. 어떤 미디어 타입을 생성할 때 필요한 논리 뷰를 일일이 하드코딩하지 않고도 뷰 하나만 있으면 이 리졸버가 가장 적합한 뷰를 알아서 찾아주기 때문에 컨트롤러 설계를 간소화할 수 있다.

 

8. 뷰에 예외 매핑

서버에서 예기치 않은 예외가 발생하면 대개 페이지에 예외 스택 트레이스가 뿌려진다. HTTP 에러가 발생하거나 클래스 예외가 발생했을 때 JSP 안내 페이지를 보이도록 web.xml 파일을 설정할 수도 있으나, 스프링 MVC 는 클래스 예외 처리용 뷰를 관리하는 기능을 지원한다.

 

스프링 MVC 어플리케이션에서 컨텍스트 레벨에 ExceptionResolver 빈을 하나 이상 등록하면 디스패처 서블릿이 자동으로 감지해 예외가 발생했을 때 처리할 수 있도록 한다.

코트 예약 서비스에서 예약이 불가한 경우 다음 예외를 발생시키려 한다.

public class ReservationNotAvailableException extends RuntimeException {
	
    private String courtName;
    private Date date;
    private int hour;
    
    // Constructor, Getter, Setter
}

코드에서 처리되지 않은 예외는 HandlerExceptionResolver 인터페이스를 구현한 커스텀 예외 리졸버로 해석이 가능하다. 보통 예외 카테고리별로 각각의 에러 페이지를 매핑한다. 스프링 MVC 에 탑재된 예외 리졸버 SimpleMappingExceptionResolver 를 이용하면 웹 어플리케이션 컨텍스트에서 발생한 예외를 매핑할 수 있다.

@Configuration
public class ExceptionHandlerConfiguration implements WebMvcConfigurer {
	
    @Override
    public void configureHandlerExceptionResolvers(
        List<HandlerExceptionResolver> exceptionResolvers) {
        exceptionResolvers.add(handlerExceptionResolver());
    }
    
    @Bean
    public HandlerExceptionResolver handlerExceptionResolver() {
        Properties exceptionMapping = new Properties();
        exceptionMapping.setProperty(ReservationNotAvailableException.class.getName(),
            "reservationNotAvailable");
        
        SimpleMappingExceptionResolver exceptionResolver = new SimpleMappingExceptionResolver();
        exceptionResolver.setExceptionMappings(exceptionMapping);
        exceptionResolver.setDefaultErrorView("error");
        return exceptionResolver;
    }
}

위의 코드에서 ReservationNotAvailableException 예외 클래스를 reservationNotAvailable 논리 뷰에 매핑했다. exceptionMapping 프로퍼티에 예외 클래스를 지정해 처리할 예외 클래스를 추가하고, defaultErrorView 프로퍼티로 exceptionMappings 에 매핑되지 않은 예외가 발생하면 표시할 기본 뷰 이름을 설정할 수 있다.

 

@ExceptionHandler 로 예외 매핑

HandlerExceptionResolver 를 구성하지 않고 메소드에 @ExceptionHandler 를 붙여 예외 핸들러를 매핑하는 방법도 있다. 작동 원리는 @RequestMapping 과 비슷하다.

@Controller
@ReqeustMapping("/reservationForm")
public class ReservationFormController {
	
    ...
    
    @ExceptionHandler(ReservationNotAvailableException.class)
    public String handle(ReservationNotAvailableException ex) {
        return "reservationNotAvailable";
    }
    
    @ExceptionHandler
    public String handleDefault(Exception e) {
        return "error";
    }
}

메소드 handle() 은 ReservationNotAvailableException 예외를 처리하는 전용 메소드고 handleDefault() 는 그 외 모든 예외를 처리하는 메소드이다. 이제 더이상 ExceptionHandlerConfiguration 구성 클래스에서 HandlerExceptionResolver 를 정의할 필요가 없다.

 

@ExceptionHandler 메소드는 여러 타입을 반환할 수 있다. 예제에서는 단순히 렌더링할 뷰 이름을 반환했지만 ModelAndView, View 등 객체도 반환이 가능하다.

이 메소드는 매우 강력하고 유연하지만 자신이 속해 있는 컨트롤러 내부에서만 작동하기 때문에 다른 컨트롤러에서 예외가 발생하면 호출되지 않는 문제가 있다. 따라서 범용적인 예외 처리 메소드는 별도 클래스로 빼내 클래스 레벨에 @ControllerAdvice 를 붙인다.

@ControllerAdvice
public class ExceptionHandlingAdvice {
	
    @ExceptionHandler
    public String handleDefault(Exception e) {
        return "error";
    }
}

 

9. 컨트롤러에서 폼 처리하기

유저가 폼과 상호작용할 때 컨트롤러는 보통 두 가지 일을 반드시 수행한다.

  1. 폼에 대한 GET 요청이 발생하면 컨트롤러는 폼 뷰를 렌더링해 화면에 표시한다.
  2. 유저가 POST 요청을 통해 폼을 전송하면 폼 데이터를 검증 (Validation) 후 요건에 맞게 처리한다.
  3. 이후 폼 처리에 성공하면 유저에게 성공 뷰를 보여주고, 폼 처리에 실패하면 원래 폼에 에러 메시지를 표시한다.

다음 코드는 간단한 폼 뷰이다. 스프링의 폼 태그 라이브러리를 사용하면 폼 데이터 바인딩과 에러 메시지의 표시를 비롯해 에러 발생 시 유저가 처음 입력했던 값을 다시 보여주는 등의 작업을 간편하게 처리할 수 있다.

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<html>
<head>
    <title>Reservation Form</title>
    <style>
        .error {
            color: #ff0000;
            font-weight: bold;
        }
    </style>
</head>

<body>
    <form:form method="post" modelAttribute="reservation">
    <form:erros path="*" cssClass="error" />
    <table>
        <tr>
            <td>Court Name</td>
            <td><form:input path="courtName" /> </td>
            <td><form:errors path="courtName" cssClass="error" /></td>
        </tr>
        
        ...
        
        <tr>
            <td colspan="3"><input type="submit" /></td>
        </tr>
    </table>
</body>
</html>

스프링 <form:form> 태그에는 두 가지 속성이 있다. method="post" 는 폼 전송 시 POST 요청을 하겠다는 의미이고, modelAttribute="reservation" 은 폼 데이터를 reservation 이라는 모델에 바인딩하겠다는 의미이다.

<form:form> 태그는 유저에게 전송되기 전 표준 HTML 코드로 렌더링된다는 사실을 기억해야 한다. modelAttribute="reservation" 은 브라우저가 쓰는 코드가 아니라, HTML 폼을 실제로 생성하는 데 필요한 편의 기능이다.

 

<form:errors> 태그는 전송된 폼이 컨트롤러가 정한 규칙을 위반했을 때 에러 메시지를 표시할 폼 위치를 지정한다. path="*" 의 와일드카드는 모든 에러를 표시한다는 뜻이고, cssClass="error" 는 에러 표시에 사용할 CSS 클래스이다.

 

폼 처리 서비스 작성

유저가 폼에 기재한 예약 데이터는 컨트롤러가 아니라 컨트롤러가 호출하는 서비스가 처리한다. 먼저 서비스 인터페이스 ReservationService 에 make() 메소드를 다음과 같이 정의한다. 예약이 중복되면 ReservationNotAvailableException 예외가 발생하도록 한다.

public class ReservationService {
	
    @Override
    public void make(Reservation reservation) throws ReservationNotAvailableException {
        long cnt = reservations.stream()
            .filter(made -> Objects.equals(made.getCourtName(), reservation.getCourtName()))
            .filter(made -> Objects.equals(made.getDate(), reservation.getDate()))
            .filter(made -> made.getHour() == reservation.getHour())
            .count();
            
        if (cnt > 0) {
            throw new ReservationNotAvailableException(reservation.getCourtName(),
                reservation.getDate(), reservation.getHour());
        } else {
            reservations.add(reservation);
        }
    }
}

 

폼 컨트롤러 작성

@Controller
@RequestMapping("/reservationForm")
@SessionAttributes("reservation")
public class ReservationFormController {
	
    private final ReservationService reservationService;
    
    // Constructor
    
    @GetMapping
    public String setupForm(Model model) {
        Reservation reservation = new Reservation();
        model.addAttribute("reservation", reservation);
        return "reservationForm";
    }
    
    @PostMapping
    public String submitForm(@ModelAttribute("reservation") Reservation reservation,
        BindingResult result, SessionStatus status) {
        reservationService.make(reservation);
        return "redirect:reservationSuccess";
    }
}

브라우저에서 URL 로 접속하면 브라우저는 웹 어플리케이션에 GET 요청을 하고, @RequestMapping 설정 내용에 따라 이를 setupForm() 메소드가 받아 처리한다.

setupForm() 메소드는 Model 객체를 입력 매개변수로 받아 이 객체는 모델 데이터를 담아 뷰에 보낼 떄도 쓰인다. 핸들러 메소드는 비어 있는 Reservation 객체를 하나 만들어 컨트롤러의 Model 객체 속성으로 추가한다. 컨트롤러가 reservationForm 뷰로 실행 흐름을 넘기면 reservationForm.jsp 로 해석될 것이다.

 

submitForm() 메소드에서 가장 눈여겨 봐야 할 대목은 비어 있는 Reservation 객체를 추가하는 부분이다. reservationForm.jsp 폼에서 <form:form> 태그에서 modelAttribute="reservation" 속성을 선언했다. 따라서 뷰를 렌더링할 때 이 폼은 핸들러 메소드가 Model 객체 속으로 reservation 이라는 이름의 객체를 넣었다고 간주한다.

 

컨트롤러 클래스에 붙인 @SessionAttributes("reservations") 도 짚고 넘어가야 할 어노테이션이다. 폼에 에러가 날지도 모르는 상황에서 유저가 전에 입력한 유효한 데이터를 또다시 입력하게 만들면 유저 입장에서 매우 불편할 것이다. 그래서 @SessionAttributes 로 reservation 필드를 유저 세션에 보관했다가 폼을 여러 차례 재전송해 동일한 레퍼런스를 참조해 필요한 데이터를 가져오려는 것이다.

 

폼을 전송하는 부분에서, 유저가 폼 필드를 입력하고 폼을 전송하면 서버에 POST 요청을 하게 되고 @PostMapping 을 붙인 submitForm() 메소드가 호출된다. 이 메소드에는 세 개의 매개변수가 선언되어 있는데, 첫 번쨰 Reservation 은 reservation 객체를 참고한다고 선언한 것이다. BindingResult 객체는 유저가 전송한 데이터가 담기고, SessionStatus 객체는 처리가 다 끝나면 완료 표시 후, HttpSession 에서 Reservation 객체를 삭제하는 용도로 쓰인다.

 

submitForm() 메소드가 반환하는 redirect:reservationSuccess 뷰에서 redirect: 접두어는 중복된 폼 전송 문제 (폼 처리 다음 페이지를 리다이렉트가 아니라 포워드로 하게 될 경우, 먼저 전송했던 값이 폼에 그대로 남아있어 페이지를 새로고침하면 폼이 한 번 더 전송되는 문제) 를 방지하는 장치이다.

 

모델 속성 객체의 초기화와 폼 값 미리 채우기

Reservation 도메인 클래스에는 예약을 한 사용자의 정보를 담은 player 필드가 포함되어 있다. reservationForm 입력 시 이를 같이 입력받을 수 있지만, 위의 예제에서 보인 바와 같이 reservation 객체를 새로 생성한 경우 player 속성이 null 이므로 폼 렌더링 시 오류가 발생한다. 다음과 같이 Reservation 객체를 뷰에 건네주기 전, 비어 있는 Player 객체를 미리 초기화해 할당하면 문제를 해결할 수 있다.

@GetMapping
public String setupForm(@RequestParam(required = false, value = "username") String username,
    Model model) {
    Reservation reservation = new Reservation();
    reservation.setPlayer(new Player(username, null));
    model.addAttribute("reservation", reservation);
    return "reservationForm";
}

 

폼에 레퍼런스 데이터 제공

폼 컨트롤러가 뷰에 전달하는 데이터 중에는 참조형 데이터 (예: HTML 셀렉트 박스에 표시되는 항목) 도 있다. 유저가 코트를 예약할 때 셀렉트 박스에서 종목을 선택할 것이다.

<%@ taglib prefix="form" uri="http://www.springframework.org/tags/form" %>

<html>
<head>
    <title>Reservation Form</title>
</head>

<body>
    <form:form method="post" modelAttribute="reservation">
    <form:erros path="*" cssClass="error" />
    <table>
        ...
        <tr>
            <td>Sport Type</td>
            <td><form:select path="sportType" items="${sportTypes}"
                itemValue="id" itemLabel="name" /> </td>
            <td><form:errors path="sportType" cssClass="error" /></td>
        </tr>
        <tr>
            <td colspan="3"><input type="submit" /></td>
        </tr>
    </table>
</body>
</html>

<form:select> 태그는 컨트롤러에서 전달받은 값 목록을 셀렉트 박스의 형태로 렌더링한다. 그 결과로 유저는 텍스트를 자유롭게 입력할 수 있는 input 대신, select 옵션 값 중 하나를 고르는 sportType 필드가 추가된다.

 

컨트롤러가 sportType 필드를 모델 속성에 할당하는 것은 앞서 입력한 input 필드와는 과정이 다르다. 일단 ReservationService 클래스에 선택 가능한 종목 모두를 조회하는 getAllSportTypes() 메소드를 다음과 같이 정의한다. 편의를 위해 종목 리스트는 하드코딩해서 리스트로 반환한다.

public class ReservationService {
	
    ...
    
    private static final SportType TENNIS = new SportType(1, "Tennis");
    private static final SportType SOCCER = new SportType(2, "Soccer");
    
    public List<SportType> getAllSportTypes() {
        return Arrays.asList(TENNIS, SOCCER);
    }
}

 

서비스가 반환한 SportType 객체 리스트는 다음과 같이 컨트롤러에서 엮어 폼 뷰에 반환한다.

public class ReservationFormController {
	
    ...
    
    @ModelAttribute("sportTypes")
    public List<SportType> populateSportTypes() {
        return reservationService.getAllSportTypes();
    }
}

Reservation 객체를 폼 뷰에 반환하는 setupForm() 메소드는 그대로 두고 SportType 객체 리스트를 반환하는 메소드를 하나 추가했다. populateSportTypes() 메소드에 붙인 @ModelAttribute("sportTypes") 는 모든 핸들러 메소드가 반환하는 뷰에서 공통적으로 쓸 수 있는 전역 모델 속성을 정의하는 어노테이션이다. 핸들러 메소드가 Model 객체를 입력 매개변수로 선언하고 반환 뷰에서 액세스할 속성을 이 객체에 할당하는 것과 같은 방식이다. 메소드가 반환한 List<SportType> 이 sportTypes 모델 속성에 할당되고, 이 모델 속성에 추가된 값을 이용해 뷰는 <form:select> 태그로 셀렉트 박스를 렌더링하는 것이다.

 

커스텀 타입 프로퍼티 바인딩

폼 전송 시 컨트롤러는 폼 필드값을 같은 이름의 모델 객체의 프로퍼티로 바인딩한다. 하지만 커스텀 타입 프로퍼티는 별도의 프로퍼티 편집기를 설정하지 않는 한 컨트롤러가 알아서 변환할 도리가 없다.

<select> 필드의 작동 원리상 종목 셀렉트 박스에서 실제로 서버에 전송되는 데이터는 유저가 선택한 종목의 ID 뿐이다. 따라서 종목 ID 를 SportType 객체로 변환하려면 프로퍼티 편집기가 필요하다. 종목 ID 에 대응되는 SportType 객체를 조회하는 getSportType() 메소드를 ReservationService 에 정의하자.

public class ReservationService {
	
    public SportType getSportType(int sportTypeId) {
        switch (sportTypeId) {
        case 1:
            return TENNIS;
        case 2:
            return SOCCER;
        default:
            return null;
        }
    }
}

다음은 종목 ID 를 SportType 객체로 변환하는 SportTypeConverter 클래스이다. 이 변환기는 ReservationService 를 호출해 SportType 객체를 찾는다.

public class SportTypeConverter implements Converter<String, SportType> {
	
    @Autowired
    private ReservationService reservationService;
    
    @Override
    public SportType conver(String source) {
        int sportTypeId = Integer.parseInt(source);
        return reservationService.getSportType(sportTypeId);
    }
}

이제 폼 프로퍼티를 SportType 같은 커스텀 클래스로 바인딩하는 데 필요한 컨버터 클래스와 컨트롤러를 연관지을 차례이다. WebMvcConfigurer 의 addFormatters() 메소드가 이런 상황에 사용된다.

@Configuration
@EnableWebMvc
public class WebConfiguration implements WebMvcConfigurer {
	
    @Autowired
    private ReservationService reservationService;
    
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new SportTypeConverter(reservationService));
    }
}

addFormatters() 메소드는 reservationService 필드를 이용해 SportTypeConverter 변환기 객체를 생성한 뒤, 자신의 매개변수로 받은 FormatterRegistry 객체에 추가한다.

 

폼 데이터 검증

폼이 전송되면 처리하기 전 유저가 입력한 데이터가 올바른지 검사해야 한다. 스프링 MVC 는 Validator 인터페이스를 구현한 검증기 객체를 지원한다. 다음 코드는 필수 입력 필드를 모두 기재했는지, 예약한 시간이 운영 시간 이내인지 체크하는 검증기이다.

@Component
public class ReservationValidator implements Validator {
	
    @Override
    public boolean supports(Class<?> clazz) {
        return Reservation.class.isAssignableFrom(clazz);
    }
    
    @Override
    public void validate(Object target, Errors errors) {
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "courtName",
            "required.courtName", "Court name is required.");
        ValidationUtils.rejectIfEmpty(errors, "date",
            "required.date", "Date is required.");
        ValidationUtils.rejectIfEmpty(errors, "hour",
            "required.hour", "Hour is required.");
        ValidationUtils.rejectIfEmptyOrWhitespace(errors, "player.name",
            "required.playerName", "Player name is required.");
        ValidationUtils.rejectIfEmpty(errors, "sportType",
            "required.sportType", "Sport type is required.");

        Reservation reservation = (Reservation) target;
        LocalDate date = reservation.getDate();
        int hour = reservation.getHour();
        if (date != null) {
            if (date.getDayOfWeek() == DayOfWeek.SUNDAY) {
                if (hour < 8 || hour > 22) {
                    errors.reject("invalid.holidayHour", "Invalid holiday hour.");
                }
            } else {
                if (hour < 9 || hour > 21) {
                    errors.reject("invalid.weekdayHour", "Invalid weekday hour.");
                }
            }
        }
    }
}

필수 입력 필드의 기재 여부는 ValidationUtils 클래스의 rejectIfEmptyOrWhitespace(), rejectIfEmpty() 등의 유틸리티 메소드로 조사해 하나라도 값이 비어있으면 필드 에러를 만들어 해당 필드에 바인딩한다. 이들 메소드의 두 번째 인자는 프로퍼티명, 세 번째, 네 번째 인자는 각각 에러 코드 및 기본 에러 메시지이다.

 

밑의 코드는 유저가 예약 신청한 시간이 운영 시간 이내인지 체크한다. 예약 시간이 올바르지 않으면 reject() 메소드로 에러를 발생시키고 이번엔 필드가 아닌 Reservation 객체에 바인딩한다.

 

검증기를 위와 같이 정의한 후 컨트롤러를 다음과 같이 수정해 검증기를 적용한다.

public class ReservationFormController {
	
    private ReservationService reservationService;
    private ReservationValidator reservationValidator;
    
    ...
    
    @PostMapping
    public String submitForm(@ModelAttribute("reservation") @Validated Reservation reservation,
        BindingResult result, SessionStatus status) {
        if (result.hasErrors()) {
            return "reservationForm";
        } else {
            reservationService.make(reservation);
            return "redirect:reservationSuccess";
        }
    }
    
    @InitBinder
    public void initBinder(WebDataBinder binder) {
        binder.setValidator(reservationValidator);
    }
}

먼저 검증기 빈 인스턴스를 액세스하기 위해 reservationValidator 필드를 선언한다.

폼 전송 시 호출하는 POST 메소드도 수정해야 한다. @ModelAttribute 다음에 @Validated 를 나란히 적어 검증 대상 매개변수임을 명시한다. 검증 결과는 모두 BindingResult 객체에 저장되므로 조건문에서 result.hasErrors() 에 따라 분기 처리한다.

 

폼 처리 도중 에러가 발생하면 메소드 핸들러는 reservationForm 뷰를 반환하고 유저는 동일한 폼에서 오류를 전송한 뒤 다시 전송할 것이다. 에러가 없으면 예약 처리한 후 성공 뷰로 리다이렉트한다.

 

@InitBinder 를 붙인 메소드는 setValidator() 메소드를 호출해 검증기를 등록한다. 검증기는 바인딩 이후 사용할 수 있게 WebDataBinder 에 설정한다. 검증기 인스턴스를 가변 인수로 여럿 취하는 addValidators() 메소드를 싸용하면 여러 검증기를 한 번에 등록할 수 있다.

NOTE.
WebDataBinder 는 형변환 용도로 PropertyEditor, Converter, Formatter 인스턴스를 추가할 때에도 사용한다. 덕분에 전역 PropertyEditor, Converter, Formatter 를 등록하지 않아도 된다.

 

컨트롤러의 세션 데이터 만료시키기

폼이 여러 번 전송되거나 유저 입력 데이터가 유실되지 않게 하기 위해 컨트롤러에 @SessionAttributes 를 붙여 사용한다. 이렇게 하면 여러 요청을 거치더라도 Reservation 객체 형태의 예약 필드를 참조할 수 있다.

 

하지만 폼 전송 후 예약을 성공적으로 완료한 이후까지 Reservation 객체를 세션에 보관할 이유는 없다. 유저가 아주 짧은 기간 내에 폼 페이지를 재방문할 경우, 미처 삭제되지 않은 객체의 찌꺼기 데이터가 노출될 수 있기 때문이다.

 

@SessionAttributes 로 세션에 추가된 값은 SessionStatus 객체로 지운다. 이 객체는 핸들러 메소드의 입력 매개변수로 가져올 수 있다.

@PostMapping
public String submitForm( ... ) {
    if (result.hasErrors()) {
        ...
    } else {
        ...
        status.setComplete();
        return ...;
    }
}

세션에 추가된 값의 삭제는 SessionStatus 객체의 setComplete() 메소드를 호출해서 수행할 수 있다. 위의 submitForm() 메소드에 setComplete() 호출만 추가하면 세션 데이터를 만료시킬 수 있다.

 

#Reference.

 

스프링 5 레시피(4판)

이 책은 스프링 5에 새로 탑재된 기능 및 다양한 구성 옵션 등 업데이트된 프레임워크 전반을 실무에 유용한 해법을 제시하는 형식으로 다룹니다. IoC 컨테이너 같은 스프링 기초부터 스프링 AOP/A

www.hanbit.co.kr

 

+ Recent posts