본문 바로가기

스프링/Spring

컨버전 서비스 사용하기 - ConversionService (타임리프, 폼)

컨버전 서비스란?

타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는 것은 매우 불편합니다.

개별 컨버터를 모아 두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스( ConversionService )입니다.

 

ConversionService 인터페이스

컨버전 서비스 인터페이스는 단순히 컨버팅이 가능한가? 확인하는 기능과, 컨버팅 기능을 제공합니다.

package org.springframework.core.convert;
import org.springframework.lang.Nullable;

public interface ConversionService {
    boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType);
    boolean canConvert(@Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
    <T> T convert(@Nullable Object source, Class<T> targetType);
    Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}

 

ConversionServiceTest - 컨버전 서비스 테스트 코드

이전에 만들었던 4가지 컨버터를 컨버전 서비스에 등록하고 사용하는 테스트를 해보겠습니다.

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 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는 ConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공합니다.

 

ConversionService와 인터페이스 분리 원칙 - ISP

등록과 사용 분리

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

 

인터페이스 분리 원칙 - ISP(Interface Segregation Principal)

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

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

ConfigurableConversionService은 DefaultConversionService의 부모인 GenericConversionService가 구현한 인터페이스입니다.

  • ConversionService : 컨버터 사용에 초점
  • ConverterRegistry : 컨버터 등록에 초점

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있습니다. 특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 됩니다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게 됩니다. 이렇게 인터페이스를 분리하는 것을 ISP 라 합니다.

 

스프링에 Converter 적용하기

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

 

\WebConfig - 컨버터 등록

package hello.typeconverter;

import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import org.springframework.context.annotation.Configuration;
import org.springframework.format.FormatterRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@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("/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

실행 로그

StringToIpPortConverter : convert source=127.0.0.1:8080
ipPort IP = 127.0.0.1
ipPort PORT = 8080

?ipPort=127.0.0.1:8080 쿼리 스트링이 @RequestParam IpPort ipPort에서 객체 타입으로 잘 변환된 것을 확인할 수 있습니다.

 

뷰 템플릿에(타임리프) 컨버터 적용하기

타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원합니다

이전까지는 문자를 객체로 변환했다면, 이번에는 그 반대로 객체를 문자로 변환하는 작업을 확인할 수 있습니다.

 

ConverterController

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

resources/templates/converter-view.html

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

타임리프는 ${{...}} 를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해줍니다.

물론 스프링과 통합되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있습니다.

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

 

실행

http://localhost:8080/converter-view

실행 결과

로그 확인

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

 

폼에 컨버터 적용하기

이번에는 컨버터를 폼에 적용해보겠습니다.

package hello.typeconverter.controller;

import hello.typeconverter.type.IpPort;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;

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

}

Form 객체를 데이터를 전달하는 폼 객체로 사용합니다.

  • GET /converter/edit : IpPort를 뷰 템플릿 폼에 출력합니다.
  • POST /converter/edit : 뷰 템플릿 폼의 IpPort 정보를 받아서 출력합니다.

resources/templates/converter-form.html

<!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를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용됩니다.

실행

http://localhost:8080/converter/edit

  • GET /converter/edit

th:field가 자동으로 컨버전 서비스를 적용해줘서 ${{ipPort}}처럼 적용이 되었습니다. 따라서 IpPort 객체가 String으로 변환됩니다.

  • POST /converter/edit

@ModelAttribute를 사용해서 String을 IpPort 객체로 변환했습니다.