둘셋 개발!

[spring mvc 2편-웹 개발 활용 기술 - 8 ] 스프링 타입 컨버터 본문

SPRING/MVC

[spring mvc 2편-웹 개발 활용 기술 - 8 ] 스프링 타입 컨버터

23 2022. 3. 2. 12:12

✔️스프링 타입 컨버터

: 문자를 숫자로 변환하거나, 숫자를 문자로 변환하는 타입 변환을 해야할 때 타입컨버터를 사용하면 된다!

✔️스프링 타입 변환 적용 예

- 스프링 MVC 요청 파라미터 (@RequestParam, @ModelAttribute, @PathVariable)
- @Value 등으로 YML 정보 읽기
- XML에 넣은 스프링 빈 정보 반환
- 뷰 렌더링

먼저 스프링을 사용하지 않고 직접 타입을 바꾸게 되면

@RestController public class HelloController { @GetMapping("/hello-v1") public String helloV1(HttpServletRequest request){ String data = request.getParameter("data"); //문자타입 조회 Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경 System.out.println("intValue = " + intValue); return "ok"; } }

➡️ HTTP요청 파라미터는 모두 문자로 처리가 되기 때문에 request.getParameter("data")는 문자타입으로 조회 된다.

이번에는 스프링MVC가 제공하는 @RequestParam을 사용해보자

@GetMapping("/hello-v2") public String helloV2(@RequestParam Integer data){ //스프링이 중간에서 타입변환을 해줌 System.out.println("data = " + data); return "ok"; }

➡️ localhost:8080/hello-v2?data=10 에서 10은 숫자가 아니라 문자이다. 스프링이 중간에 숫자를 문자로 타입을 변환해준다.


✔️만약 기본 타입이 아닌 개발자가 새로운 타입을 만들어서 변환하고자 할때는 컨버터 인터페이스를 구현해서 등록하기만 하면 된다.

컨버터 인터페이스

package org.springframework.core.convert.converter; public interface Converter<S, T> { T convert(S source); }


먼저, 이미 구현되어 있겠지만 간단하게 문자를 숫자로 변환하는 타입 컨버터를 만들어보면

public class StringToIntegerConverter implements Converter<String,Integer> { @Override public Integer convert(String source) { return Integer.valueOf(source); } }

반대로 숫자를 문자타입으로 변환하는 타입 컨버터는

public class IntegerToStringConverter implements Converter<Integer,String> { @Override public String convert(Integer source) { return String.valueOf(source); } }






이제 사용자가 새로운 타입을 만들때의 예시를 살펴보자
127.0.0.1:8080 과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들것이다.!

IpPort 클래스는 다음과 같다.

@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)의 결과가 참이 된다.

이제 문자로 온 "127.0.0.1:8080"을 IpPort객체로 바꾸는 타입 컨버터를 만들면

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); } }

반대로 IpPort객체를 문자타입으로 바꾸는 타입 컨버터를 만들면

public class IpPortToStringConverter implements Converter<IpPort,String> { @Override public String convert(IpPort source) { return source.getIp()+":"+source.getPort(); } }

이렇게 하면 된다!!!




하지만 이렇게 하나하나 반대의 경우까지 모두 개발자가 만들기는 쉽지 않다. 따라서 스프링에서는 용도에 따라 다양한 방식의 타입 컨버터를 기본으로 제공해 준다😄

- Converter -> 기본 타입 컨버터
- ConverterFactory -> 전체 클래스 계층 구조가 필요할 때
- GenericConverter -> 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
- ConditionalGenericConverter -> 특정 조건이 참인 경우에만 실행


컨버전 서비스 - 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); }


테스트코드로 컨버전 서비스에 컨버터를 등록하고 사용할 때는 다음과 같다.

public class ConversionServiceTest { @Test void conversionService(){ //등록 DefaultConversionService conversionService = new DefaultConversionService(); conversionService.addConverter(new StringToIpPortConverter()); conversionService.addConverter(new IpPortToStringConverter()); conversionService.addConverter(new IntegerToStringConverter()); conversionService.addConverter(new StringToIntegerConverter()); //사용 assertThat(conversionService.convert("10",Integer.class)).isEqualTo(10); assertThat(conversionService.convert(10,String.class)).isEqualTo("10"); assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).isEqualTo(new IpPort("127.0.0.1",8080)); assertThat(conversionService.convert(new IpPort("127.0.0.1",8080),String.class)).isEqualTo("127.0.0.1:8080"); } }

➡️ 등록할 때는 conversionService.addConverter을 사용하면 되고
사용 할때는 conversionService.conver(바꾸고자 하는 객체, 바꾸고자 하는 타입)을 이용하면 된다.


✔️인터페이스 분리 원칙 (ISP)

여기서 중요한 개념이 숨어있다. DefaultConversionService는
ConversionService(컨버터 사용에 초점), ConverterRegistry(컨버터 등록에 초점) 이 두 인터페이스를 구현한다.
이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트는 컨버터 사용에만 집중하면 되고 어떻게 등록하고 이를 관리하는지는 전혀 몰라도 된다. 결국 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다!!


웹 애플리케이션에 Converter 적용!


위에서 만들어 놓은 컨버터들을 다음과 같이 등록한다.

@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()); } }

이렇게 하면 스프링 내부에서는 ConversionService에 컨버터를 추가한다.

@RequestParam을 통해 컨버터가 작동하는지 보자

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

실행해보면 잘 동작한다...ㅎㅎㅎ

➡️ @RequestParam은 @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver에서 ConversionService를 사용해서 타입을 변환하게 된다

뷰 템플릿에서 컨버터가 작동하는지 보자

컨트롤러에서 model에 객체를 담아서 뷰로 보내게 되면, 뷰에서 객체를 문자타입으로 잘 변환해서 렌더링 해준다.

- 컨트롤러

@Controller public class ConverterController { @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"; } }


- 뷰

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body> <ul>
    <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>
</ul>
</body>
</html>

-실행결과


➡️ 타임리프는 ${{ }}를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다!

폼에 적용해보자

폼 객체는 데이터를 전달하는 용도로 사용한다.

-컨트롤러

@Controller
public class ConverterController {

    @GetMapping("/converter/edit")
    public String converterForm(Model model){
        IpPort ipPort = new IpPort("127.0.0.1", 8080);
        Form form = new Form(ipPort);
        model.addAttribute("form", form);
        return "converter-form";
    }

    @PostMapping("/converter/edit")
    public String converterEdit(@ModelAttribute Form form, Model model){
        IpPort ipPort = form.getIpPort();
        model.addAttribute("ipPort",ipPort);
        return "converter-view";
    }

    @Data
    static class Form {
        private IpPort ipPort;

        public Form(IpPort ipPort) {
            this.ipPort = ipPort;
        }
    }

}


- 뷰

<!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">
    th:field <input type="text" th:field="*{ipPort}"><br/>
    th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/> <input type="submit"/>
</form>
</body>
</html>

➡️ 타임리프는 th:field가 자동으로 컨버전 서비스를 적용해준다!! ${{ ipPort }}처럼 적용.


포맷터 - Formatter

일반적으로 개발자는 문자에서 다른 타입으로, 어떤 타입에서 문자로 변환하는 상황을 대부분 마주친다.

예를들어 숫자 1000을 "1,000"처럼 쉼표를 넣어서 문자로 출력하거나 "1,000"을 숫자 1000으로 변경해야 한다면?
날짜객체를 "2021-01-01 10:50:11"처럼 날짜를 깔끔히 문자로 출력해야 하거나 "2021-01-01 10:50:11"을 날짜 객체로 변경해야 한다면?

✔️여기서 포맷터가 등장한다. 객체를 특정한 포맷에 맞춰 문자로 출력하거나 특정한 포맷에 맞춰져 있는 문자를 객체로 바꾸는 기능을 제공하는 것이 바로 포맷터이다. 포맷터는 컨버터의 문자의 특화된 특별한 버전으로 이해하면 쉽다.

포맷터 인터페이스

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> { }

➡️ print는 객체를 문자로 변경
➡️ parse는 문자를 객체로 변경


숫자 1000을 문자 "1,000"으로 바꿔주고 그 반대도 만드는 포맷터를 만들어 보자

public class MyNumberFormatter implements Formatter<Number> { @Override public Number parse(String text, Locale locale) throws ParseException { //"1,000" ->1000 NumberFormat format = NumberFormat.getInstance(locale); Number number = format.parse(text); return number; } @Override public String print(Number number, Locale locale) { //1,000->"1000" NumberFormat format = NumberFormat.getInstance(locale); String text = format.format(number); return text; } }

➡️ 자바가 제공하는 NumberFormat객체를 이용하면 local정보를 활용해 나라별로 다른 숫자 포맷을 만들어 준다.

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

FormattingConversionService가 포맷터를 지원하는 컨버전 서비스이다.
이는 ConversionService 관련 기능을 상속받기 때문에 컨버터와 포맷터 둘 다 등록할 수 있다. 그리고 포맷터를 사용할 때 마찬가지로 convert를 사용하면된다.

다음은 FormattingConversionService를 사용한 테스트 코드이다.

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));
        //포맷터 사용
        Long number = conversionService.convert("1,000", Long.class);
        assertThat(number).isEqualTo(1000L);

    }
}


웹 애플리케이션에 포맷터을 적용

우선 포맷터를 등록해야한다.

@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());
    }
}

➡️ 컨버터가 포맷터보다 우선순위가 높으므로 잠깐 컨버터등록한 것을 주석처리 해주었다.

이를 실행해보면 문자에서 숫자로 숫자에서 문자로 잘 타입이 바뀌어서 정상동작한다.


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

스프링은 자바에서 기본으로 제공하는 타입들에 대해 다양한 포맷터를 기본으로 제공해준다.
기본형식이 지정되어 있다보니 한 객체에 대한 다른 포맷을 지정하고 싶을때에 곤란해진다.
이를 해결하기 위해서 스프링은 애노테이션 기반으로 포맷을 지정할 수 있게 해준다.

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


다음과 같이 활용한다!

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


끄읕!!


(참고 : 인프런 김영한 강사님 - 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 )