[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 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶은 경우, 해당 라이브러리가 제공하는 설정을 통해 포맷을 지정해야한다

profile
공부한 내용을 정리해서 기록하고 다시 보기 위한 공간

0개의 댓글