스프링 타입 컨버터

고동현·2024년 5월 28일
0

스프링 MVC

목록 보기
12/13

스프링 타입 컨버터 소개

문자를 숫자로, 숫자를 문자로 변환해야하는 경우가 상당히 많다.

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

그러나 이걸 Integer.valueOf로 변환하는게 귀찮다.

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

@RequestParam을 사용하면 자동적으로 Integer 해당 type으로 캐스팅해준다.

이것처럼 URL경로, 쿼리파라미터, 스프링 MVC요청 파라미터 @RequestParam, @ModelAttribute,@PathVariable등등 이 모든것들은 그냥 문자이다.
이걸 중간에서 스프링이 문자를 해당 타입으로 변환을 해준다.

내가 원하는 type도 변경 가능하다.

컨버터 인터페이스

public interface Converter<S, T> {
 T convert(S source);
 }

필요하다면, Converter 인터페이스를 직접 구현할 수도 있다. 예를들어 문자로 true가 오면 String -> Boolean 타입으로 변환되도록 컨버터 인터페이스를 만들어서 등록하고, 반대로 Boolean타입이 String type으로 변환되도록 컨버터를 추가로 만들어 등록할 수도 있다.

타입 컨버터 - Converter

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

만약에 127.9.9.1:8080과 같은 Ip,Port를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보겠다. 당연히 IpPort객체를 입력하면 IP와 Port가 나오도록 설계해야한다.

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를 사용하면 객체 참조값과는 관계없이 필드에 있는 값들을 비교하여서 전부 동일하면 true 아니면 false를 반환한다.

StringToIpPortConverter

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

어렵지 않게 :로 split하고 앞에게 ip이고 뒤에게 int type portnumber가 될것이다. 그러면 생성자로 생성해서 반환하면도니다.

Converter<A,B>이면 A를 입력해서 B로 만들어야한다는것이다.

IpToStringConverter

@Slf4j
public class IpPortToStringConverter implements Converter<IpPort,String> {

    @Override
    public String convert(IpPort source) {
        log.info("convert source = {}",source);
        return source.getIp() + ":"+source.getPort();
    }
}

간단하게 ipPort 객체가 들어오면 IP랑 Port가져와서 String으로 만들어 반환하면된다.

TestCode

  @Test
    void stringToIpPort(){
        StringToIpPortConverter stringToIpPortConverter = new StringToIpPortConverter();
        String source = "246.342.43.2:8080";
        IpPort result = stringToIpPortConverter.convert(source);
        assertThat(result).isEqualTo(new IpPort("246.342.43.2",8080));
    }

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

그런데 이렇게 되면 문제는 우리가 직접 구현체를 new해서 사용해야한다는점이다. 또한 이 구현체의 메서드 convert를 호출하여서 사용해야한다는 불편한 점도 있다.

뭐 이 Converter를 빈으로 등록해서 AutoWired로 생성자 주입받고 메서드를 호출하는방법도 있지만 이건 너무 불편할 것이다. 그래서 우리는 ConversionService를 사용할 것이다.

ConversionService

앞처럼 타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는것은 매우 불편하다. 그래서 스프링은 개별 컨버터를 모아두고 그것을 묶어서 편리하게 사용할수있는 기능을 제공한다.

ConversionServiceTest

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

이것처럼 DefaultConversionService를 만들어서 addConverter메서드로 등록하고 우리는 convert메서드를 호출해서 컨버팅해주면된다.
그때, 원하는 컨버트 하고싶은 source와 컨버트 되야하는 type을 명시해주면 된다.

참고
인터페이스 분리 원칙 - ISP
인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야한다.
DefaultConversionService는 ConversionService와 COnverterRegistry를 구현했는데

  • ConversionService - 컨버터 사용에 초점
  • ConversionRegistry - 컨버터 등록에 초점

이렇게 인터페이스를 분리하면, 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트를 분리할 수 있다.

특히, 컨버터를 사용하는 클라이언트는 COnversionService에만 의존하면 되므로, 어떻게 등록하고 관리되는지는 몰라도 된다.

결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.(convert 메서드만 알면되지 해당 컨버터를 어떻게 등록해야하는지 알 필요가 없다 이말이다. )
실제로
ConversionService에는

convert가 있고
COnversionRegistry에는

addconverter메서드가 있다.

스프링에 Converter 적용하기

스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다. 예를 들어서 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다.

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

webConfig에 등록을해주고,
컨트롤러에서 확인해보면

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

정상적으로 된다. 실제로 우리가 만든 컨버터인지 아니면 그냥 스프링이 자체적으로 바꾼건지는 우리가 만든 컨버터 즉 자세한것이 우선순위를 가진다.
실제로도 로그가 찍혀있음을 확인 할 수 있다.

뷰 템플릿에 컨버터 적용하기

타임리프는 랜더링 시에 컨버터를 적용해서 랜더링해준다.

@Controller
public class ConverterController {
    @GetMapping("/converter-view")
    public String converterView(Model model){
        model.addAttribute("number",1000);
        model.addAttribute("ipPort",new IpPort("127.0.0.1",8080));
        return "converter-view";
    }
}

이런식으로 model에다가 상수와 객체를 담아서 뷰에 넘겨줬다.

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

일단 뷰이기 때문에 어쨋든, 상수던 객체던 문자열로 바꿔서 출력해줘야한다.
결과를 보면

{{}}=> 두번 감싼거는 컨버터가 적용된것을 볼 수 있다.
숫자 1000은 {}이어도 알아서 컨버팅을 해준다.

이제 폼에 적용을 해보자.

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

순차적으로 하면서 알아보자.
우선 /converter-view로 접속을 하게되면

이렇게 나온다.
로직을 보면 ipPort객체를 만들어서, Form에다가 넣어서 이 Form을 model에 담아서 view에 전달한다.

view로 가보면

<!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를 적용하면 {{}}를 하지 않아도 컨버팅이 된다. 만약에 그게 싫으면 value를 쓰면 되고 이러면 객체를 출력해야되서 ipPort.toString()이런식으로 변환되어 출력된다.

/converter/edit Postmapping도 동일하다
@ModelAttribute로 Form을 받는데 이때 문자 127.0.0.1:8080을 주면 컨버터가 자동으로 적용되어서 IpPort로 변환된다.

포멧터 - Formatter

포메터는 객체->문자, 문자->객체를 바꾸는데 특화된 기능이다. 앞선 Converter는 객체 -> 객체
만약 1000이라는 숫자를 문자로 -> 1,000으로 바꾸거나 문자 1,000-> 숫자 1000으로 바꾸고싶을때 사용될 수 있다.
또는 날짜 객체를 문자인 "2021-01-01 10:50:11"와 같이 출력하거나 그 반대의 상황에서 사용된다.

Locale
여기에 추가로 날짜 숫자의 표현방법에서 Locale 현지화 정보가 사용될수있다.

MyNumberFormatter

@Slf4j
public class MyNumberFormatter 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);
        return NumberFormat.getInstance(locale).format(object);
    }
}

우선 MyNumberFormatter에서 Formatter를 구현하였고, Number로 설정하였다.
그러면 parse와 print가 있는데
parse는 문자가오면 숫자로 바꿔주고 프린트는 숫자가 오면 문자로 바꿔주는것이다.
여기서 우리는 직접 숫자 문자 1,000을 1000으로 바꾸는 로직을 구현하지는 않고, NumberFormat이라는 클래스를 사용해서 만들것이다.
NumberFormat에서 getInstance에다가 현지 locale정보를 넣어서 가져오고 여기서 parse에 인자로 해당 text를 넣으면 된다.

print에서는 format메서드에다가 객체를 넣으면 문자로 출력이 된다. 여기서 객체는 Number 숫자이다.

Test


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를 호출할때 문자를 넘겨주면 숫자가 나온다. parse의 결과가 Long이므로 유의해야한다.
print에서는 숫자 1000을 넘겨주면 문자 1,000이 나온다.

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

DefaultFormattingConversionService는 컨버터와 포멧터 둘다 등록이 가능하다.
앞선 DefaultConversionService에서는 포멧터를 등록하지는 못한다.

public class FormattingConversionServiceTest {
    @Test
    void formattingConversionService(){
        DefaultFormattingConversionService defaultConversionService = new DefaultFormattingConversionService();
        defaultConversionService.addConverter(new StringToIpPortConverter());
        defaultConversionService.addConverter(new IpPortToStringConverter());
        defaultConversionService.addFormatter(new MyNumberFormatter());

        IpPort ipPort = defaultConversionService.convert("127.0.0.1:8080",IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1",8080));

        String n = defaultConversionService.convert(1000,String.class);
        assertThat(n).isEqualTo("1,000");

        Long x = defaultConversionService.convert("1,000",Long.class);
        assertThat(x).isEqualTo(1000L);
    }
}

그러면 생각해보면 우리는 Conversion을 할때 최종적으로 결국 WebConfig에 등록해서 사용하였다.

스프링 부트는 WebConversionService를 내부적으로 사용하는데, WebConversionService는 DefaultFormattingConversionService를 상속받고 있다. 그래서 스프링 부트가 DefaultFormattingConversionService에서 등록해놓은것 까지 다 취합하여서 컨버팅, 포멧팅을 실행한다.

뒤에서 나오겠지만 결국 WebConfig에 등록하거나 DefaultFormattingConversionService에다가 메서드로 등록하거나 등록하는 방식은 여러가지 겠지만, 결국 사용하는것은, WebConversionService에서 일관성있게 사용할 수 있다.




보면 결국 default~여기나 FormatterRegistry에 등록하나 결국 등록되고 WebConversionService에서 사용할 수 있음을 확인 할 수 있다.

포맷터 적용하기

포맷터를 적용해보자

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

        registry.addFormatter(new MyNumberFormatter());
    }
}

앞에 컨버터 등록했던것처럼 addFormatter메서드를 통해서 등록하면된다.

이렇게 되면, 문자를 객체로 바꾸는걸 한번 해보자.
http://localhost:8080/hello-v2?data=10,000 로 요청을 보내면
로그로

포맷팅이제대로 된것을 볼 수 있다.
v2가 뭐였냐면

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

이거 이고 @RequestParam에서 포멧팅을 해줘야하는데, 여기서 등록한 MyNumberFormatter가 적용되어서 실행되었다.

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

결국, 우리는 만들어져 있는 기본 포멧터를 사용하면된다.
경우 1. 숫자 10000을 10,000로 바꾸는법 이미 스프링이 제공하는 기본 포멧터가 있음 -> 그냥 스프링이 제공하는것 사용
경우 2. 스프링이 제공하는 기본 포멧터가 없는경우 ex) IpPort예제, 우리가 직접 만들기

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

우선 /formatter/edit부터 보자 (Get) 일단 Form을 만들어서 10000과 현재 시각을 넣는다.
착각하면 안된느데 Format을 선언했다고 이걸 Integer인데 10,000으로 넣는것은 아니다.
날짜도 마찬가지다.
그다음에 model에 담아서 formatter-form view로 이동한다.

<!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">
    number <input type="text" th:field="*{number}"><br/>
    localDateTime <input type="text" th:field="*{localDateTime}"><br/>
    <input type="submit"/>
</form>
</body>
</html>

여기서 보면 th:field가 있는것을 볼 수 있다. field는 앞에서도 말했지만 포멧팅을 기본으로 해준다고 하였다. {{}}가 아니더라도.
그래서 다시 애노테이션으로 가보면 @NumberFormat이 있고 해당 패턴에 맞춰서 포멧팅을 해줘서 보여준다.

Post도 해보자.
@ModelAttribute가 있다. 그러면 이걸 일단 우리는 Form형태로 담아야하는데 여기서 주는건 결국 10,000과 2024-05-30 10:42:10이라는 문자열이다. 그러므로 @ModelAttribute도 포멧팅을 해줘야하는데 Form의 필드에 붙은 @NumberFormat과 @DateTimeFOrmat을 보고 이 패턴에 맞춰서 변환해준다음에 Form객체에다가 바인딩을 해준다.


결과를 확인해보면 여기서도 {}는 그냥 그대로 .toString으로 바꿔서 출력하였고 {{}}는 포멧팅이 적용된 결과가 나왔음을 알수있다. {}에서는 dateTime에 T가 끼어있는것을 볼 수 있다.

참고:
주의해야할점은 만약 내가 Json형식으로 응답을 받고싶어, 그래서 내가 응답 IpPort객체를 Json형식으로 변환할때 이제 Json도 String 형식이니까 어? 객체를 -> String으로 바꿀때 10,000으로 바꾸고 싶어.
그래도 @NumberFormat이거 적용 안된다.
왜냐하면 컨버젼 서비스는 @RequestParam,@ModelAttribute,@PathVVariable,뷰템플릿등에서 사용하는것이지,
Json결과로 만들어지는 숫자나 날짜포멧은 컨버젼 서비스를 사용 할수 없다.
고로, 만약에 JSon결과로 만들어지는 포멧을 컨버젼하고싶다면 Jaskon같은 라이브러리가 지원하는 설정을 통해서 포멧을 지정해야한다.

profile
항상 Why?[왜썻는지] What?[이를 통해 무엇을 얻었는지 생각하겠습니다.]

0개의 댓글