본문 바로가기

더 나은 엔지니어가 되기 위해/지금은 안쓰는 자바

[스프링 프레임워크 핵심 기술] 데이터 바인딩 추상화

인프런에서 백기선님의 스프링 프레임워크 핵심 기술 을 공부하며 개인적으로 정리한 글입니다.

데이터 바인딩

데이터 바인딩은 넘어오는 값을 동적으로 객체에 할당(바인딩) 해주는 것을 의미한다.
이번 포스팅에서는 데이터 바인딩을 어떻게 하고, 어떻게 동작하는지에 대해 알아본다.

PropertyEditor

PropertyEditor 를 사용해서 바인딩하는 방법을 알아보자.
먼저, 바인딩 시킬 객체와 값을 입력받는 컨트롤러를 정의하자.

public class Event {

  private Integer id;
  private String title;

  public Event(Integer id) {
    this.id = id;
  }

  ... getter, setter and toString ...
}
@RestController
public class EventController {

  @GetMapping("/event/{event}")
  public String getEvent(@PathVariable Event event) {
    System.out.println(event);
    return event.getId().toString();
  }
}

이제 /event/{event} 로 요청을 줄 테스트 코드를 작성하자.

@RunWith(SpringRunner.class)
@WebMvcTest
public class EventControllerTest {

  @Autowired
  MockMvc mockMvc;

  @Test
  public void getTest() throws Exception {
    mockMvc.perform(get("/event/1"))
      .andExpect(status().isOk())
      .andExpect(content().string("1"));
  }
}

테스트 코드를 살펴보면, 우리가 기대하는 것은 {event}"1" 을 주었을 때, Event 인스턴스가 만들어지고, 이 인스턴스의 id 값이 1 이 되는 것이다.
정리하면, 컨트롤러 파라미터로 넘어온 "1"id = 1Event 인스턴스에 바인딩 되기를 기대한다.

위 테스트 코드를 실행하면 당연히 실패한다. 바인딩 해주는 부분이 없기 때문이다.
이를 위해 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)));
  }
}

getAsTextEvent 객체를 받아와 원하는 id 속성 값을 String 형태로 반환한다.
setAsTextString 을 입력받아 이를 Integer 로 변환한 뒤, Event 객체의 id 속성 값으로 저장한다. (생성자를 통해)

이제 컨트롤러에 다음을 추가해주자.

@RestController
public class EventController {

  @InitBinder
  public void init(WebDataBinder webDataBinder) {
    webDataBinder.registerCustomEditor(Event.class, new EventEditor());
  }

  ...
}

@InitBinder 라는 어노테이션을 통해 이 컨트롤러의 WebDataBinder 에 우리가 만든 데이터 바인더 EventEditor 를 추가해줬다.

이제 이 컨트롤러는 Event 타입으로 변수를 바인딩할 시, EventEditor 를 통해 바인딩하게 된다.
테스트 코드를 실행해보면 이제 테스트에 성공하는 것을 볼 수있다.

이 때, 한 가지 주의해아하는 사실은 PropertyEditor 객체 (여기서는 EventEditor) 는 Stateful 하다는 것이다. 즉, 이 객체는 현재 바인딩된 객체 정보를 온전히 담고있다.
따라서, Thread-safe 하지 않으며 빈으로 등록해서도 안된다.
Thread-scope 인 빈으로 등록하면 그나마 낫다고 하지만, 그래도 비추되는 방법이라고 한다.

Converter 와 Formatter

스프링 3.0 부터 도입된 기능으로, PropertyEditor 를 통한 데이터 바인딩의 단점을 일부 보완한다.
PropertyEditor 의 단점은 다음과 같다.

  • Stateful 하기 때문에 Thread-safe 하지 못하다.
  • String - Object 간의 데이터 바인딩만 가능하다.
    즉, Object - Object 간의 좀 더 범용적인 데이터 바인딩은 불가능함.

ConverterFormatter 는 이에 반해 Thread-safe 하고 여러 데이터 바인딩이 가능하다.
사용법도 훨씬 간편하다!
이제 그 사용법을 알아보자.

1) Converter

먼저 컨트롤러는 다음과 같이 구성된다.

@RestController
public class EventController {

  @GetMapping("/event/{event}")
  public String getEvent(@PathVariable Event event) {
    System.out.println(event);
    return event.getId().toString();
  }
}

다음으로, ConverterConverter<T, T> 인터페이스를 구현한다.
이 안에서 convert() 만 정의하면 된다.

import org.springframework.core.convert.converter.Converter;

public class EventConverter {

  public static class StringToEventConverter implements Converter<String, Event> {
    @Override
    public Event convert(String s) {
      return new Event(Integer.parseInt(s));
    }
  }

  public static class EventToStringConverter implements Converter<Event, String> {
    @Override
    public String convert(Event event) {
      return event.getId().toString();
    }
  }
}

여기서는 class EventConverter 내부에 Event - String 간의 컨버터 클래스들을 구현했다.

마지막으로, 사용할 ConverterWebMvcConfigurer 에 등록해줘야 한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

  @Override
  public void addFormatters(FormatterRegistry registry) {
    registry.addConverter(new EventConverter.StringToEventConverter());
  }
}

이제 테스트 코드를 실행하면 성공하는 것을 볼 수 있다.

2) Formatter

FormatterConverter 와 별반 다르지 않다.
다만, Locale 이 파라미터에 추가되어 다국어 기능을 구현할 수 있다.

다음과 같이 Formatter<T> 인터페이스를 구현하는 Formatter 클래스를 만든다.

public class EventFormatter implements Formatter<Event> {

  @Override
  public Event parse(String s, Locale locale) throws ParseException {
    return new Event(Integer.parseInt(s));
  }

  @Override
  public String print(Event event, Locale locale) {
    return event.getId().toString();
  }
}

마찬가지로 WebMvcConfigurer 에 등록한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

  @Override
  public void addFormatters(FormatterRegistry registry) {
    registry.addFormatter(new EventFormatter());
  }
}

테스트 코드를 실행하면 성공한다.

ConversionService

사실 Formatter 는 Converter 를 상속한다. 즉 Formatter 는 Converter 에 기능을 좀 더 추가한 것.

DefaultFormattingConvsersionService 를 이용하면 WebMvcConfigurer 에 등록 없이도 빈으로 주입받아 활용 가능하다. 이 객체는 위 그림에 나와있듯이, ConversionServiceFormatter Registry 를 모두 수행한다.

여기서는 스프링 부트에 기반해 WebMvcConfigurer 등록 없이, @Component 로 빈 등록 후 사용해본다.
스프링 부트에서는 ConversionService@Autowired 로 주입받으면 WebConversionService 의 인스턴스가 등장하는데, 이는 DefaultFormattingConvsersionService 을 상속받은 객체다.

여하튼 각설하고, WebConfig 클래스를 삭제한 뒤, Formatter 혹은 Converter@Component 를 통해 빈 등록을 해주자.

@Component
public class EventFormatter implements Formatter<Event> {
  ...
}

이제 테스트 코드를 돌려보면 잘 수행된다.

기타

사실 컨트롤러에서 데이터 바인딩을 명시해주지 않아도, 다음과 같은 코드는 잘 돌아간다.

@GetMapping("/event/{id}")
public String getEvent(@PathVariable int id) {
  ...
}

사실 id"1" 과 같이 String 형태로 넘어올텐데 int 에 바인딩이 된다.
내가 따로 데이터 바인더를 등록하지 않아도 이게 가능한 이유는, 이미 스프링 내부적으로 이러한 바인더가 등록되어있기 때문이다.

스프링 혹은 스프링 부트에서 제공해주는 이러한 기본적인 바인더는 어떤게 있을까?
다음과 같이 ConversionService 객체를 toString 으로 출력하면 볼 수 있다.

@Component
public class AppRunner implements ApplicationRunner {

  @Autowired
  ConversionService conversionService;

  @Override
  public void run(ApplicationArguments args) throws Exception {
    System.out.println(conversionService);
  }
}
ConversionService converters =
    @org.springframework.format.annotation.DateTimeFormat java.lang.Long -> java.lang.String: org.springframework.format.datetime.DateTimeFormatAnnotationFormatterFactory@317a118b,@org.springframework.format.annotation.NumberFormat java.lang.Long -> java.lang.String: org.springframework.format.number.NumberFormatAnnotationFormatterFactory@518bfd90
    @org.springframework.format.annotation.DateTimeFormat java.time.LocalDate -> java.lang.String: org.springframework.format.datetime.standard.Jsr310DateTimeFormatAnnotationFormatterFactory@1dc3502b,java.time.LocalDate -> java.lang.String : org.springframework.format.datetime.standard.TemporalAccessorPrinter@6a1d3225
    @org.springframework.format.annotation.DateTimeFormat java.time.LocalDateTime -> java.lang.String: org.springframework.format.datetime.standard.Jsr310DateTimeFormatAnnotationFormatterFactory@1dc3502b,java.time.LocalDateTime -> java.lang.String : org.springframework.format.datetime.standard.TemporalAccessorPrinter@1457fde
    ...