데이터 바인딩에 대해서 알아보자!
데이터 바인딩은 사용자의 입력값을 애플리케이션 도메인 모델에 동적으로 변환해주어 넣어주는 기능이다. 더 풀어서 이야기를 하자면, 사용자의 입력값을 객체가 가지고 있는 각 필드의 타입으로 변환해주어서 넣어주는 기능을 의미한다.
가장 원시적인 방법부터 접근해보자. 하단의 코드는 PathVariable로 1을 값을 넘겼을 때, 1이 문자열이 아닌 Integer 타입으로 바인딩해주는 코드다.
public class Event {
private Integer id;
private String title;
public Event(int id) {
this.id = id;
}
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
@Override
public String toString() {
return "Event{" +
"id=" + id +
", title='" + title + '\'' +
'}';
}
}
@RestController
public class EventController {
@InitBinder
public void init(WebDataBinder webDataBinder) {
webDataBinder.registerCustomEditor(Event.class, new EventEditor());
}
@GetMapping("/event/{event}")
public String getEvent(@PathVariable Event event) {
System.out.println(event);
return event.getId().toString();
}
}
// PropertyEditor를 구현하려면 많은 메서드를 구현해야하니 PropertyEditorSupport을 상속받아서 처리
public class EventEditor extends PropertyEditorSupport {
@Override
public String getAsText() {
Event event = (Event) getValue();
return event.getId().toString();
}
@Override
public void setAsText(String text) throws IllegalArgumentException {
setValue(new Event(Integer.parseInt(text)));
}
}
@RunWith(SpringRunner.class)
@WebMvcTest
class EventControllerTest {
@Autowired
MockMvc mockMvc;
@Test
public void getTest() throws Exception {
mockMvc.perform(get("/event/1"))
.andExpect(status().isOk())
.andExpect(content().string("1"));
}
}
이 코드에는 엄청나게 치명적인 문제를 가지고 있다. 바로 쓰레드 세이프하지 않는다는 점이다. (절대로 빈으로 등록해서는 안된다!)
스코프에 스레드 옵션을 주면 하나의 스레드에서만 유효하도록 설정할 수는 있으나, 이 방법은 권장하지 않는다고 한다. 또한 이 클래스는 Object와 String 간의 변환만 가능하여 사용 범위가 제한적이라는 단점을 가지고 있다.
스레드 세이프하지 않다는 점, Object와 String 간의 변환만 가능하다는 점에서 초기에 사용하던 방법은 문제가 많았다. 그래서 나온 개념이 Converter와 Formatter다.
Converter의 경우 상태 정보가 없기 때문에 얼마든지 빈으로 등록이 가능하고, 등록은 ConverterRegistry를 이용하여 처리해야 한다.
public class EventConverter {
public static class StringToEventConverter implements Converter<String, Event> {
@Override
public Event convert(String source) {
return new Event(Integer.parseInt(source));
}
}
public static class EventToStringConverter implements Converter<Event, String> {
@Override
public String convert(Event source) {
return source.getId().toString();
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
registry.addConverter(new EventConverter.EventToStringConverter());
}
}
@RestController
public class EventController {
@GetMapping("/event/{event}")
public String getEvent(@PathVariable Event event) {
System.out.println(event);
return event.getId().toString();
}
}
가장 원시적인 방법인 PropertyEditor의 대체제다. Converter와 마찬가지로 얼마든지 빈으로 등록이 가능하고 사용하는 방법 또한 Converter와 동일하다. 또한 문자열을 Locale에 따라 다국화하는 기능도 제공한다.
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// registry.addConverter(new EventConverter.EventToStringConverter());
registry.addFormatter(new EventFormatter());
}
}
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 object.getId().toString();
}
}
💡 Converter는 ConverterRegistry로, Formatter는 FormatterRegistry로 등록해야하지 않나?
정답은 FormatterRegistry가 Converter Registry를 상속받기 때문에 FormatterRegistry 하나만 이용해도 상관이 없다.
이 두 개의 핵심적인 차이는 단방향이냐 양방향이냐의 차이다. 추가적으로 Converter는 어떤 타입으로도 변환할 수 있다면 Formatter는 Object와 String 간의 변환을 담당한다.
ConversionService가 무엇인지 이해하기까지 시간이 좀 걸린 것 같다. 😫
위에서 살펴보았듯, Converter와 Formatter는 Registry로 등록해야 했다. 하지만 실질적으로 데이터 바인딩은 ConversionService 인터페이스로 이루어진다. 그럼 Converter/Formatter와 ConversionService의 관계는 뭘까? 🤬
쉽게 말하자면, Registry로 등록하는 Converter와 Formatter는 ConversionService에 등록이 된다고 볼 수 있다. 그리고 우리는 ConversionService를 이용해 변환 작업을 수행하는 것이다.
스프링이 제공하는 여러 구현체 중에서도 DefaultFormattingConversionService가 있는데, 이 클래스는 ConversionService의 빈으로 자주 사용된다. DefaultFormattingConversionService의 코드를 보면, FormatterRegistry와 ConversionService 인터페이스를 모두 구현한 것을 볼 수 있다. (그리고 위에서 언급햇듯이 FormatterRegistry는 ConverterRegistry를 상속받는다고 했다.)
즉, DefaultFormattingConversionService는 여러 기본 Converter와 Formatter 등록할 수 있도록 도와준다는 것을 알 수 있다.
public class DefaultFormattingConversionService extends FormattingConversionService { }
public class FormattingConversionService extends GenericConversionService implements FormatterRegistry, EmbeddedValueResolverAware { }
public class GenericConversionService implements ConfigurableConversionService { }
public interface ConfigurableConversionService extends ConversionService, ConverterRegistry { }
여기서 실제로 DefaultFormattingConversionService를 사용하는지 확인하기 위해 스프링 부트에 ConversionService를 주입받아와 출력해보자. 결과는 DefaultFormattingConversionService가 아닌 WebConversionService가 출력되는 것을 볼 수 있다.
@Component
public class AppRunner implements ApplicationRunner {
@Autowired
ConversionService conversionService;
@Override
public void run(ApplicationArguments args) throws Exception {
System.out.println(conversionService.getClass().toString()); // WebConversionService
}
}
DefaultFormattingConversionService와 어떤 차이가 있을지 궁금하니 코드를 열어서 살펴보면, WebConversionService는 DefaultFormattingConversionService를 상속하고 있고, 조금 더 많은 기능을 가지고 있는 것을 확인할 수 있다.
public class WebConversionService extends DefaultFormattingConversionService { }
추가적으로, 스프링 부트를 이용하면 Converter와 Formatter 빈을 찾아 자동으로 등록해준다. 따라서 기존처럼 WebMvcConfigurer를 구현하여 등록하지 않아도 @Component
어노테이션 하나만 붙여 빈을 등록하여 손쉽게 사용할 수 있다. (Converter와 Formatter는 스레드 세이프하니까 빈으로 등록해도 괜찮다!)
public class EventConverter {
@Component
public static class StringToEventConverter implements Converter<String, Event> {
@Override
public Event convert(String source) {
return new Event(Integer.parseInt(source));
}
}
@Component
public static class EventToStringConverter implements Converter<Event, String> {
@Override
public String convert(Event source) {
return source.getId().toString();
}
}
}
@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 object.getId().toString();
}
}
@RunWith(SpringRunner.class)
@WebMvcTest({
EventConverter.StringToEventConverter.class,
EventFormatter.class,
EventController.class}) // 테스트할 때 필요한 빈 등록
class EventControllerTest {
@Autowired
MockMvc mockMvc;
@Test
public void getTest() throws Exception {
mockMvc.perform(get("/event/1"))
.andExpect(status().isOk())
.andExpect(content().string("1"));
}
}