개발놀이터

스프링 타입컨버터 본문

Spring/Spring

스프링 타입컨버터

마늘냄새폴폴 2021. 11. 13. 23:00

본 포스트는 김영한님의 인프런강의 스프링 MVC 2편 - 백엔드 웹 개발 활용 기술을 보고 정리한 포스트입니다. 자세한 내용은 강의를 참고해주세요.

*스프링 타입 컨버터

문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것 처럼 애플리케이션을 개발하다 보면 타입을 변환해야 하는 경우가 상당히 많다.

스프링을 사용하지 않는다면 보통의 경우 WrapperClass의 valueOf (String.valueOf, Integer.valueOf 등등) 메소드를 사용하거나 WrapperClass의 pasrseSomething (parseInt, parseDouble 등등) 메소드를 사용하는 것이 대부분일 것이다. 하지만 이렇게 일일이 타입을 변환해주는 것은 매우 번거로운 일이다. 혹은 단순히 타입 변환이 아니라 객체 -> 객체 변환을 사용하고 싶을 때가 있을 것이다. 이 때 사용하는 것이 바로 스프링의 타입 컨버터이다. 

앞으로 소개하는 타입 컨버터는 밑바닥부터 천천히 풀어내는 내용이 담겨있다. 따라서 점진적으로 발전하는 코드를 볼 수 있을 것이다. 

스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다. 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X -> Y타입으로 변환하는 컨버터 인터페이스를 만들고 또 Y -> X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다. 예를 들어서 문자로 "true"가 왔을 때 Boolean타입으로 받고 싶으면 String -> Boolean타입으로 변환되도록 컨버터 인터페이스를 만들어서 등록하면 된다.


*타입 컨버터 - Converter
타입 컨버터를 어떻게 사용하는지 코드로 알아보자

타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다.

Converter 인터페이스



먼저 가장 단순한 형태인 문자를 숫자로 바꾸는 타입 컨버터를 만들어보자

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



Converter의 제네릭에 들어가는 <S, T> 는 S타입의 소스가 T타입으로 변환되는 것을 의미한다. 위의 예제에서는 String 타입이 Integer타입으로 변환되는 것을 의미한다.

String -> Integer로 변환하기 때문에 소스가 String이 된다. 이 문자를 Integer.valueOf(source)를 사용해서 숫자로 변경한 다음에 변경된 숫자르 반환하면 된다.

위의 예제를 토대로 반대의 경우도 만들 수 있을 것이다. 

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



이런 단순한 타입 변환 말고 객체를 또다른 객체로 변환하는 사용자 정의 타입 컨버터에 대해서 알아보자

127.0.0.1:8080과 같은 IP와 Port를 입력하면 IP, Port 둘로 나눠지게 되는 컨버터를 만들어보자

@Getter
@EqualsAndHashCode
public class IpPort {

    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}



cf) @EqualsAndHashCode는 모든 필드를 사용해서 equals()와 hashcode()를 생성한다. 따라서 모든 필드의 값이 같다면 a.equals(b)의 결과가 참이 된다.

@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
    @Override
    public IpPort convert(String source) {
        log.info("convert source={}", source);
        //"127.0.0.1:8080" -> IP = "127.0.0.1", Port = 8080
        String[] split = source.split(":");
        String ip = split[0];
        int port = Integer.parseInt(split[1]);
        return new IpPort(ip, port);
    }
}



위의 예제는 String 타입으로 "127.0.0.1:8080"이 들어오게 되면 IpPort객체로 바뀌게 되는 컨버터이다.

위의 예제를 토대로 반대의 경우도 만들 수 있을 것이다.

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



위의 예제를 테스트해볼 테스트 코드를 작성해보자

    @Test
    void StringToIpPort() {
        IpPortToStringConverter converter = new IpPortToStringConverter();
        IpPort source = new IpPort("127.0.0.1", 8080);
        String result = converter.convert(source);
        assertThat(result).isEqualTo("127.0.0.1:8080");
    }

    @Test
    void IpPortToString() {
        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));
    }



잘 동작하는 것을 볼 수 있다. 그런데 이렇게 타입 컨버터를 하나하나 직접 사용하면, 개발자가 직접 컨버팅 하는 것과 크게 차이가 없다. 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다.


*컨버전 서비스 - ConversionService

이렇게 타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것이 매우 불편하다. 그래서 스프링은 개별 컨버터를 모두 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스이다.

    @Test
    void ConversionService() {
        //등록
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        //사용
        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");
    }



DefaultConversionService는 conversionService 인터페이스를 구현햇는데, 추가로 컨버터를 등록하는 기능도 제공한다.

컨버터를 등록할 때는 StringToIntegerConverter같은 타입 컨버터를 명확하게 알아야한다. 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다. 따라서 타입을 변환하는 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.


*스프링에 Converter 적용하기

웹 애플리케이션에 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를 제공한다. 우리는 WebMvcConfigurer가 제공하는 addFormatters() 를 사용해서 추가하고 싶은 컨버터를 등록하면 된다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService에 컨버터를 추가해준다.

이제 잘 작동하는지 테스트 해보자

    @GetMapping("/hello-v2")
    public String helloV2(@RequestParam Integer data) {
        System.out.println("data = " + data);
        return "ok";
    }



?data=10 의 쿼리 파라미터는 문자이고 이것을 Integer data로 변환하는 과정이 필요하다. 실행해보면 직접 등록한 StringToIntegerConverter가 작동하는 로그를 확인할 수 있다.

그런데 생각해보면 StringToIntegerConverter를 등록하기 전에도 이 코드는 잘 수행되었다. 그것은 스프링이 내부에서 수 많은 기본 컨버터들을 제공하기 때문이다. 컨버터를 추가하면 추가한 컨버터가 기본 컨버터보다 높은 우선순위를 가진다.

이번에는 직접 정의한 타입인 IpPort를 사용해보자

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



실행
http://localhost:8080/ip-port?ipPort=127.0.0.1:8080

실행로그

실행결과




*뷰 템플릿에 컨버터 적용하기
이번에는 뷰 템플릿에 컨버터를 적용하는 방법을 알아보자. 타임리프는 랜더링 시에 컨버터를 적용해서 랜더링하는 방법을 편리하게 지원한다. 이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있다.

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



타임리프는 ${{...}}를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다. 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로 우리가 등록한 컨버터들을 사용할 수 있다.

변수 표현식 : ${...}
컨버전 서비스 적용 : ${{...}}

${{number}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 Integer타입인 10000을 String타입으로 변환하는 컨버터인 IntegerToStringConverter를 실행하게 된다. 이 부분은 컨버터를 실행하지 않아도 타임리프가 숫자를 문자로 자동으로 변환하기 때문에 컨버터를 적용할때와 하지 않을 때가 같다.

${{ipPort}} : 뷰 템플릿은 데이터를 문자로 출력한다. 따라서 컨버터를 적용하게 되면 IpPort타입을 String타입으로 변환해야 하므로 IpPortToStringConverter가 적용된다. 그 결과 127.0.0.1:8080가 출력된다.


*포맷터 - Formatter

Converter는 입력과 출력 타입에 제한이 없는 범용 타입 변환 기능을 제공한다. 이번에는 일반적인 웹 애플리케이션 환경을 생각해보자. 불린 타입을 숫자로 바꾸는 것 같은 범용 기능 보다는 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 더 일반적이다. 앞서 살펴본 예제들은 떠올려 보면 문자를 다른 객체로 변환하거나 객체를 문자로 변환하는 일이 대부분이다. 

-웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 예
1. 화면에 숫자를 출력해야 하는데, Integer -> String 출력 시점에 숫자 1000 -> 문자 "1,000" 이렇게 1000단위에 쉼포를 넣어서 출력하거나 또는 "1,000"이라는 문자를 1000이라는 숫자로 변경해야 한다. 
2. 날짜 객체를 문자인 "2021-01-01 10:50:11" 와 같이 출력하거나 또는 그 반대의 상황

이렇게 객체를 특정한 포맷에 맞추어 문자를 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.

Converter vs Formatter
Converter : 범용 (객체 -> 객체)
Formatter : 문자에 특화 (문자 -> 객체, 객체 -> 문자)

포맷터 만들기
포맷터는 객체를 문자로 변경하고, 문자를 객체로 변경하는두 가지 기능을 모두 수행한다.

Formatter 인터페이스

Formatter 인터페이스



숫자 1000을 문자 "1,000"으로 그러니까 1000단위로 쉼표가 들어가는 포맷을 적용해보자. 그리고 그 반대도 처리해주는 포맷터를 만들어보자

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text={}, locale={}", text, locale);
        //"1,000" -> 1000
        NumberFormat format = NumberFormat.getInstance(locale);
        return format.parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        log.info("object={}, locale={}", object, locale);
        return NumberFormat.getInstance(locale).format(object);
    }
}



"1,000" 처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.

parse() 를 사용해서 문자를 숫자로 변환한다. 참고로 Number 타입은 Integer , Long 과 같은 숫자 타입의 부모 클래스이다.


*포맷터를 지원하는 컨버전 서비스
컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수는 없다. 그런데 생각해보면 포맷터는 객체 -> 문자, 문자 -> 객체로 변환하는 특별한 컨버터일 뿐이다.

포맷터를 지우너하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter가 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());

        //추가
        registry.addFormatter(new MyNumberFormatter());
    }
}



주의!
StringToIntegerConverter, IntegerToStringConverter를 꼭 주석처리 하자. MyNumberFormatter도 숫자 -> 문자, 문자 -> 숫자로 변경하기 때문에 둘의 기능이 겹친다. 우선순위는 컨버터가 우선하므로 포맷터가 적용되지 않고 컨버터가 적용된다.

실행해보면 잘 실행되는 것을 볼 수 있다.

이제 다음으로 스프링이 제공하는 기본 포맷터를 알아보자


*스프링이 제공하는 기본 포맷터
스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다. IDE에서 Formatter인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.

그런데 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.

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

예제를 통해 확인해보자

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



실행해보면 지정한 포맷으로 출력된 것을 확인할 수 있다.

'Spring > Spring' 카테고리의 다른 글

객체지향 설계 5원칙 SOLID  (0) 2021.12.20
타임리프 classappend  (0) 2021.12.14
스프링 API 예외처리  (0) 2021.11.12
스프링 예외처리, 오류페이지  (0) 2021.11.11
스프링 필터, 인터셉터  (0) 2021.11.09