BackEnd/Spring MVC

[Spring] 스프링 타입 컨버터 - 1

샤아이인 2022. 3. 18.

내가 공부한것을 올리며, 중요한 단원은 저 자신도 곱씹어 볼겸 상세히 기록하고 얕은부분들은 가겹게 포스팅 하겠습니다.

 

1. 스프링 타입 컨버터 소개

다음과 같은 컨트롤러가 하나 있다고 하자!

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

HTTP 요청 파라미터는 모두 문자로 처리된다.

따라서 요청 파라미터를 자바에서 다른 타입으로 변환해서 사용하고 싶으면 다음과 같이 숫자 타입으로 변환하는 과정을 거쳐야 한다.

 

위 코드에서도 다음과 같이 String -> Integer로 타입을 변환하여 사용하고 있다.

Integer intValue = Integer.valueOf(data);
 

● 스프링의 타입 변환

스프링을 사용하면 @RequstParam, @ModelAttribute , @PathVariable 에서도 타입 컨버팅을 확인할 수 있다.

 

예를 들어 쿼리 스트링으로 ?data=10 으로 전달하면, 여기서 10은 String의 10으로 전달된다.

이를 @RequstParam 같은것을 사용하면 Interger 타입의 10으로 변환되어 받을수가 있다.

 

이것은 스프링이 중간에서 타입을 변환해주었기 때문이다.

이러한 예는 @ModelAttribute , @PathVariable 에서도 확인할 수 있다.

 

이러한 중간 타입 변환 과정을 스프링에서 전부 도와주고 있다.

 

더 나아가 만약 개발자가 새로운 타입을 만들어 컨버팅 하고싶다면? 어떻게 해야할까?

=> 컨버터 인터페이스 를 사용하면 된다.

package org.springframework.core.convert.converter;

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

스프링은 이러한 확장 가능한 컨버터 인터페이스를 제공한다.

개발자는 스프링에 추가적인 타입 변환이 필요하면 언제든 이 컨버터 인터페이스를 구현해서 등록하면 된다!

 

2. 타입 컨버터 - Converter

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

 

예시로 127.0.0.1:8080 과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보자.

우선 객체로 사용될 IpPort Class 는 다음과 같다.

@Getter
@EqualsAndHashCode
public class IpPort {
    private String ip;
    private int port;
 
    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}

 

이제 이 class를 활용하는 컨버터를 구현해 보자.

 

● String => IpPort

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

● IpPort => String

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

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

 

양방향으로 컨버팅이 가능하도록 2가지 모두 구현해 주었다.

 

이제 이를 사용하는 Test 코드를 작성해 보자!

● converterTest

public class converterTest {
    @Test
    void stringToIpPort() {
        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));
    }

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

 

실행결과 원하는 결과를 얻을 수 있다.

 

하지만 한가지 의문이 든다. 이렇게 컨버터를 직접 구현한후, 하나하나 객체를 만들고, Source를 넘기고 사용하는 방식은 매우 불편한다.

이렇게 타입 컨버터를 하나하나 직접 사용하면, 개발자가 직접 컨버팅 하는 것과 큰 차이가 없다.

 

Spring은 이를 해결하기 위해 ConversionService 를 제공한다. 이는 다음 단락에서 알아보자!

 

3. 컨버전 서비스 - ConversionService

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

그래서 스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공하는데, 이것이 바로 컨버전 서비스( 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 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 를 사용했는데, DefaultConversionService 는 GenericConversionService를 상속 했으며,

GenericConversionService 는 => ConfigurableConversionService를 구현하였으며,

ConfigurableConversionService가 ConversionService를 확장하였다.

결론적으로 DefaultConversionService 는 ConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 ConverterRegistry도 구현했기 때문에 등록 기능도 제공한다.

 

● 등록과 사용의 분리

컨버터를 등록할 때는 StringToIntegerConverter 같은 타입 컨버터를 명확하게 알아야 하지만,

반면 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다.

사용자는 컨버전 서비스의 인터페이스에만 의존하면서 사용만 하면 된다.

 

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

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

 

위에서 DefaultConversionService 는 다음 두 인터페이스를 구현했었다.

- ConversionService : 컨버터 사용에 초점

- ConverterRegistry : 컨버터 등록에 초점

 

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

 

특히 컨버터를 사용하는 클라이언트는 ConversionService만 알면 되기 때문에 관리하는 부분은 전혀 몰라도 된다.

이러한 인터페이스 분리를 ISP 라고 부른다.

 

4. 스프링에 Converter 적용하기

지금까지 만든 컨버터를 등록하여 Spring에서 사용해 봅시다!

@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 에 컨버터를 추가해준다.

 

컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선순위를 가진다.

 

● IpPort 컨트롤러 사용해 보기

기존에 우리가 만든 IpPort class를 사용하여 컨버팅을 진행해 보자.

@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 로 요청을 보내보자!

결과는 다음과 같다.

로그가 정상적으로 출력 되었다.

 

● 처리 과정

@RequestParam은 @RequestParam 을 처리하는 ArgumentResolver 인 RequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다.

이후 컨트롤러가 호출될때 인자로 IpPort의 객체가 전달되어 정보를 출력하게 된다.

 

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

컨트롤러에서 view로 데이터를 전달할때, 객체를 문자로 변환하여 화면에 렌더링 해주어야 한다. 이 작업을 해보자.

 

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

 

model에 숫자 10000과 IpPort 객체를 담아서 뷰에 전달하고 있다.

 

● converter-view.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은 String "10000"이 출력된 것 이다.

${{number}}는 String을 Integer로 변환하는 IntegerToStringConverter를 사용하여 숫자 10000을 출력한 것 이다.

${ipPort}는 객체 그대로를 출력한 것 이다. 객체를 출력하려 했기 때문에 객체.toString()이 호출된 결과이다.

${{ipPort}}는 IpPortToStringConverter를 상용해 객체를 String으로 타입 컨버팅 하여 출력한 것 이다.

 

컨버팅 되는 동안 출력된 결과를 보면 알수있다.

IntegerToStringConverter와 IpPortToStringConverter가 사용된것을 알 수 있다.

 

● 폼에 적용하기

@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-form 일부

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

처음 Get방식으로 "/converter/edit" 에 요청을 보내면 IpPort 객체를 Form에 담아서 model에 추가해주고 있다.

따라서 처음 화면에 IpPort에 대한 정보가 담겨서 보여진다. 다음과 같이 말이다.

th:field는 {{ }} 처럼 2중 괄호를 사용하지 않았는데도 컨버전 서비스가 적용되었다.

타임리프의 th:field 는 앞서 설명했듯이 id , name 를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스도 함께 적용된다.

 

이제 제출 버튼을 눌러보자! 결과는 다음과 같다.

Form이 전달될때 Post 방식으로 전달 되었다.

이때 전달된 데이터는 "127.0.0.1:8080" 이라는 String이 전달된다.

위 HTML을 보면 value="127.0.0.1:8080" 이라는 String값을 확인할수 있다.

 

따라서 @ModelAttribute가 적용되게 된다. 넘어온 String 데이터를 Form 객체로 변환해야 하는데, Form객체 안에 IpPort 객체 또한 존제한다. 따라서 IpPort 객체를 만들게 된다. 이때 String 이 IpPort 로 변환되게 된다.

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

[Spring] 파일 업로드  (0) 2022.03.21
[Spring] 스프링 타입 컨버터 - 2  (0) 2022.03.18
[Spring] API 예외 처리 - 2  (0) 2022.03.15
[Spring] API 예외 처리 - 1  (0) 2022.03.14
[Spring] 예외 처리와 오류 페이지 - 2  (0) 2022.03.14

댓글