스프링 타입 컨버터

oh_eol·2024년 3월 24일
0

Spring MVC2

목록 보기
4/4

스프링 타입 컨버터란?

문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야 하는 것 처럼 애플리케이션을 개발하다 보면 타입을 변환해야 하는 경우가 상당히 많다.
HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 요청 파라미터를 자바에서 다른 타입으로 변환해서 사용하고 싶으면 다음과 같이 숫자 타입으로 변환하는 과정을 거쳐야 한다.

Integer intValue = Integer.valueOf(data)

그러나 스프링 MVC가 제공하는 @RequestParam을 사용하면 이런 번거로운 과정이 필요 없다.

@RequestParam 타입 변환 예시

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

이 외에도 스프링 MVC는 다음과 같은 타입 변환들을 제공한다.

@ModelAttribute 타입 변환 예시

 @ModelAttribute UserData data
 class UserData {
   Integer data;
}

@RequestParam 와 같이, 문자 data=10 을 숫자 10으로 받을 수 있다.

@PathVariable 타입 변환 예시

 /users/{userId}
 @PathVariable("userId") Integer data

URL 경로는 문자다. /users/10 여기서 10도 숫자 10이 아니라 그냥 문자 "10"이다. data를 Integer 타입으로 받을 수 있는 것도 스프링이 타입 변환을 해주기 때문이다.

만약 개발자가 새로운 타입을 만들어서 변환하고 싶으면 어떻게 하면 될까?

컨버터 인터페이스

 package org.springframework.core.convert.converter;
 public interface Converter<S, T> {
   T convert(S source);
}

스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
여기서 S는 파라미터, T는 반환값으로 만든다. 아래에서 코드로 더 자세히 알아보자.

Converter

컨버터를 좀 더 이해하고자 바로 활용형 예제인 사용자 정의 타입 컨버터를 살펴보자.
127.0.0.1:8080 과 같은 IP, PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만들어보자.
먼저 IpPort 클래스를 하나 만든다.

IpPort

 package hello.typeconverter.type;
 import lombok.EqualsAndHashCode;
 import lombok.Getter;
 @Getter
 @EqualsAndHashCode
 public class IpPort {
     private String ip;
     private int port;
     public IpPort(String ip, int port) {
         this.ip = ip;
         this.port = port;
     }
}

그리고 String을 IpPort 객체로 변환할 컨버터를 만든다.

StringToIpPortConverter - 컨버터

 package hello.typeconverter.converter;
 import hello.typeconverter.type.IpPort;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.core.convert.converter.Converter;
 @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);
     }
}

127.0.0.1:8080 같은 문자를 입력하면, 콜론(:)을 기준으로 잘라 배열에 넣는다. 이를 다시 각각 ip와 port 에 넣은 IpPort 객체를 생성하여 반환하였다.

반대로 IpPort 객체를 문자열로 반환하는 컨버터도 만들 수 있다.

IpPortToStringConverter

 package hello.typeconverter.converter;
 import hello.typeconverter.type.IpPort;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.core.convert.converter.Converter;
 @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 객체를 입력하면 127.0.0.1:8080 같은 문자를 반환한다.

테스트를 만들어 검증해보자.

ConverterTest - IpPort 컨버터 테스트 추가

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

assertThat(result).isEqualTo(new IpPort("127.0.0.1", 8080));
여기서 isEqualTo 가 가능한 이유는, IpPort 클래스를 만들 때 @EqualsAndHashCode를 적 용했기 때문이다. 이 때 IpPort의 참조값이 다르더라도 ip 와 port 만 같으면 true 가 가능하다.

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 - 컨버전 서비스 테스트 코드

 package hello.typeconverter.converter;
 import hello.typeconverter.type.IpPort;
 import org.junit.jupiter.api.Test;
 import org.springframework.core.convert.support.DefaultConversionService;
 import static org.assertj.core.api.Assertions.*;
 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");
     }
}

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

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

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

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

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

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

정리하면 Converter들은 어딘가에서 ConversionService에 스프링 빈으로 등록해놓고, 사용할 때는 ConversionService 만 주입받아서 사용하면 된다.

스프링에 Converter 적용하기

기본으로 정의된 컨버터 적용

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

등록한 컨버터가 잘 동작하는지 확인해보자.

HelloController - 기존 코드

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

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

실행 로그

 StringToIntegerConverter   : convert source=10
 data = 10

?data=10 의 쿼리 파라미터는 문자이고 이것을 Integer data 로 변환하는 과정이 필요하다. 실행해보면 직접 등록한 StringToIntegerConverter 가 작동하는 로그를 확인할 수 있다.
그런데 생각해보면 StringToIntegerConverter 를 등록하기 전에도 이 코드는 잘 수행되었다. 그것은 스프링이 내부에서 수많은 기본 컨버터들을 제공하기 때문이다. 컨버터를 추가하면 추가한 컨버터가 기본 컨버터 보다 높은 우선 순위를 가진다.(더 자세한 것, 직접 기술한 것이 우선순위)

직접 정의한 타입 컨버터 적용

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

HelloController - 추가

 @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 에서 객체 타입으로 잘 변환 된 것을 확인할 수 있다.

처리 과정
@RequestParam@RequestParam 을 처리하는 ArgumentResolverRequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다. 부모 클
래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기 때문에 대략 이렇게 처리되는 것으로 이해해도 충분하다.
만약 더 깊이있게 확인하고 싶으면 IpPortConverter 에 디버그 브레이크 포인트를 걸어서 확인해보자.(디버그 찍고, 벌레 모양으로 실행한 뒤 역으로 올라가며 확인한다.)

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

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

ConverterController

 package hello.typeconverter.controller;
 import hello.typeconverter.type.IpPort;
 import org.springframework.stereotype.Controller;
 import org.springframework.ui.Model;
 import org.springframework.web.bind.annotation.GetMapping;
 @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";
     }
}

Model 에 숫자 10000ipPort 객체를 담아서 뷰 템플릿에 전달한다.

html을 다음과 같이 작성해보자.(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

실행 결과

• ${number}: 10000
• ${{number}}: 10000
• ${ipPort}: hello.typeconverter.type.IpPort@59cb0946
• ${{ipPort}}: 127.0.0.1:8080

실행 결과 로그

IntegerToStringConverter   : convert source=10000
IpPortToStringConverter    : convert
source=hello.typeconverter.type.IpPort@59cb0946

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

폼에 적용하기

이번에는 컨버터를 폼에 적용해보자.
ConverterController - 코드 추가

 package hello.typeconverter.controller;
 import hello.typeconverter.type.IpPort;
 import lombok.Data;
 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;
 @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";
     }
     @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 정보를 받아서 출력한다.

html을 작성해보자.(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 를 출력하는 등 다양한 기능이 있는데, 여기에 컨버전 서비스 도 함께 적용된다.
컨버터를 적용하기 싫다면 th:value를 쓰면 된다.

실행
http://localhost:8080/converter/edit

  • GET /converter/edit
    - th:field 가 자동으로 컨버전 서비스를 적용해주어서 ${{ipPort}} 처럼 적용이 되었다. 따라서 IpPort String 으로 변환된다.
  • POST /converter/edit
    - @ModelAttribute 를사용해서 String IpPort 로변환된다.
profile
공부 중입니다.

0개의 댓글