Spring MVC : Converter, Formatter 알아보기

    이 글은 인프런 김영한님의 강의를 복습하며 정리한 글입니다. 

     

    스프링 타입 컨버터와 포멧터


    기본적으로 웹 서버에서 클라이언트로 전달되는 모든 값들을 String 타입이다. 그렇기 때문에 String 타입이 서버로 들어와서 적절하게 사용이 되기 위해서는 적절한 형태의 타입변경이 필요하다. 또한  View Template에 표시될 때도 모든 값은 String이다. 이 때도 적절한 형태의 타입 변경이 필요하다. 

    서버와 웹이 통신을 할 때는 적절하게 타입이 변경되어야 한다는 이야기다. 스프링은 내부적으로 "Type Converter"나 "Formatter" 같은 것들을 이용해서 개발자의 개발 편의를 돕고 있다. Paramter가 Binding되거나 Paramter가 View Templated에 랜더링 되는 시점에 적절한 Type Converter, Formatter가 사용된다. 

     

    스프링 타입 컨버터, 포멧터의 적용 시점 정확히 알기


    컨버터(포멧터)는 앞서 이야기 한 것처럼 요청 파라미터의 Binding 시점에 동작한다. 이 때 Paramter는 Argument Resolver가 Binding을 해서 Adapater에 넘겨준다. 이 때 ArgumentResolver는 내부적으로 등록된 컨버터(포멧터)를 사용해서 값을 바인딩해서 넘겨준다. 

    이 때 주의해야 할 점이 있다. Converter / Formatter는 HTTP API에는 동작하지 않는다. HTTP API는 ArgumentResolver에서 미디어 타입과 요청 클래스 타입을 고려한 다음, 적절한 HttpMessageConverter를 사용해서 변수를 만들어서 Adapter로 돌려준다. HttpMessageConverter는 내부적으로 JackSon 라이브러리를 사용해서 값을 작성해낸다. 

     

    스프링 타입 컨버터, 포멧터가 사용되는 곳


    • @RequestParam
    • @ModelAttribute
    • @PathVariable
    • ViewTemplate : {{...}} 형태, th:field = 형태

    기본적으로 스프링 타입 컨버터와 포멧터는 위 상태에서 사용이 된다.

     

     

    스프링 타입 컨버터와 포멧터 알아보기


    스프링 타입 컨버터

    스프링 타입 컨버터는 시작 값과 변경될 값을 모두 개발자가 지원할 수 있는 형태의 Interface다. 예를 들어 Integer → String으로 바꿀 수도 있고, String → Boolean 타입으로 바꿀 수도 있다. 

    타입 컨버터는 Converter Interface를 직접 구현해서 사용하면 된다. Interface에 있는 S와 T는 각각 시작 타입, 변경될 타입이다. 이것을 참고해서 convert 메서드를 오버라이드 한 후, 등록해서 사용을 하면 된다. 

     

    스프링 포멧터

    그런데 생각해보면 개발자는 대부분 String의 값을 어떤 형태로 바꾸는 일을 하고 있다. 왜냐하면 주로 서버와 통신을 하면서 값이 바인딩 되는데, 이 때 들어오는 값은 모두 String이기 때문이다. 이처럼 String에 대한 Type 변경을 해주는 특화된 좁은 의미의 컨버터가 Formatter다. 

    Formatter Interface를 개발자가 구현해서 등록해서 사용하면 된다. 앞서 말한 것처럼 Formatter는 String을 기준으로 값을 변경하는 형태이기 때문에 변경할 값 타입 하나만 더 등록하면 된다.

    이 때 ISP(Ineterface Seperation Principle)에 따라서 Formatter Interface는 Printer와 Parser를 모두 구현을 해야한다. 이렇게 하는 이유는 이것을 사용하는 쪽은 "String ↔ 특정 타입"으로 의존성을 가지지 않고 사용하기 위해서다. 단, 이 포멧터를 구현하고 등록하는 사람은 당연히 이 인터페이스에 의존하게 된다. 

    또한 포멧터는 Locale을 가지도록 인터페이스 차원에서 강제된다.  즉, 나라에 대한 값을 넣으면 그 값을 고려해서 원하는 형태로 값을 변경해줄 수 있다는 점이다. 

     

    스프링 타입 컨버터와 포멧터를 조금 더 쉽게 사용하는 방법


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

    기본적으로 스프링 타입 컨버터와 포멧터를 사용하기 위해서는 가져와서 사용을 해야한다. 그렇다면 여기서 "매번 내가 구현한 컨버터와 포멧터를 가져와서 써야하면, 그냥 쓰는게 낫지 않나?"라는 의문이 들 것이다. 맞다. 지금은 다를 바가 없다.

    개발자들의 번거로움을 개선해주기 위해 ConversionService라는 것이 지원된다. ConversionService는 컨버터를 모아두고 그것을 묶어서 편리하게 사용할 수 있는 기능을 지원한다. 앞으로는 ConversionService에 Converter와 Formatter를 등록해서 사용하면 된다. 

     

    스프링 MVC가 제공하는 ConversionService 사용하기


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

    내가 만든 컨버터와 포멧터를 사용하는 가장 쉬운 방법은 Spring MVC가 제공하는 ConversionService에 컨버터와 포멧터를 등록하는 것이다. Spring MVC는 WebMvcConfiguration을 상속받은 설정 정보를 스프링 빈으로 등록한다. 그리고 이 설정정보를 읽어 자동으로 컨버터와 포멧터를 등록해준다. 

     

    스프링 MVC 컨버터와 포멧터의 우선순위


    1.  직접 등록한 컨버터
    2.  자동 등록된 컨버터
    3.  직접 등록한 포멧터
    4.  자동 등록한 포멧터

    당연한 이야기지만 컨버터와 포멧터는 유사한 영역을 바라보고 있다. 왜냐하면 포멧터가 String 타입을 기준으로 동작을 하기 때문이다. 따라서, 등록한 컨버터와 포멧터는 위의 우선순위를 고려해서 동작한다. 기본적으로 컨버터의 우선순위가 포멧터보다 높다.

     

    컨버터와 포멧터는 타임리프에서도 사용될 수 있다! 


    컨버터와 포멧터는 타임리프에서도 사용할 수 있다. 기본적으로 우리는 변수 타입을 ${...} 형태로 사용한다. 이것은 전달된 변수 자체를 String으로 출력하는 것이다. 따라서 객체를 출력하면, 객체가 그대로 출력된다. 

    타임리프는 크게는 ${{...}}를 이용해서 뷰 랜더링 시점에 Spring MVC에 등록된 컨버터 + 포멧터를 사용해서 결과를 렌더링하게 해준다. 뿐만 아니라 "th:field = *{}" 형태로 코드를 작성해두면 자동으로 필요한 컨버터를 찾아서 convert된 결과가 출력된다. 

     

     

    스프링이 지원하는 어노테이션 기반 포멧터


    • @NumberFormat(pattern = "###.###")
    • @DateTimeFormat(patern = "yyyy-MM-dd HH:mm:ss")

    아래에서 살펴볼 예정이지만, 포멧터를 직접 구현하는 것은 꽤 번거로운 일이다. 따라서 스프링은 위 2가지 포멧터를 제공한다. 위 포멧터를 적용될 변수에 어노테이션으로 달아주면 값이 바인딩 되는 시점에 자동으로 포멧터가 적용된다. 

     

    코드로 하나씩 알아보기


    STEP1. 요청 파라미터가 변환되는 것을 확인하기.

    @RestController
    @Slf4j
    public class HelloController {
    
        @GetMapping("/hello-v1")
        public String helloV1(HttpServletRequest request) {
    
            String data = request.getParameter("data");
            Integer intData = Integer.valueOf(data);
            System.out.println("intData = " + intData);
    
            return "ok";
        }
    
        @GetMapping("/hello-v2")
        public String helloV2(@RequestParam Integer data) {
            System.out.println("intData = " + data);
            return "ok";
        }
    
    
        @GetMapping("/hello-v3")
        public String helloV3(@ModelAttribute(name = "data") Integer data) {
            System.out.println("data = " + data);
            return "ok";
        }
    
        @GetMapping("/hello-v3/{data}")
        public String helloV4(@PathVariable Integer data) {
            System.out.println("data = " + data);
            return "ok";
        }
    
    
    }
    • 요청 파라미터를 받는 컨트롤러를 작성했다.
    • @RequestParam, @ModelAttribute, @PathVariable의 형태로 요청 파라미터를 받는다. 

     

    STEP2. 컨버터 직접 작성해보기

    @Slf4j
    public class IntegerToStringConverter implements Converter<Integer,String> {
    
        @Override
        public String convert(Integer source) {
            log.info("IntegerToStringConverter");
    
            return String.valueOf(source);
        }
    @Slf4j
    public class IpPortToStringConverter implements Converter<IpPort,String> {
    
        @Override
        public String convert(IpPort source) {
            log.info("convert source = {}", source);
    
            // IpPort 객체 -> "127.0.0.1:8000"
            return source.getIp() + ":" + source.getPort();
        }
    @Slf4j
    public class StringToIntegerConverter implements Converter<String, Integer> {
    
    
        @Override
        public Integer convert(String source) {
            log.info("Convert source = {}", source);
            return Integer.valueOf(source);
        }
    
    }
    @Slf4j
    public class StringToIpPortConverter implements Converter<String, IpPort> {
    
    
        @Override
        public IpPort convert(String source) {
            log.info("convert source = {}", source);
    
            //127.0.0.1:8080 문자열 들어옴 --> 파싱해야하니 소스를 잘라야함
            String[] split = source.split(":");
            String ip = split[0];
            int port = Integer.parseInt(split[1]);
            return new IpPort(ip, port);
        }
    }
    • 위 4개의 컨버터를 작성했다.
    • 컨버터는 implements Converter<S,T>를 구현했다. 

     

    STEP3. 작성 컨버터의 테스트 코드

    public class ConverterTest {
    
        @Test
        public void test1() {
            IntegerToStringConverter converter = new IntegerToStringConverter();
            String result = converter.convert(1000);
            assertThat(result).isEqualTo("1000");
        }
    
    
        @Test
        public void test2() {
            StringToIntegerConverter converter = new StringToIntegerConverter();
            Integer result = converter.convert("1000");
            assertThat(result).isEqualTo(1000);
        }
    
    
        @Test
        public void test3() {
            IpPortToStringConverter converter = new IpPortToStringConverter();
            String result = converter.convert(new IpPort("127.1.1.0", 8080));
            assertThat(result).isEqualTo("127.1.1.0:8080");
        }
    
        @Test
        public void test4() {
            StringToIpPortConverter converter = new StringToIpPortConverter();
            IpPort result = converter.convert("127.1.1.0:8080");
            assertThat(result).isEqualTo(new IpPort("127.1.1.0", 8080));
        }
    
    
    }
    • 작성한 컨버터를 테스트 코드를 통해서 확인했다.
    • 컨버터를 사용하기 위해서는 컨버터를 생성자를 통해 가져와서 사용해야했다. 

     

    STEP4. Conversion Service 이용한 테스트 코드

    public class ConversionServiceTest {
    
        @Test
        public void test1() {
            DefaultConversionService conversionService = new DefaultConversionService();
    
            conversionService.addConverter(new IntegerToStringConverter());
            conversionService.addConverter(new IpPortToStringConverter());
            conversionService.addConverter(new StringToIpPortConverter());
            conversionService.addConverter(new StringToIntegerConverter());
    
            assertThat(conversionService.convert("1000", Integer.class))
                    .isEqualTo(1000);
    
            assertThat(conversionService.convert(1000, String.class))
                    .isEqualTo("1000");
    
    
            assertThat(conversionService.convert("127.1.1.0:8080", IpPort.class))
                    .isEqualTo(new IpPort("127.1.1.0", 8080));
    
    
            assertThat(conversionService.convert(new IpPort("127.1.1.0", 8080), String.class))
                    .isEqualTo("127.1.1.0:8080");
    
        }
    }
    • Converter를 하나씩 불러와서 사용하는 것은 좋지 않다.
    • Conversion Service에 작성한 컨버터를 등록하고, Conversion.convert(...)를 이용해 값을 변경할 수 있다.

     

    STEP5. 스프링 MVC Conversion Service에 컨버터 등록하기 

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addConverter(new StringToIntegerConverter());
            registry.addConverter(new StringToIpPortConverter());
            registry.addConverter(new IpPortToStringConverter());
            registry.addConverter(new IntegerToStringConverter());
        }
    }
    • WebMvcConfigurer를 구현한 클래스에 addFormatter를 통해 스프링 Conversion Service에 컨버터를 등록할 수 있다.
    • @Configuration으로 해당 설정 정보를 스프링 빈에 등록해준다. 

     

    STEP6. 컨버터 컨트롤러 만들기 

    @Slf4j
    @Controller
    public class ConverterController {
    
        @GetMapping("/converter-view")
        public String converterView(Model model) {
    
            model.addAttribute("number", 10000);
            model.addAttribute("ipPort",new IpPort("127.1.1.0", 8080));
            return "converter-view";
        }
    
        @GetMapping("/converter/edit")
        public String converterForm(Model model) {
    
            Form form = new Form(new IpPort("127.0.0.1", 8080));
            model.addAttribute("form", form);
    
            return "converter-form";
        }
    
        @PostMapping("/converter/edit")
        public String converterEditView(@ModelAttribute Form form, Model model) {
            model.addAttribute("ipPort", form.getIpPort());
            return "converter-view";
        }
    
        @Data
        @AllArgsConstructor
        static class Form {
            private IpPort ipPort;
        }
    }
    • 컨버터 컨트롤러를 만들었다.
    • 컨버터 컨트롤러는 Model에 값을 넣어서 view에서 렌더링 할 수 있는 간단한 형식으로 만들어져 있다. 
    • Form을 컨트롤러에 던지는데, 이 때 ipPort 객체를 넣어서 던진다.
      • View에서 랜더링 될 때, Conversion Service를 이용할 수도 있다.
    • PostMapping으로 다시 받는 시점을 살펴본다
      • Form 객체가 넘어오는데, 이 때 Form에는 ipPort(String 타입)가 포함되어있다.
      • @ModelAttribute가 ArgumentResolver 동작 시점에 StringToIpConverter를 사용해서 IpPort로 바꾸어서 바인딩 해준다. 

     

    STEP7. 컨버터 컨트롤러 뷰 랜더링 확인하기

    http://www.localhost:8080/converter-view

    • ${...}는 객체 자체를 String으로 랜더링 해준다.
    • ${{...}}는 객체를 Conversion Service를 한번 이용해서 String으로 필요한 형태로 바꾼 다음 렌더링 해준다. 

    http://www.localhost:8080/converter/edit (제출 전)

    • th:field는 내부적으로 랜더링 하기 전에 Conversion Service를 한번 사용해준다. 
    • th:value는 ${...}처럼 객체를 바로 String으로 랜더링 해준다. 

    http://www.localhost:8080/converter/edit (제출 후)

    • converter/edit에 제출을 하면서 form 내부에 ipPort 객체가 String 형태로 넘어간다.
    • 컨트롤러는 String 형태의 ipPort를 Converter를 이용해 Form.ipPort에 ipPort형태로 바인딩해준다. 
      • 이 화면으로 넘어올 때 Model에는 ipPort가 있다. 
    • 뷰를 렌더링 하는 시점에 ipPort(IpPort)는 String으로 Conversion되어 랜더링 된다. 

     

    STEP8. 포멧터 만들기

    @Slf4j
    public class MyNumberFormatter implements Formatter<Number> {
    
    
        @Override
        public Number parse(String text, Locale locale) throws ParseException {
            log.info("text = {}", text);
            return NumberFormat.getInstance(locale).parse(text);
        }
    
        @Override
        public String print(Number object, Locale locale) {
            log.info("objectd = {}", object);
            return NumberFormat.getInstance(locale).format(object);
        }
    }
    • Formatter는 Formmater를 implements해서 구현한다.
    • "1,000" ↔ 1000을 변경하는 포멧터를 구현한다. 
    • NumberFormat을 활용해 값을 변경한다. 이 때 locale을 넣어주고, 이 값을 바탕으로 국제적으로 다르게 변경해준다. 

     

    STEP9. 직접 만든 포메터 테스트하기

    @Test
    public void test1() {
    
    
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
        conversionService.addFormatter(new MyNumberFormatter());
    
        assertThat(conversionService.convert(1000L, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    
    
    }
    • 포메터는 DefaultFormattingConversionSevice에 등록해서 사용할 수 있다.
    • 기존 Converter를 사용하는 것과 동일하게 사용해주면 된다. 

     

    STEP10. 스프링 MVC Conversion Service에 포메터 등록하기

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addConverter(new StringToIpPortConverter());
            registry.addConverter(new IpPortToStringConverter());
    
            registry.addFormatter(new MyNumberFormatter());
    
        }
    
    }
    
    • addFormatter 메서드를 이용해 포멧터를 등록할 수 있다.

     

     

    STEP11. Converter 컨트롤러로 포멧터 변경되는 거 보기

    @Slf4j
    @Controller
    public class ConverterController {
    
        @GetMapping("/converter-view")
        public String converterView(Model model) {
    
            model.addAttribute("number", 10000);
            model.addAttribute("ipPort",new IpPort("127.1.1.0", 8080));
            return "converter-view";
        }
    
        @GetMapping("/converter/edit")
        public String converterForm(Model model) {
    
            Form form = new Form(new IpPort("127.0.0.1", 8080));
            model.addAttribute("form", form);
    
            return "converter-form";
        }
    
        @PostMapping("/converter/edit")
        public String converterEditView(@ModelAttribute Form form, Model model) {
            model.addAttribute("ipPort", form.getIpPort());
            return "converter-view";
        }
    
        @Data
        @AllArgsConstructor
        static class Form {
            private IpPort ipPort;
        }
    }

    이전에 등록했던 컨트롤러를 활용해서 다시 포멧터가 정상적으로 동작하는지를 볼 수 있다. 

    • MyNumberFormatter는 "1,000" ↔ 1000 형태로 변경해주는 포멧터다.
    • 따라서 ${{number}}에서 값이 10,000으로 잘 변경된 것을 확인할 수 있다. 

     

    STEP12. 어노테이션 기반 포멧터 사용하기

    @Controller
    public class FormatterController {
    
        @GetMapping("/formatter/edit")
        public String formmaterForm(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 formmaterEdit(@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;
    
        }
    }
    • 어노테이션 기반 포멧터를 다음과 같이 사용할 클래스의 필드 위에 기입해준다.
    • 그리고 패턴을 정해준다. 

     

    STEP13. 어노테이션 기반 포멧터 동작확인하기.

    http://localhost:8080/formatter/edit 제출 전

    • number, localDateTime은 어노테이션 기반 포멧터가 적용이 되었다. 이는 th:filed = ""형태로 값이 설정된 것을 의미한다. 
    • 값은 현재 String 형태로 변경되어있는 상태이다.
    • 이 상태로 제출을 하게 되면 String 형태의 값이 form에 담겨져서 넘어간다. 

    http://localhost:8080/formatter/edit 제출 후

    • 컨트롤러는 String으로 넘어온 값들을 바인딩 하는 시점에 @NumberFormat, @DateTimeFormat 어노테이션을 보고 해당 필드에 포메터를 적용한다.
    • Formatter 적용된 form은 String이 아닌 Integer, LocalDateTime 타입을 가진다.
    • 다시 뷰 렌더링이 되면서 Model에 있는 form은 넘어오는데, 이 때 {{변수}}로 된 곳은 ConversionService에 저장된 어노테이션 포메터가 동작하면서, 위와 같이 랜더링 된다. 

    댓글

    Designed by JB FACTORY