데이터 바인딩이란 간단하게 말해서 프로퍼티 값을 타겟 객체에 설정해주는 것을 의미한다. 예를들어 설명하자면 사용자의 문자열 입력값을 어플리케이션 도메인 객체의 프로퍼티값으로 동적으로 할당해주는 것과 같은 일을 데이터 바인딩이라고 한다.
이제, 스프링에서는 데이터바인딩이 어떻게 이루어지는 지 살펴보도록 하자.
먼저 Event 라는 도메인 객체가 있다고 생각해보자
public class Event {
Integer id;
public Event(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
}
그리고, 아이디를 url 패턴으로 받아와 Event 객체에 바인딩 시켜서 받는 핸들러가 있다.
@RestController
public class EventController {
@GetMapping("/event/{eventId}")
public String getEvent(@PathVariable("eventId") Event event){
return event.getId().toString();
}
}
테스트를 통해 아이디가 Event객체로 잘 바인딩 되는지 확인해보면
URL 패턴으로 받아온 String 을 Event타입으로 converting 할 수 없다는 에러메세지와 함께 테스트가 실패하는 것을 볼 수 있다.
이를 해결하기위해 PropertyEditor 를 만들어 String 을 Event로 바인딩 시켜줄 수 있는데 나는 PropertyEditor 의 구현체인 PropertyEditorSupport 를 사용하여 바인딩 해보도록 하겠다.
import java.beans.PropertyEditorSupport;
public class EventPropertyEditor extends PropertyEditorSupport {
@Override
public void setAsText(String text) throws IllegalArgumentException {
Event event = new Event(Integer.parseInt(text));
setValue(event);
}
}
간단하게 코드를 설명하자면 setAsText 메소드를 통해 argument로 String 을 받아와 코드를 통해 바인딩 시킨후 setValue 를 통해 바인딩한 객체를 사용할 수 있게 하는 방법이다. 그런데, 이러한 PropertyEditor를 사용할 때 주의해야할 점이 있다.
바로, setValue 에서 알 수 있듯이 value 를 PropertyEditor 가 가지고 있게된다. 즉 stateless 하지 않다는 소리이다. 이말은 곧 여러 사용자의 요청에 의해 바인딩 처리를 하다가 value 가 꼬여버릴 수 있다는 말이다. Thread-safe 하지 못하기 때문에 일반적인 singletone scope 를 가지는 bean으로 사용해서는 안된다. 이를 피하기 위해 bean의 스코프를 thred 로 변경하여 사용하면 더 안전하긴 하지만, 권장되지 않는다고 한다.
그렇다면 어떻게 사용해야 할까?
@InitBinder 를 활용해 WebDataBinder 에 PropertyEditor 를 등록하여 사용하는 방법이 있다.
@RestController
public class EventController {
@InitBinder
public void init(WebDataBinder webDataBinder){
webDataBinder.registerCustomEditor(Event.class, new EventPropertyEditor());
}
@GetMapping("/events/{eventId}")
public String getEvent(@PathVariable("eventId") Event event){
return event.getId().toString();
}
}
등록된 바인더가 잘 동작하는지 확인하기 위해 아까전의 테스트를 다시 돌려보면
테스트가 통과하는 것을 확인할 수 있다.
그런데, 이러한 방법은 사용해봐서 느낄 수 있듯이 불편한점들이 있다. Threadsafe 하지도 않고, String - Object 간의 컨버팅만 가능하다.
그래서 Spring에서는 데이터바인딩을 위해 Converter, Formatter를 제공해준다.
그럼 이제 Converter 에 대해서 알아보도록 하자.
마찬가지로 도메인 객체와 컨트롤러를 생성하고 테스트를 진행한다.
public class Event {
private Integer id;
public Event(Integer id) {
this.id = id;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
}
@RestController
public class EventController {
@GetMapping("/events/{eventId}")
public String getEvent(@PathVariable("eventId") Event event){
return event.getId().toString();
}
}
@WebMvcTest
class EventControllerPropertyEditorTest {
@Autowired
MockMvc mockMvc;
@Test
void getEvent() throws Exception {
mockMvc.perform(get("/events/1"))
.andExpect(status().isOk())
.andExpect(content().string("1"));
}
}
역시나 마찬가지로 URL 패턴으로 받아온 String 을 Event타입으로 converting 할 수 없다는 에러메세지와 함께 테스트가 실패한다.
그럼 이제 Converter를 추가해주도록 하자.
@Component
public class EventConverter implements Converter<String, Event> {
@Override
public Event convert(String source) {
return new Event(Integer.parseInt(source));
}
}
다음과 같이 Converter 를 추가해 주었는데, 우선 Conveter는 Threadsafe 함으로 빈으로 등록해서 사용해도 아무런 지장이 없다. 그래서 @Component 어노테이션을 통해 빈으로 등록하여 사용하면 된다. 그리고 테스트를 돌리면 테스트가 성공하는 것을 확인할 수 있을 것이다. 그런데 여기서 스프링 부트와 스프링간의 차이가 있다.
스프링 부트에서는 위와같이 Conveter를 빈으로 등록하기만 하면 알아서, FormatterRegistry에 해당 Converter를 추가해 사용할 수 있게 해주지만
스프링 부트가 아닌 스프링의 경우 그런 기능이 없기 때문에 직접 설정해주어야 한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new EventConverter());
}
}
다음과 같이 설정 클래스를 만들어 직접 등록해주면 된다.
다음으로 위에서 Converter 와 같이 언급했던 Formatter에 대해 알아보자.
Formatter도 Converter와 마찬가지로 Threadsafe 하며, 부트를 사용한다면 빈으로 등록하기만 해도 사용할 수 있다.
그럼 Formatter 를 구현해 보도록 하자.
@Component
public class EventFormatter implements Formatter<Event> {
@Override
public Event parse(String text, Locale locale) throws ParseException {
return new Event(Integer.parseInt(text));
}
@Override
public String print(Event object, Locale locale) {
return null;
}
}
그런데 테스트를 돌리면, 테스트가 깨지는 것을 확인할 수 있다. 이렇게 테스트에서는 깨져도, 앱을 구동시켜 직접 입력하면 제대로 동작하는것을 알 수 있는데 테스트에서 깨지는 이유는 바로 슬라이싱 테스트를 위해 테스트에 @WebMvcTest을 작성했기 때문이다.
@WebMvcTest의 Doc 을 확인해보면 다음과 같이 쓰여있다.
Using this annotation will disable full auto-configuration and instead apply only configuration relevant to MVC tests (i.e. @Controller, @ControllerAdvice, @JsonComponent, Converter/GenericConverter, Filter, WebMvcConfigurer and HandlerMethodArgumentResolver beans but not @Component, @Service or @Repository beans).
MVC test 를 위한 Configuration 만 apply 해준다고 쓰여있는데 Conveter는 포함되어있지만 Formatter 는 포함되어있지 않음을 알 수 있다. 즉, 테스트 환경에서 Formatter 빈이 등록되지 않기 때문에 테스트가 깨지는 것이다. 따라서 테스트를 통과시키기 위해서는 2가지 방법정도를 생각할 수 있느데
@SpringBootTest
@AutoConfigureMockMvc
class EventFormatterControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void getEvent() throws Exception {
mockMvc.perform(get("/events/1"))
.andExpect(status().isOk())
.andExpect(content().string("1"));
}
}
다음과 같이 @SpringBootTest 어노테이션을 통해 슬라이싱 테스트를 하지 않고 모든 빈을 등록해서 테스트할 수 있는 환경을 만들거나,
@WebMvcTest({EventController.class, EventFormatter.class})
class EventFormatterControllerTest {
@Autowired
MockMvc mockMvc;
@Test
void getEvent() throws Exception {
mockMvc.perform(get("/events/1"))
.andExpect(status().isOk())
.andExpect(content().string("1"));
}
}
슬라이싱 테스트를 이용하되, 명시적으로 등록할 빈을 적어주는 방법이 있다.
소스코드 Github 주소 : Spring-databinding
*참고: 이 포스팅은 백기선님의 스프링프레임워크 핵심기술 강의를 듣고, 제 나름대로 다시 공부하고 정리한 포스팅 입니다. 만약, 조금 더 자세한 내용을 듣고 싶으시다면 이곳의 강의를 참고해보시기 바랍니다