본문 바로가기
spring

mvc2 - 타입컨버터

by 오우지 2022. 3. 2.

쿼리 파라미터로 숫자를 보낸다고 생각하면 

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

다음과 같이 서블릿을 이용한 타입 변환을 해야했지만

 

@RequestParam으로 받아준다면

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

이건 스프링이 중간에서 타입을 변환해주기 때문에 Integer타입의 숫자 10으로 편리하게 받을 수 있다.

@ModelAttribute나 @PathVariable도 똑같다.

 

이렇게 타입을 변환해야 하는 경우는 상당히 많다. 개발자가 직접 타입 변환을 해야한다면 괴로울 것이다. 스프링이 중간에 타입 변환기를 사용해 변환해주기 때문에 편리하게 이용할 수 있짐나 개발자가 새로운 타입을 만들어서 변환하고 싶다면 어떻게 해야할까?

 

스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 컨버터 인터페이스를 구현해서 등록하면 된다. 과거에는 PropertyEditor라는 것으로 변환했지만 동시성 문제가 있어 변환시마다 객체를 생성해야 해서 그냥 Converter를 이용하면 된다.

 

org.springframework.core.convert.converter.Converter

 

 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보면

 

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

하지만 이렇게 개발하면 개발자가 직접 컨버팅 하는 것과 큰 차이가 없다.

 

스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.

Converter -> 기본 타입 컨버터

ConverterFactory -> 전체 클래스 계층 구조가 필요할 때

GenericConverter -> 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능

ConditionalGenericConverter -> 특정 조건이 참인 경우에만 실행

 


컨버전 서비스 - ConversionSevice

 

스프링은 개별 컨버터를 모아두고 그것을 편리하게 사용할 수 있는 기능을 제공하는데 그게 ConversionService다.

지금까지 만든 컨버터들을 컨버전 서비스에 등록하고 사용하기만 하면 된다.

public class ConversionServiceTest {

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

        IpPort result = conversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(result).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");
    }
}

이렇게 사용하면 등록과 사용을 분리하게 돼 사용자는 컨버전 서비스 인터페이스에만 의존하게 된다.

 

여기서 ISP(Interface Segregation Principal)를 다시 생각해보자

인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.

 

DefaultConversionService는 두 인터페이스를 구현했다.

ConversionService: 컨버터 사용에 초점

ConverterRegistry: 컨버터 등록에 초점

 

이렇게 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지 몰라도 된다.

 

이제 스프링에서 사용해보자.

 

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToIpPortConverter());
        registry.addConverter(new IpPortToStringConverter());
        registry.addConverter(new StringToIntegerConverter());
        registry.addConverter(new IntegerToStringConverter());
    }
}

스프링은 내부에서 ConversionService를 제공하기 때문에 WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 추가하고 싶은 컨버터를 등록하면 된다.

 

http://localhost:8080/hello-v2?data=10

를 입력해보면 컨버터 사용에 대한 로그가 호출되는 것을 알 수 있다. 이런 기본 컨버터는 스프링에서도 제공해주는데 우리가 컨버터를 등록하면 등록한 컨버터가 더 높은 우선순위를 가지게 된다.

 

이제 직접 정의한 IpPort를 변환해보자.

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

ipPort IP= 127.0.0.1
ipPort Port = 8080

쿼리 스트링이 IpPort에서 객체로 잘 변환 된 것을 확인할 수 있다.

 

처리과정

@RequestParam은 @RequestParam을 처리하는 ArgumentResolver인 ArgumentResolver인 RequestParamMethodArgumentResolver에서 복잡한 내부과정을 거쳐 ConversionService를 사용해서 타입을 변환한다.

 


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

이번에는 뷰 템플릿에 컨버터를 적용하는 방법이다. 이전까지는 문자를 객체로 변환했다면, 이번에는 객체를 문자로 변환하는 작업을 확인할 수 있다.  

 

@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", 8000));
        return "converter-view";
    }
}

컨트롤러고 model에 10000과 ipPort 객체를 담아서 뷰 템플릿에 전달한다.

 

<!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}: 10000
${{number}}: 10000
${ipPort}: hello.typeconverter.type.IpPort@59caf6d6
${{ipPort}}: 127.0.0.1:8000

실행결과는 다음과 같다. 

 

그 외에도 컨버터가 적용되는 경우가 하나 더 있는데 폼이다. 

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

다음과 같이 객체를 전달하기 위해 폼 객체 안에 값을 넣고 return 해주면

<!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는 id, name을 출력하는 등 다양한 기능과 함께 컨버전 서비스도 적용된다.

 

GET /converter/edit

- th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.

POST /converter/edit

- @ModelAttribute 를 사용해서 String IpPort 로 변환된다.

 


포맷터 - Formatter

Converter는 입출력에 제한이 없는 범용 타입 변환 기능을 제공한다. 하지만 문자를 다른 타입으로 변환하거나 다른 타입을 문자로 변환하는 상황이 대부분이다.

 

웹 애플리케션에서 객체를 문자로, 문자를 객체로 변환하는 예

Integer -> String 출력 시점에 숫자 1000 -> 문자 1,000 이렇게 출력하거나 날짜를 2021-01-01과 같이 출력하는 상황이 더 많다. 추가로 날짜 숫자 표현 방법은 Locale이 사용될 수 있다.

 

이렇게 객체를 특정한 포멧에 맞춰 문자로 출력하거나 그 반대의 역할을 하는 것에 특화된 기능이 Formatter다. Converter의 특별한 버전이라고 생각하면 된다.

 

 

Formatter는 객체를 문자로 변경하고, 문자를 객체로 변경한다.

String print(T object, Local locale): 객체를 문자로 변경한다.

T parse(String text, Locale locale): 문자를 객체로 변경한다.

 

Formatter 인터페이스

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

 

@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);
        NumberFormat instance = NumberFormat.getInstance(locale);
        return instance.format(object);
    }
}

숫자 중간에 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이는 Locale을 활용해 나라별로 달느 숫자 포맷을 만들어준다.

 

parse()를 사용해서 문자를 숫자로 변환한다. Number는 Integer, Long의 부모클래스다.

print()를 사용해서 객체를 문자로 변환한다.

 

class MyNumberFormatterTest {

    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        assertThat(result).isEqualTo(1000L);//Long 타입 주의
    }

    @Test
    void print() {
        String result = formatter.print(1000, Locale.KOREA);
        assertThat(result).isEqualTo("1,000");
    }
}

parse()의 결과가 Long이기 때문에 L을 붙여줬다.

 

스프링은 용도에 따라 다양한 방식의 포맷터를 제공한다.

Formatter 포맷터

AnnotationFormatterFactory필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터

 

 

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

컨버전 서비스는 컨버터만 등록할 수 있고 포멧터는 등록할 수 없다. 하지만 포맷터는 특별한 컨버터일 뿐이기 때문에 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에 어댑터 패턴이 구현되어 있다.

 

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

        //포멧터 사용
        assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
        assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
    }
}

 

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

스프링은 자바에서 기본으로 제공하는 타입들에 대해 포멧터를 기본으로 제공한다. 그런데 포멧터는 기본 형식이 지정되어 있기 때문에 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.

스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 유용한 포맷터 두 가지를 기본으로 제공한다.

 

@NumberFormat: 숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory

@DateTimeFormat: 날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory

 

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

실행해보면 지정한 포맷으로 출력된다.

${form.number}: 10000
${{form.number}}: 10,000
${form.localDateTime}: 2022-03-03T14:19:13
${{form.localDateTime}}: 2022-03-03 14:19:13

 

'spring' 카테고리의 다른 글

스프링 DB 1편 - 2  (0) 2022.11.30
스프링 DB 1편 - 1  (0) 2022.11.02
API 예외 처리  (0) 2022.03.01
mvc 1 - 스프링 MVC  (0) 2022.02.24
mvc2 - 필터, 인터셉터, 예외처리  (0) 2022.02.21