framework/spring

스프링 mvc2 - 8. 스프링 타입 컨버터

wooweee 2023. 5. 10. 01:21
728x90

0. summary

  • implements Converter<S, T> 로 사용자 정의 converter 생성

  • webMvcConfigure의 addFormatter()를 이용해서 해당 converter를 DefaultConversionSerivce에 등록한다.

  • converter 사용은 내부적으로 동작하므로 내부 동작을 할 수 있도록 1. 스프링 타입 컨버터 소개의 스프링과 타입 변환 예처럼 사용하면 알아서 converting 된다.

  • formatter도 동일하다. 하지만 converter보다 우선순위에서 밀린다.

 

1. 스프링 타입 컨버터 소개

  • 스프링이 중간에서 타입변환기를 사용해서 타입을 변환해주는 것

  • 스프링과 타입 변환 예 
    1. 스프링 MVC 요청 파라미터 : @RequestParam @ModelAttribute, @PathVariable
    2. YML 정보 읽기 - @Value
    3. XML에 넣은 스프링 빈 정보 변환
    4. 뷰를 렌더링 할 때
    5. 사용자 타입 변환 - 구현 필요

  • 컨버터 인터페이스
// converter interface source code
package org.springframework.core.convert.converter.Converter;

public interface Converter<S, T> {
    T convert(S source);
}

 

  • 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
  • 모든 타입에 적용이 가능 - 사용자 타입 변환 가능해짐 (아래와 같이 2개 만들면 되기 때문)
    1. S type -> T type
    2. T type -> S type 

 

2. 타입 컨버터 - Converter

  • 직접 컨버터를 만들어서 TDD까지 수행
    -> spring에 등록하고 사용해야하지만 등록없이 직접 만들어보고 작동원리를 알기 위함

  • org.springframework.core.convert.converter.Converter 의 인터페이스를 구현
    (Converter 라는 interface가 많으므로 주의)

 

2.1. 기본 예시

  • Integer -> String
package hello.typeconverter.converter;

@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
    @Override
    public String convert(Integer source) {
        log.info("convert source={}", source);
        return String.valueOf(source);
    }
}
  • string -> Integer
package hello.typeconverter.converter;

@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> { // String -> Integer로 변환하는 컨버터

    @Override
    public Integer convert(String source) {
        log.info("convert source={}", source);
        return Integer.valueOf(source);
    }
}
  • TDD
@Test
void stringToInteger() {
    StringToIntegerConverter converter = new StringToIntegerConverter();
    Integer result = converter.convert("10");
    assertThat(result).isEqualTo(10);
}

@Test
void integerToString() {
    IntegerToStringConverter converter = new IntegerToStringConverter();
    String result = converter.convert(10);
    assertThat(result).isEqualTo("10");
}

 

 

2.2. 실용 예시

 

  • IpPort class
@Getter
@EqualsAndHashCode // iv 같으면 동일 객체로 취급해주는 lombok
public class IpPort {
    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}
  • IpPort -> String
@Slf4j
public class IpPortToStringConverter implements Converter<IpPort, String> {
    @Override
    public String convert(IpPort source) {
        log.info("convert source={}", source);
        // IpPort 객체 -> "127.0.0.1:8080"
        return source.getIp() + ":" + source.getPort();
    }
}
  • String -> IpPort
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
    @Override
    public IpPort convert(String source) {
        log.info("convert source={}", source);

        // "127.0.0.1:8080" -> IpPort 객체
        String[] split = source.split(":");
        String ip = split[0];
        int port = Integer.parseInt(split[1]);
        return new IpPort(ip, port);
    }
}
  • TDD
@Test
void stringToIpPort() {
    IpPortToStringConverter converter = new IpPortToStringConverter();
    IpPort source = new IpPort("127.0.0.1", 8080);
    String result = converter.convert(source);
    assertThat(result).isEqualTo("127.0.0.1:8080");
}

@Test
void IpPortToString() {
    StringToIpPortConverter converter = new StringToIpPortConverter();
    String source = "127.0.0.1:8080";
    IpPort result = converter.convert(source);
    assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080));
}

 

2.3. 결론

 

  • 타입 컨버터를 하나하나 직접 작성해서 사용하면 개발자가 직접 converting하는 것고 큰 차이가 없다.

  • 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요
  •  conversionService

 

  •  conversionService
    • conversionService만 사용하면 되도록 spring이 conversionService 내부에 다양한 방식의 타입 컨버터를 제공

    • conversionService 내부 컨버터들

      1. Converter : 기본 타입 컨버터

      2. ConverterFactory : 전체 클래스 계층 구조가 필요할 때

      3. GenericConverter : 정교한 구현, 대상 필드의 어노테이션 정보 사용 가능

      4. ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행

 

3. 컨버전 서비스 - conversionService

  • 스프링이 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는 것

  • source Code
    • interface 
    • converting 가능 여부 확인
    • converting 수행

 

public interface ConversionService {

   // converting 할 수 있는지 확인
   boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);
   boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);

   // converting 하는 기능
   @Nullable
   <T> T convert(@Nullable Object source, Class<T> targetType);

   @Nullable
   Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}

 

3.1. conversionService 구현

  • 등록과 사용 분리
    • 등록 : 타입 컨버터를 명확하게 알아야한다.
    • 사용: 컨버터를 몰라도 된다. == 타입 변환을 원하는 개발자는 conversionService Interface에만 의존하면 된다. 

 

  • 인터페이스 분리 원칙(ISP)
    • client가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.

 

  • DefaultConversionService
    conversionService를 구현한 구현체로써, 2가지 인터페이스를 구현
    1. ConversionService
      • 컨버터 사용에 초점
    2. ConversionRegistry
      • 컨버터 등록에 초점
      • 만약에 등록의 interface가 막 변경되어도 이쪽에서만 잘 처리하면 컨버터 사용에서는 전혀 상관없이 동일하게 사용가능

  • 보통 spring interface 구현체들은 이 원칙을 토대로 구현
  • 해당 원칙으로 인해서 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.

 

public class ConversionServiceTest {

    @Test
    void conversionTest() {
        // 등록
        DefaultConversionService conversionService = new DefaultConversionService();
        // conversionService를 구현한 구현체 class

        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        // 사용
        // 개발자는 conversionService 내부에 뭐가 있는지 알 필요없이 convert()만 사용하면 된다.
        assertThat(conversionService.convert("10", Integer.class));
        assertThat(conversionService.convert(10, String.class));

        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));

        String ipPortString = conversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
        assertThat(ipPortString).isEqualTo("127.0.0.1:8080");
    }
}

 

4. 스프링에 Converter 적용하기

  • 등록 원리
    • 스프링은 내부에서 ConversionService를 제공
      = 스프링은 ConversionService로 DefaultConversionService를 기본으로 등록한다.

    • WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 DefaultConversionService에 추가하고 싶은 컨버터를 등록(커스텀 한다고 한다.)

 

  • 참고 - WebMvcConfigurer
    • Spring MVC에서 애플리케이션을 설정할 때 사용

    • 이 인터페이스를 구현하면 Spring MVC 구성을 커스터마이징할 수 있다.

    • 예) 해당 인터페이스를 사용하여 뷰 리졸버, 인터셉터, 메시지 컨버터 등을 등록하고, 리소스 핸들러를 설정하고, CORS를 활성화할 수 있다.

 

  • WebConfig
package hello.typeconverter;

@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
         registry.addConverter(new StringToIntegerConverter());
         registry.addConverter(new IntegerToStringConverter());
         registry.addConverter(new StringToIpPortConverter());
         registry.addConverter(new IpPortToStringConverter());
    }
}

 

  • controller - 등록
@RestController
public class HelloController {
    @GetMapping("/hello-v2")
    public String helloV2(@RequestParam Integer data) {
        System.out.println("data = " + data);
        return "ok";
    }

    @GetMapping("/ip-port")
    public String ipPort(@RequestParam IpPort ipPort) {
        System.out.println("ipPort IP = " + ipPort.getIp());
        System.out.println("ipPort.getPort() = " + ipPort.getPort());
        return "ok";
    }
}

 

  • 결과
    • log를 보면 StringToIntegerConverter 를 사용했다는 것이 잘 나온다.
      -> 사용자 등록 컨버터를 이용하지 않아도 DefaultConversionService 내부에 기본으로 제공하는 converter에 해당 기능이 존재하지만 공부용으로 찍어봄

    • 직접 만들었던 IpPort 객체도 잘 convert가 된다.
      -> string to IpPort 경우 spring 기본 컨버터에 등록이 되어있지 않기 때문에 WebMvcConfigurer를 이용해서 spring이 기본으로 사용하는 DefaultConversionService 내부에 해당 converter를 추가한다.

 

  • 처리과정
    • @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver에서 ConversionService를 내부적으로 수행 후 타입을 변환

 

5. 뷰 템플릿에 컨버터 적용하기

  • 타임리프  ${{...}} : 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
  • 상식적으로 매우 간단한 것들은 컨버전 서비스를 사용안해도 알아서 컨버팅 된다.

 

  • th:field : converter 자동 적용할 때
  • th:value : converter 적용 안하고 싶을 때

 

6.포맷터 - Formatter

 

  • Converter vs Formatter
    • Converter는 범용(객체 <-> 객체)
    • Formatter는 문자에 특화 (객체 <-> 문자) + 현지화(Locale)
    • Formatter는 Converter의 특별한 버전이라고 생각하면 된다.

 

  • 예시
    • 1000 -> "1000"이 아닌 "1,000" 으로 변경해야 할 경우
    • 날짜 객체를 "2023-05-10" || "2023/05/10" 같이 원하는 형식의 날짜 String으로 변환 하고 싶을 경우
       + 현지화 까지 적용시, 음력 양력도 고려 필요

 

 

6.1. Formatter interface

public interface Printer<T> {
    String print(T object, Locale locale);
}

public interface Parser<T> {
    T parse(String text, Locale locale) throws ParseException;
}

public interface Formatter<T> extends Printer<T>, Parser<T> {
}

 

6.2. Formatter 구현

 

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text={}, locale={}", text, locale);
        // "1,000" -> 1000
        return NumberFormat.getInstance(locale).parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        log.info("object={}, locale={}", object, locale);
        // 1000 -> "1,000"
        return NumberFormat.getInstance(locale).format(object);
    }
}
  • parse()
    • "1,000" -> 1000
    • 문자를 숫자로 변환
  • print()
    • 1000 -> "1,000"
    • 객체를 문자로 변환
  • NumberFormat
    • 자바가 기본으로 제공하는 NumberFormat 객체
    • locale 정보를 활용해서 나라별 다른 숫자 포맷을 만들어준다.

 

  • test
class MyNumberFormatterTest {

    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        Assertions.assertThat(result).isEqualTo(1000L); // Long type 주의
    }

    @Test
    void print() {
        String result = formatter.print(1000, Locale.KOREA);
        Assertions.assertThat(result).isEqualTo("1,000");
    }
}

 

 

7.  포맷터를 지원하는 컨버전 서비스

  • FormattingConversionService : formatter를 지원하는 conversionService

  • DefaultFormattingConversionService : FormattingConversionService + 기본적인 통화, 숫자 관련 몇가지 기본 formatter를 추가해서 제공
    • addConverter(), addFormatter() 둘다 등록이 가능 - 조상으로 conversionService를 가지고 있다.
    • 사용할 때는 convert()만 사용한다.

 

package hello.typeconverter.formatter;

public class FormattingConversionServiceTest {
    @Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();         //컨버터 등록
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());         
        //포맷터 등록
        conversionService.addFormatter(new MyNumberFormatter());
        //컨버터 사용
        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));         
        //포맷터 사용
        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    }
}

 

  • FormattingConversionService는  ConversionService 관련 기능을 상속받기 때문에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 
  • 그리고 사용할 때는  ConversionService가 제공하는  convert() 를 사용하면 된다.
  • 추가로 스프링 부트는  DefaultFormattingConversionService를 상속 받은  WebConversionService 를 내부에서 사용한다.

 

 

7.1. 포맷터 직접 등록하기

  • 내가 구현한 Formatter를 spring이 제공하는 DefaultFormattingConversionService에 넣기

 

  • DefaultFormatter
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        // 우선순위에 우위에 있어서 잠시 주석 처리
//         registry.addConverter(new StringToIntegerConverter());
//         registry.addConverter(new IntegerToStringConverter());
         registry.addConverter(new StringToIpPortConverter());
         registry.addConverter(new IpPortToStringConverter());
         registry.addFormatter(new MyNumberFormatter());
    }
}
  • 위의 예에서는 converter와 formatter 둘다 String <-> integer 로 변경되는 기능을 수행하게 되면 converter가 우선 순위를 가지게 된다.

 

7.2.  스프링이 제공하는 기본 포맷터

  • 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.

  • 그래서 어노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 유용한 포맷터 2가지를 기본으로 제공한다.

    1. @NumberFormat: 숫자 관련 형식 지정 포맷터 사용
    2. @DateTimeFormat: 날짜 관련 형식 지정 포맷터 사용

 

  • formatter Controller - @ModelAttribute formatting
@Controller
public class FormatterController {
    @GetMapping("/formatter/edit")
    public String formatterForm(Model model) {
        Form form = new Form();
        form.setNumber(10000);
        form.setLocalDateTime(LocalDateTime.now());

        model.addAttribute("form", form);
        return "formatter-form";
    }

    @PostMapping("/formatter/edit")
    public String formatterEdit(@ModelAttribute Form form) {
        return "formatter-view";
    }

    @Data
    static class Form {
        @NumberFormat(pattern = "###,###")
        private Integer number;

        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}
  • @PostMapping의 @ModelAttribute에서 Form의 어노테이션을 읽고 formatting을 수행한다.

 

  • view formatting1 - th:field
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title></head>
<body>

<form th:object="${form}" th:method="post">
    number <input type="text" th:field="*{number}"><br/>
    localDateTime<input type="text" th:field="*{localDateTime}"><br/>
    <input type="submit"/>
</form>
</body>
</html>
  • th:field="*{number}"
  • th:field="*{localDateTime}"

 

  • view formatting2 - ${{}}
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title></head>
<body>
</body>
</html>

<ul>
    <li>${form.number}: <span th:text="${form.number}"></span></li>
    <li>${{form.number}}: <span th:text="${{form.number}}"></span></li>
    <li>${form.localDateTime}: <span th:text="${form.localDateTime}"></span></li>
    <li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}"></span></li>
</ul>
</body>
</html>

 

8. 정리

  • 컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 다르지만,
    사용할 때는 컨버전 서비스를 통해서 일관성 있게 사용할 수 있다.

 

  • 주의
    • 메시지 컨버터 ( HttpMessageConverter )에는 컨버전 서비스가 적용되지 않는다.

    • 특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter 의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.
      • 예) JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용한다.
        객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다.
        따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다.

      • 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.

    • conversionService는  @RequestParam ,  @ModelAttribute ,  @PathVariable , 뷰 템플릿 등에서 사용할 수 있다.