[Spring MVC 2편] 10. 스프링 타입 컨버터

HJ·2023년 1월 23일
0

Spring MVC 2편

목록 보기
10/13

김영한 님의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의를 보고 작성한 내용입니다.
https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard


1. 타입 변환

1-1. 직접 타입 변환

@GetMapping("/hello-v1")
public String helloV1(HttpServletRequest request) {
    String data = request.getParameter("data"); 
    Integer intValue = Integer.valueOf(data);
    return "ok";
}
  • localhost:8080/hello-v1?data=10로 요청을 보낸다고 가정했을 때

  • HTTP 요청 파라미터는 모두 문자로 처리되기 때문에 숫자로 이용하고 싶다면 위처럼 타입 변환을 해야한다

  • but> 위의 방식처럼 직접 타입을 변환하지 않아도 스프링이 중간에서 타입 변환을 해주기도 한다


1-2. 스프링이 자동으로 변환

@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
    ...
    return "ok";
}
  • 쿼리 스트링으로 문자가 전달되어도 @Requestparam을 사용하면 문자를 Integer 10 으로 받을 수 있다

  • @ModelAttribute@PathVariable도 스프링이 자동으로 타입변환을 해주기 때문에 선언된 타입으로 받을 수 있다

  • 스프링의 타입 변환 적용 예시

    • 스프링 MVC 요청 파라미터 : @RequestParam, @ModelAttribute, @PathVariable

    • @Value 등으로 YML 정보 읽기

    • XML에 넣은 스프링 빈 정보를 반환

    • 뷰를 렌더링할 때


1-3. 컨버터 인터페이스

@FunctionalInterface
public interface Converter<S, T> {

	@Nullable
	T convert(S source);
}
  • 새로운 타입을 만들어서 변환하고 싶거나 추가적인 타입 변환이 필요한 경우 위의 인터페이스를 구현해서 등록하면 된다

  • 컨버터 인터페이스는 모든 타입에 적용할 수 있다

  • org.springframework.core.convert.converter.Converter




2. 타입 컨버터 - Converter

2-1. 예시로 이해하기

@Getter
@EqualsAndHashCode
public class IpPort {

    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}
  • @EqualsAndHashCode 를 넣으면 모든 필드를 사용해서 equals() , hashcode() 를 생성한다

  • 따라서 참조값이 달라도 모든 필드의 값이 같다면 a.equals(b) 의 결과가 true가 된다

  • @EqualsAndHashCode 덕분에 아래 테스트 코드에서 isEqualTo()로 비교가 가능하다


public class StringToIpPortConverter implements Converter<String, IpPort> {

    @Override
    public IpPort convert(String source) {
        String[] split = source.split(":");
        String ip = split[0];
        int port = Integer.parseInt(split[1]);
        return new IpPort(ip, port);
    }
}
  • 127.0.0.1:8080이라는 문자가 들어오면 IpPort 라는 객체로 변환하는 컨버터

  • ip와 port가 주어지면 127.0.0.1:8080처럼 변환하는 컨버터 코드는 생략

  • 어떤 식으로 컨버터를 구현하는지를 확인!!


@Test
void stringToIpPort() {
    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));
}
  • 위의 방식은 컨버터를 생성하고 직접 convert()를 진행했는데 이런 방식을 사용하진 않는다

  • 타입 컨버터를 스프링에 등록하고 관리하면서 편리하게 변환 기능을 제공하는 것이 존재

    • @RequestParam이나 @ModelAttribute 같은 어노테이션을 통해 바로 받을 수 있다

2-2. 참고

  • 스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다

  • 용도에 따른 다양한 방식의 타입 컨버터

    • Converter : 기본 타입 컨버터

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

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

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




3. ConversionService

3-1. ConversionService 인터페이스

public interface ConversionService {

	boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);

	boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);

	@Nullable
	<T> T convert(@Nullable Object source, Class<T> targetType);

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

}
  • 타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것이 아니라 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있도록 하는 것이 ConversionService

  • ConversionService는 변환이 가능한지 확인하는 기능과 변환하는 기능을 제공


3-2. 테스트로 확인

public class ConversionServiceTest {

    @Test
    void conversionService() {
        // 컨버터를 등록
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        ...

        // 사용
        assertThat(conversionService.convert("10", Integer.class)).isEqualTo(10);

        IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
        ...
    }
}
  • DefaultConversionService는 ConversionService를 구현한 구현체

  • addConverter()를 통해 컨버터를 등록한다

  • conversionService.convert()로 실행하는데 입력 데이터와 반환하는 타입을 파라미터로 전달한다

  • conversionService가 자동으로 적절한 컨버터를 찾아 컨버터의 convert()를 실행해준다


3-3. 인터페이스 분리 원칙 - ISP

  • 인터페이스 분리 원칙 : 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다

  • DefaultConversionService는 아래 두 인터페이스를 구현했다

    • ConversionService : 컨버터 사용에 초점

    • ConverterRegistry : 컨버터 등록에 초점

  • 사용과 등록 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하는 클라이언트의 관심사를 명확하게 분리할 수 있다

    • 컨버터를 사용하는 클라이언트는 어떻게 등록하고 관리하는지는 몰라도 되기 때문에 ConversionService만 의존한다

    • 그러므로 컨버터를 사용하는 클라이언트는 등록하는 메서드는 사용하지도 않고 의존하지도 않는다

  • 이처럼 인터페이스를 분리하는 것을 ISP라고 한다

  • 스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다 ( @Requestparam 같은 곳에서 사용 )




4. 스프링에 Converter 적용

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToIntegerConverter());
        ...
    }
}
  • WebMvcConfigurer가 제공하는 addConverter()를 사용해 추가하고 싶은 컨버터 등록

  • addConverter()는 스프링 내부에서 사용하는 ConversionService에 컨버터를 추가해준다

  • 컨버터를 추가하면 기존에 존재하던 컨버터보다 높은 우선순위를 가진다

    • 문자 ➜ 정수로 변환하는 컨버터를 등록하기 전에도 기존에 존재하던 컨버터에 의해 정상적으로 변환이 이루어짐

    • 문자 ➜ 정수로 변환하는 컨버터를 직접 만들어서 등록한 경우, 기존에 존재하던 컨버터가 아닌 새로 등록한 컨버터가 동작함

  • @RequestParam 사용 시 처리 과정

    • @RequestParam을 처리하는 ArgumentResolver 및 RequestParamMethodArgumentResolver에서 ConversionService를 사용해서 타입을 변환



5. 뷰 템플릿에 Converter 적용

// Controller
@GetMapping("/converter-view")
public String converterView(Model model) {
    model.addAttribute("number", 10000);
    model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));
    return "converter-view";
}
<!-- Thymeleaf ( converter-view.html ) -->
<li>${number}: <span th:text="${number}" ></span></li>
<li>${{number}}: <span th:text="${{number}}" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
<!-- 페이지 소스 보기 -->
<li>${number}: <span >10000</span></li>
<li>${{number}}: <span >10000</span></li>
<li>${ipPort}: <span >ghkwhd.typeconverter.type.IpPort@59cb0946</span></li>
<li>${{ipPort}}: <span >127.0.0.1:8080</span></li>
  • 타임리프는 렌더링 시 컨버터를 적용하는데 이 때 적용되는 컨버터는 객체를 문자로 변환해주는 컨버터

  • 타임리프는 ${{...}}를 사용하면 자동으로 ConversionService를 사용해서 변환된 결과를 출력해준다

  • 스프링과 통합되어 스프링이 제공하는 ConversionService를 사용하기 때문에 직접 등록한 컨버터도 사용 가능

  • 결과 설명

    • ${{number}}의 경우 number에는 숫자가 담겨있지만 타임리프는 텍스트로 출력을 해야하기 때문에 숫자를 문자로 변환하는 컨버터를 호출

    • ${ipPort}는 컨버터가 적용되지 않고 객체 그대로를 출력하기 때문에 ipPort.toString()이 호출된다

    • ${{ipPort}}는 타임리프가 IpPort를 String으로 변환하는 컨버터를 불러서 변환 후 출력한 결과




6. 폼에 Converter 적용

6-1. @GetMapping

// Controller
@GetMapping("/converter/edit")
public String convertForm(Model model) {
    IpPort ipPort = new IpPort("127.0.0.1", 8080);
    Form form = new Form(ipPort);
    model.addAttribute("form", form);
    return "converter-form";
}
<!-- Thymeleaf ( converter-form.html ) -->
<form th:object="${form}" th:method="post">
    <input type="text" th:field="*{ipPort}"><br/>
    <input type="text" th:value="*{ipPort}"><br/>
    <input type="submit"/>
</form>
<!-- 페이지 소스 보기 -->
<input type="text" id="ipPort" name="ipPort" value="127.0.0.1:8080">
<input type="text" value="ghkwhd.typeconverter.type.IpPort@59cb0946">
  • th:field는 컨버터를 자동으로 적용한다

  • 그래서 th:field에 중괄호를 하나만 사용했지만 IpPort 객체를 문자로 변환하는 컨버터에 의해 변환된 결과가 출력된 것을 볼 수 있다

  • but> th:value는 컨버터가 자동으로 적용되지 않아 IpPort.toString()이 호출된 결과가 출력된다


6-2. @PostMapping

// Controller
@PostMapping("/converter/edit")
public String converterEdit(@ModelAttribute Form form, Model model) {
    IpPort ipPort = form.getIpPort();
    model.addAttribute("ipPort", ipPort);
    return "converter-view";
}
<!-- Thymeleaf ( converter-view.html ) -->
<li>${number}: <span th:text="${number}" ></span></li>
<li>${{number}}: <span th:text="${{number}}" ></span></li>
<li>${ipPort}: <span th:text="${ipPort}" ></span></li>
<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li>
<!-- 페이지 소스 보기 -->
<li>${ipPort}: <span >ghkwhd.typeconverter.type.IpPort@59cb0946</span></li>
<li>${{ipPort}}: <span >127.0.0.1:8080</span></li>
  • form의 submit을 누르면 @PostMapping으로 전달되는데 @GetMapping의 결과를 보면 value가 문자임을 알 수 있다

  • 그러므로 @ModelAttribute에 의해 문자를 IpPort 객체로 변환하는 컨버터가 호출

  • 다시 렌더링 될 때는 ${{...}}에 의해 IpPort 객체를 문자로 변환하는 컨버터가 호출




7. Formatter

7-1. 설명

  • 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는데 특화된 기능이 Formatter

  • ex> 숫자를 1000단위에 숫자를 넣어 문자로 출력 or 날짜 객체를 특정 형식의 문자로 출력

  • 날짜와 숫자의 표현이 나라에 따라 다를 수도 있기 때문에 Formatter에는 Locale 정보가 제공된다

  • 즉, Formatter는 문자에 특화( 객체 ➜ 문자, 문자 ➜ 객체 ) + 현지화( Locale )

  • 스프링은 용도에 따라 다양한 방식의 Formatter 제공

    • Formatter

    • AnnotationFormatterFactory : 필드의 타입이나 어노테이션 정보를 활용할 수 있는 Formatter


7-2. Formatter 인터페이스

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> { }
  • String print(T object, Locale locale) : 객체 ➜ 문자

  • T parse(String text, Locale locale) : 문자 ➜ 객체

  • Formatter는 위의 두 가지를 모두 상속받기 때문에 객체를 문자로 변환하고, 문자를 객체로 변환하는 두 가지 기능을 모두 수행


7-3. Formatter 구현

public class MyNumberFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        return NumberFormat.getInstance(locale).parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        return NumberFormat.getInstance(locale).format(object);
    }
}
  • Number 타입 : Integer, Long 과 같은 숫자 타입의 부모 클래스

  • 숫자의 천 단위에 쉼표를 적용하려면 NumberFormat 객체를 사용

    • NumberFormat은 Locale 정보를 활용해서 나라 별로 다른 숫자 포맷을 만들어준다
  • parse() : 문자 ➜ 숫자 ( Number 타입 )

  • print() : 객체 ➜ 문자


7-4. 테스트로 확인

class MyNumberFormatterTest {

    MyNumberFormatter formatter = new MyNumberFormatter();

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

    @Test
    void print() {
        String result = formatter.print(1000, Locale.KOREA);
        assertThat(result).isEqualTo("1,000");
    }
}
  • parse()의 경우 실제 내부에서 Long 타입으로 만들어지기 때문에 테스트 확인을 Long 타입으로 진행

7-5. Formatter 적용

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addFormatter(new MyNumberFormatter());
    }
}
  • addFormatter() : Formatter가 스프링이 기본으로 들고있는 ConversionService에 등록된다

  • 동일한 기능을 하는 컨버터와 포맷터가 둘 다 등록되어 있는 경우, 컨버터가 우선순위를 갖고 동작한다




8. 포맷터를 지원하는 ConversionService

8-1. 설명

  • ConversionService에는 컨버터만 등록할 수 있고 포맷터는 등록할 수 없다

  • but> 포맷터를 지원하는 ConversionService를 사용하면 ConversionService에 포맷터를 추가할 수 있다

    • 컨버터도 등록 가능하다

    • 내부에서 어댑터 패턴을 사용해서 포맷터가 컨버터처럼 동작하도록 지원한다

  • FormattingConversionService : 포맷터를 지원하는 ConversionService

  • DefaultFormattingConversionService : FormattingConversionService에 기본적인 통화, 숫자 관련 몇 가지 기본 포맷터를 추가해서 제공


8-2. 테스트로 확인

@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()를 사용

  • 즉, ConversionService를 통해 컨버터와 포맷터를 일관성 있게 사용할 수 있다

  • 참고> 스프링부트는 DefaultFormattingConversionService를 상속받은 WebConsversionService를 내부에서 사용한다




9. 스프링이 제공하는 기본 Formatter

9-1. 설명

  • 스프링에는 자바 기본으로 제공하는 타입들에 대해 많은 Formatter를 기본으로 제공

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

  • 이런 문제를 해결하기 위해 어노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 포맷터를 제공

    • @NumberFormat : 숫자 관련 형식 지정 포맷터

    • @DataTimeFormat : 날짜 관련 형식 지정 포맷터


9-2. @GetMapping

@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";
    }


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

        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}
<!-- Thymeleaf -->
<form th:object="${form}" th:method="post">
    <input type="text" th:field="*{number}"><br/>
    <input type="text" th:field="*{localDateTime}"><br/>
    <input type="submit"/>
</form>
<!-- 페이지 소스 보기 -->
<form method="post">
    <input type="text" id="number" name="number" value="10,000"><br/>
    <input type="text" id="localDateTime" name="localDateTime" value="2023-01-24 00:06:55"><br/>
    <input type="submit"/>
</form>
  • th:field는 타입 컨버터를 자동으로 적용된다

  • 어노테이션이 있으면 스프링이 기본으로 제공하는 포맷터들이 적용됨

  • 숫자 10000이 @NumberFormat으로 지정된 형식으로 변환되어 화면에 출력됨

  • 날짜 역시 @DateTimeFormat에 지정된 형식으로 변환되어 화면에 출력됨


9-3. @PostMapping

// Controller
@PostMapping("/formatter/edit")
public String formatterEdit(@ModelAttribute Form form) {
    return "formatter-view";
}
<!-- Thymeleaf -->
<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>
<!-- 페이지 소스 보기 -->
<li>${form.number}: <span >10000</span></li>
<li>${{form.number}}: <span >10,000</span></li>
<li>${form.localDateTime}: <span >2023-01-24T00:06:55</span></li>
<li>${{form.localDateTime}}: <span >2023-01-24 00:06:55</span></li>
  • GetMapping 상태에서 submit을 누르면 문자로 number와 localDateTime이 들어온다

  • 문자를 객체로 변환해서 Form에 넣어주어야하는데 이 때도 Formatter가 적용

  • 객체를 문자로 변환할 때, 문자를 객체로 변환할 때 둘 다 어노테이션으로 지정된 포맷을 사용한다




10. 주의할 점

  • HttpMessageConverter에는 ConversionService가 적용되지 않는다

  • HttpMessageConverter의 역할은 Http Message Body의 내용을 객체로 변환하거나 객체를 message body에 입력하는 것

    • ex> JSON을 객체로 변환하는 메세지 컨버터는 내부에서 Jackson과 같은 라이브러리를 사용
  • 그러므로 객체를 JSON으로 변환할 때 결과는 라이브러리에 달려 있기 때문에 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶은 경우, 해당 라이브러리가 제공하는 설정을 통해 포맷을 지정해야한다

0개의 댓글