본문 바로가기
Spring

[Spring+Thymeleaf] Spring Converter 및 Formatter 정리

by 개미가되고싶은사람 2024. 7. 11.

목차

    코드를 작성하다 보면 형 변환을 하는 경우가 엄청 많습니다. Spring TypeConverter는 다양한 데이터 타입 간의 변환을 쉽게 할 수 있게 해주는 중요한 기능입니다. 이번 포스팅은 Spring TypeConverter에 대해서 알아보겠습니다.!!!!

     

     

     

     

    Spring TypeConverter란?

    Spring TypeConverter는 다양한 데이터 타입 간의 변환을 지원하는 인터페이스입니다. 이를 통해  데이터 타입 변환을 쉽게 처리할 수 있습니다. 예를 들어, 문자열을 숫자로 변환하거나, 날짜 형식을 변환하는 등의 작업을 쉽게 수행할 수 있습니다.

     

    개념 및 정의

    Spring TypeConverter는  주로 두 가지 주요 인터페이스를 사용합니다.

     

    1. Converter<S, T>: S 타입을 T 타입으로 변환합니다.
    2. GenericConverter: 복잡한 작업을 지원 및 애노테이션 정보 사용이 가능합니다.

     

    역할 및 기능

    Spring TypeConverter의 주요 역할은 데이터 타입 변환입니다. 이를 통해 다음과 같은 기능을 제공합니다.

     

    1. 간단한 데이터 타입 변환: 문자열을 숫자, 날짜 등으로 변환
    2. 복잡한 데이터 타입 변환: 사용자 정의 객체 간의 변환
    3. 자동 변환: Spring 프레임워크 내부에서 자동으로 변환을 처리

     

    자동 형 변환 예시

        @GetMapping("/example-v1")
        public String exampleV1(HttpServletRequest request) {
            String data = request.getParameter("data");
            Integer intValue = Integer.valueOf(data);
            System.out.println("intValue = " + intValue);
    
            return "ok";
        }
    
        @GetMapping("/example-v2")
        public String exampleV2(@RequestParam Integer data) {
            System.out.println("data = " + data);
            return "ok";
        }

    쿼리 스트팅으로 10을 전달한다고 가정하면 10은 숫자가 아니고 문자'10' 입니다. 이것을 @RequestParam 을 사용하면 이 문자 10을 Integer 타입의 숫자 10으로 편리하게 받을 수 있습니다. 이것은 스프링 Converter가 중간에서 형 변환을 해주었기 때문입니다. 

     

     

     

    Spring TypeConverter의 장점

    1. 코드의 간결성: 복잡한 변환 로직을 단순화
    2. 재사용성: 한 번 작성한 변환기를 여러 곳에서 재사용 가능
    3. 유지보수성: 변환 로직을 중앙에서 관리하여 유지보수가 용이

     

     

    ConversionService란?

    ConversionService는 Converter보다 더 광범위한 작업을 처리할 수 있으며, 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데 이것이 바로 ConversionService입니다.

     

     

    ConversionService의 장점

    Converter 예시

        @Test
        void StringToInteger() {
            StringToIntegerConverter stringToIntegerConverter = new StringToIntegerConverter();
            Integer result = stringToIntegerConverter.convert("10");
            assertThat(result).isEqualTo(10);
        }
    
    
        @Test
        void IntegerToString() {
            IntegerToStringConverter integerToStringConverter = new IntegerToStringConverter();
            String result = integerToStringConverter.convert(10);
            assertThat(result).isEqualTo("10");
        }
    
        @Test
        void StringToIpPort() {
            StringToIpPortConverter stringToIpPortConverter = new StringToIpPortConverter();
            IpPort result = stringToIpPortConverter.convert("123.0.0.1:8080");
            assertThat(result).isEqualTo(new IpPort("123.0.0.1", 8080));
            // 객체를 비교하는건데 왜 ture가 나오나면 @EqualsAndHashCode를 사용했기 때문에
        }
    
        @Test
        void IpPortToString() {
            IpPortToStringConverter ipPortToStringConverter = new IpPortToStringConverter();
            String result = ipPortToStringConverter.convert(new IpPort("123.0.0.1", 8080));
            assertThat(result).isEqualTo("123.0.0.1:8080");
        }

     

    ConversionService 예시

        @Test
        void conversionService() {
    
            DefaultConversionService conversionService = new DefaultConversionService();
    
            // 등록
            conversionService.addConverter(new StringToIntegerConverter());
            conversionService.addConverter(new IntegerToStringConverter());
            conversionService.addConverter(new IpPortToStringConverter());
            conversionService.addConverter(new StringToIpPortConverter());
    
            // 사용
            Integer num = conversionService.convert("10", Integer.class);
            String str = conversionService.convert(10, String.class);
            IpPort ipPort = conversionService.convert("123.0.0.1:8080", IpPort.class);
            String ipPortStr = conversionService.convert(new IpPort("123.0.0.1", 8080), String.class);
    
    
            // 검증
            assertThat(num).isEqualTo(10);
            assertThat(str).isEqualTo("10");
            assertThat(ipPort).isEqualTo(new IpPort("123.0.0.1", 8080)); // 객체를 비교하는건데 왜 ture가 나오나면 @EqualsAndHashCode때문에
            assertThat(ipPortStr).isEqualTo("123.0.0.1:8080");
        }

    첫번째 예시처럼 하나하나 직접 Converter를 찾아서 형 변환하는 것은 우리가 평소에 형 변환하는 것과 크게 차이는 없습니다. 하지만 ConversionService를 사용하면 Spring에 Converter를 한 번에 등록하고 어떤 Converter를 사용해야 하는지 찾거나 고민하지 않고 형 변환를 할 수 있습니다.

     

    ConversionService 사용 예시

    @Getter
    @EqualsAndHashCode
    public class IpPort {
    
        private String ip;
        private int port;
    
        public IpPort(String ip, int port) {
            this.ip = ip;
            this.port = port;
        }
    }
    @Slf4j
    public class StringToIpPortConverter implements Converter<String, IpPort> {
    
        @Override
        public IpPort convert(String source) {
            log.info("StringToIpPortConverter value = {}", source);
            String[] split = source.split(":");
            String ip = split[0];
            int port = Integer.parseInt(split[1]);
            return new IpPort(ip, port);
        }
    }
    
    @Slf4j
    public class IpPortToStringConverter implements Converter<IpPort, String> {
        @Override
        public String convert(IpPort source) {
            String value = source.getIp() + ":" + source.getPort();
            log.info("IpPortToStringConverter value = {}", value);
            return value;
        }
    }

     

    @Configuration
    public class WebConfig implements WebMvcConfigurer {
    
        @Override
        public void addFormatters(FormatterRegistry registry) {
            registry.addConverter(new IpPortToStringConverter());
            registry.addConverter(new StringToIpPortConverter());
        }
    }
        @GetMapping("/example-v3")
        public String ipPort(@RequestParam(name = "data") IpPort ipPort) {
            return "ok";
        }

     

    ConversionService 사용 로그

    ConversionService를 사용하면 스프링 내부에서 컨트롤러 호출하기 전에 ConversionService를 이용해 형 변환을 진행하고 해당 컨트롤러를 호출할 때 그 값을 넘겨줍니다.

     

     

    HTML(Thymeleaf)에 Converter 적용

     

    Controller

        @GetMapping("/example-v4")
        public String converterView(Model model) {
            model.addAttribute("number", 1000);
            model.addAttribute("ipPort", new IpPort("123.0.0.1", 8080));
            return "converter-view";
        }

     

    HTML

    <!DOCTYPE html>
    <html xmlns:th="http://www.thymeleaf.org" lang="">
    <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>

     

    결과

    • ${xxxx} : 일반적인 Thymeleaf 표현식으로, 변수나 속성 값을 직접 삽입합니다. 이 표현식은 Spring Converter를 사용하지 않으며, 값을 있는 그대로 출력합니다.

     

    • ${{xxxx}} :  ${} 표현식이랑 차이점이 있다면 자동으로 ConverterSerivice를 사용해서 변환된 결과를 출력해줍니다. 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다

     

     

    Formatter - 포맷터

    문자와 객체 간의 변환을 처리하기 위해 사용되는 인터페이스입니다. Formatter는 두 가지 주요 메서드인 parse와 print를 정의하여 문자열을 객체로 변환하고 객체를 문자열로 변환합니다. 또한, Locale을 지원하여 다국어 및 지역별 형식을 처리할 수 있습니다.

     

    String print(T object, Locale locale) : 객체 -> 문자 변경
    T parse(String text, Locale locale) : 문자 -> 객체 변경

     

     

     

    Formatter 구현 예시

    @Slf4j
    public class NumberFormatter implements Formatter<Number> {
    
        @Override
        public Number parse(String text, Locale locale) throws ParseException {
            log.info("text={}, locale={}", text, locale);
            NumberFormat format = NumberFormat.getInstance(locale);
    
            return format.parse(text);
        }
    
        @Override
        public String print(Number object, Locale locale) {
            log.info("object={}, locale={}", object, locale);
            NumberFormat instance = NumberFormat.getInstance(locale);
            
            return instance.format(object);
        }
    }
    class NumberFormatterTest {
    
        NumberFormatter formatter = new NumberFormatter();
    
        @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");
        }
    }

    포맷터는 기본적으로 객체를 문자로 변경 및 문자를 객체로 변경하는 기능들을 모두 수행하므로, print랑 parse를 모두 구현해야 합니다.

     

    Formatter를 ConversionService에 등록

    Formatter를 ConversionService에 등록하면, 그림과 같은 오류가 발생할 수 있습니다. 앞서 언급했듯이, Formatter는 문자열과 객체 간의 변환을 처리하는 인터페이스로, 일종의 Converter라고 생각할 수 있습니다. Formatter를 지원하는 ConversionService를 사용하면, Formatter를 ConversionService에 추가할 수 있습니다. 내부적으로는 어댑터 패턴을 사용하여 Formatter가 Converter처럼 동작하도록 합니다.
     
     
     

     

    Formatter를 ConversionService등록 구현체

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

     

    FormattingConversionService는 Formatter를 지원하는 ConversionService이며, DefaultFormattingConversionService는 FormattingConversionService의 상의 인터페이스이며,  여러 기능이 추가되어 제공됩니다. FormattingConversionService는 ConversionService의 기능을 상속받아 Converter와 Formatter를 모두 등록할 수 있습니다.
     
     
     
     
     

    Formatter와 애노테이션으로 다양한 데이터 형식 지정 

     Formatter는 기본 형식이 지정되어 있어 객체의 각 필드마다 다른 형식을 지정하기 어렵습니다. 이런 문제점을 스프링에서 제공하는 애노테이션으로 해결할 수 있습니다.
     
     
    사용 예시
        @GetMapping("/example-v6")
        public String formatterForm(Model model) {
            Form form = new Form();
            form.setNumber(100000000);
            form.setLocalDateTime(LocalDateTime.now());
            model.addAttribute("form", form);
    
            return "formatter-form";
        }
    
        @PostMapping("/example-v6")
        public String formatterEdit(@ModelAttribute Form form) {
            return "formatter-view";
        }
    
    
    
        @Getter
        @Setter
        @NoArgsConstructor
        @AllArgsConstructor
        static class Form {
    
            @NumberFormat(pattern = "###,###")
            private Integer number;
    
            @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
            private LocalDateTime localDateTime;
        }
     

    결과

     
     

     

     

     

     

     

    참고자료

    https://docs.spring.io/spring-framework/reference/core/validation/format.html#format-CustomFormatAnnotations

    https://docs.spring.io/spring-framework/reference/web/webflux/controller/ann-methods/typeconversion.html#page-title

    https://docs.spring.io/spring-framework/reference/core/validation/convert.html