타입 컨버터

바그다드·2023년 6월 7일
0

예전에 jsp와 servlet을 활용하여 팀 프로젝트를 진행한 적이 있었다. 그때는 클라이언트로부터 넘어온 파라미터를 integer형태로 사용하기 위해 Integer.valueOf(param) 같은 메서드를 활용하여 일일이 타입을 변경해줘야 했다. 하지만 스프링에서 컨트롤러로 Integer형 데이터를 받을때 따로 형변환을 하지 않고 파라미터로 바로 받아져서 원래 String형태에서 형변환을 해줘야 하지 않나?하는 생각을 하다가 나중에는 익숙해져서 그냥 신경쓰지 않고 사용해왔는데, 알고 보니 이런 역할을 타입 컨버터가 해주고 있었다.

@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";
    }
	// 스프링이 변환
    @GetMapping("/hello-v2")
    public String helloV2(@RequestParam Integer data) {
        System.out.println("data = " + data);
        return "ok";
    }
}

  • 이처럼 굳이 우리가 타입을 변경하지 않아도 스프링이 타입을 자동으로 변환해주는데, 스프링의 타입 변환 적용 예시는 다음과 같다
  1. 스프링 MVC요청 파라미터
    @RequestParam, @ModelAttribute, @PathVariable
  2. @Value 등으로 YML 정보 읽기
  3. XML에 넣은 스프링 빈 정보를 변환
  4. 뷰를 렌더링 할 때

그렇다면 개발자가 만든 새로운 타입으로 타입을 변환하고 싶다면 어떻게 해야할까?

컨버터 인터페이스

package org.springframework.core.convert.converter;

public interface Converter<S, T> {
	T convert(S source);
}
  • 스프링은 확장 가능한 컨버터 인터페이스를 제공하는데, 이 인터페이스를 구현해서 등록해주면 개발자가 생성한 타입에도 적용을 할 수 있다. 예를들어 위의 코드<S, T>에서 S를 String, T를 Integer라고 하면 String타입의 데이터를 Integer타입의 데이터로 변환할 수 있다!!
    마찬가지로 원하는 타입의 데이터를 컨버터 인터페이스를 구현하여 등록함으로 변환을 할 수 있게 된다.

참고사항

자바에서 제공하는 PropertyEditor라는 클래스가 있는데, 예전에는 이것을 활용해 타입을 변환했다고 한다. 하지만 동시성 문제로 인해 사용할 때마다 객체를 새로 생성해야 하는 문제가 있다고 한다.

1. 타입 컨버터

  • 그럼 컨버터를 직접 구현해보자
@Getter
@EqualsAndHashCode
public class IpPort {

    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}
  • 127.0.0.1:8080과 같이 ip와 port번호를 저장하는 객체이다.
  • @EqualsAndHashCode는 필드의 값이 같다면 a.equals(b)의 결과를 참으로 반환한다.
    이제 문자열의 데이터를 받아 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);
    }
}
  • split을 이용해 IpPort타입으로 변경해준다.
    이제 테스트를 해보자.
    @Test
    void stringToIpPort() {
        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");
    }


테스트가 정상적으로 수행된 것을 확인할 수 있다.
그런데 이렇게 일일이 컨버터를 생성하고 메서드를 실행한다고 하면 결국 개발자가 직접 컨버팅을 하는 것과 같지 않나? 하는 생각이 든다. 그럼 그런 역할을 하는 것이 무엇일까?

참고사항

스프링은 용도에 따라 다양한 타입 컨버터를 제공한다.
1. Converter : 기본 타입 컨버터
2. ConverterFactory : 전체 클래스 계층 구조가 필요할 때
3. GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
4. ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행
공식문서


2. 컨버전 서비스

  • 단지 컨버터를 생성하고 일일이 메서드를 수행하는 것은 매우 불편하다. 따라서 스프링은 컨버터를 모아두고 편리하게 사용할 수 있는 컨버전 서비스를 제공한다.
    테스트코드로 사용 예시를 확인해보자
@Test
    void conversionService() {
        // 등록
        DefaultConversionService conversionService = new DefaultConversionService();
        conversionService.addConverter(new StringToIntegerConverter());
        conversionService.addConverter(new IntegerToStringConverter());
        conversionService.addConverter(new StringToIpPortConverter());
        conversionService.addConverter(new IpPortToStringConverter());

        //사용
//        conversionService.convert(data, type);
        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");
    }

  • 기능이 정상적으로 동작하는 것을 확인할 수 있다.
  • 또한 컨버전 서비스는 객체지향원칙 중 하나인 ISP를 잘 지키고 있는데,
    상속 관계를 타고 올라가보면

    이처럼 인터페이스가
    컨버터 사용
    컨버터 등록
    에 따라 나뉘어 있는 것을 확인할 수 있다.
    덕분에 실제로 적용할 때 컨버터 등록과 사용 코드를 따로 분리할 수 있게 된다. 아래의 예시로 확인하자

3. 컨버터 적용하기

1. 컨버터 등록

  • WebConfig에 컨버터를 등록해주자
@Configuration
public class WebConfig implements WebMvcConfigurer {

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

2. 컨트롤러 추가

    @GetMapping("/ip-port")
    public String ipPort(@RequestParam IpPort ipPort) {
        System.out.println("ipPort.getIp() = " + ipPort.getIp());
        System.out.println("ipPort.getPort() = " + ipPort.getPort());
        return "OK";
    }


  • 우리가 등록한 StringToIpPortConverter가 정상적으로 작동한 것을 확인할 수 있다!!
  • @RequestParam은 @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver에서 ConversionService 를 사용해서 타입을 변환한다.

4. 포맷터 - Formatter

컨버터는 입출력 타입에 제한이 없는 범용 타입 변환 기능을 제공한다. 그런데 일반적으로 웹을 제작할 때 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
예를 들어 Integer 타입의 데이터를 문자로 변환하거나, 문자를 Integer타입으로 변환하는 상황도 마찬가지이다.

  • 따라서 포맷터는 객체를 문자로 변환하거나, 문자를 객체로 변환하는 두 가지 기능을 모두 지원한다. 코드로 확인해보자

1. 포맷터 생성

@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);
    }
}
  • parse : 문자를 객체로 변경
  • print : 객체를 문자로 변경
  • 각 메서드의 매게변수를 확인해보면 Locale을 인자로 받는 것을 확인할 수 있는데, 예를 들어 한국에서 1000이라는 숫자를 표현할 때는 1,000으로 ,로 자리를 구분하는 것처럼 각 나라에 따라서 현지화 정보가 사용될 수가 있다.
  • Number는 Integer, Long 등 숫자타입의 부모클래스이다.
    테스트 코드로 잘 동작하는지 확인해보자

2. 포맷터 테스트

class MyNumberFormatterTest {

    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        //parse의 결과가 Long이기 때문에 L명시
        assertThat(result).isEqualTo(1000L); // Long타입 주의
    }

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

  • 포맷터가 정상적으로 작동하는 것을 확인할 수 있다!!

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

  • 컨버젼 서비스는 포맷터는 등록할 수 없는데, 포맷터도 생각해보면 결국 문자를 객체로, 객체를 문자로 변경하는 컨버터의 한 종류로 볼 수 있다. 따라서 FormattingConversionService라는 포맷터를 지원하는 컨버전 서비스가 있다.
    테스트로 확인해보자.
    @Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
        //컨버터 등록
        conversionService.addConverter(new StringToIntegerConverter());
        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);
    }

  • 포매터가 정상적으로 등록되고, 작동하는 것을 확인할 수 있다.
    그럼 포맷터를 적용해보자!!!

6. 포맷터 적용

1. WebConfig 수정

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addFormatters(FormatterRegistry registry) {
        // 주석처리
        // 컨버터가 포매터보다 우선순위가 높기 떄문에 주석처리
        registry.addConverter(new StringToIpPortConverter());
//        registry.addConverter(new StringToIntegerConverter());
        registry.addConverter(new IpPortToStringConverter());
//        registry.addConverter(new IntegerToStringConverter());

        // 추가
        registry.addFormatter(new MyNumberFormatter());
    }
}
  • 컨버터가 포맷터보다 우선순위가 높으므로 컨버터는 주석처리해주자

2. 컨트롤러 추가

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



타임리프에서 ${}는 변수를 ${{}}는 타입 컨버터를 적용한 변수를 나타낸다.
포맷터가 적용되어 10000이라는 숫자가 10,000이라는 문자로 변환된 것을 확인할 수 있다!!

7. 스프링이 제공하는 기본 포맷터

포맷터는 기본 형식이 지정되어 있어 객체의 필드마다 다른 형식으로 포맷을 지정하기 어려운데, 스프링은 이런 문제를 해결하기 위해 어노테이션 기반으로 형식을 지정할 수 있는 두 가지 포맷터를 제공한다

  1. @NumberFormat
    숫자 관련 형식 지정 포맷터 사용, NumberFormatAnnotationFormatterFactory
  2. @DateTimeFormat
    날짜 관련 형식 지정 포맷터 사용, Jsr310DateTimeFormatAnnotationFormatterFactory
    코드로 확인해보자

1. 컨트롤러 생성

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

        // 월은 대문자 M, 시간은 대문자 H -> 국제 표준임
        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}
  • pattern에 적용할 패턴을 입력해주자
  • @NumberFormat은 우리나라 표기법처럼 천단위로 구분하는 패턴을 지정하였다.
  • @DateTimeFormat같은 경우
    월은 대문자 M으로, 시간은 대문자 H로 표시해야 하는데, 이는 국제 표준이므로 꼭 지켜주자!!
  • 이제 결과를 확인하기 위해 view를 생성해주자

2. view 생성

  • formatter-form
<!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는 타입 컨버터를 적용하는 역할도 한다. 참 많은 기능을 하는 것 같다.
th:field의 기능을 다시 짚고 넘어가보면,
id,name,value 속성을 생성해주고, 체크박스의 경우 name에 _(언더바)를 추가한 hidden타입의 태그를 생성해 체크가 되어 있지 않은 경우 값이 아예 넘어가지 않는 문제를 해결해준다. 거기다 오늘 확인한 타입 컨버터까지 적용해준다ㄷㄷ

  • formatter-view
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
</head>
<body>
<ul>
    <li>${form.number}: <span th:text="${form.number}" ></span></li>
    <li>${{form.number}}: <span th:text="${{form.number}}" ></span></li>
    <li>${form.localDateTime}: <span th:text="${form.localDateTime}" ></span></li>
    <li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}" ></span></li>
</ul>
</body>
</html>

  • FormatterController/Form에서 설정한 패턴대로 값이 변형되어 출력된 것을 확인할 수 있다!!

주의사항

API응답을 보낼 때 사용되는 메세지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 메세지 컨버터는 http바디의 내용을 객체로 변환하거나 객체를 http메세지 바디에 입력하는데 사용되는 것이다.
스프링 같은 경우 json을 객체로 변환할 때 메세지 컨버터 내에서 Jackson같은 라이브러리를 사용하기 때문에 변환 결과는 Jackson라이브러리에 달려있다. 따라서 json 데이터의 포맷을 변경하고 싶다면 라이브러리에서 제공하는 기능을 통해 포맷을 지정해야 한다.
나도 타입 컨버터라는 것을 처음 들었을 때 메세지 컨버터랑 헷갈렸는데 자세한 역할을 알고 나니 확실히 서로 다른 역할을 한다는 것을 알게 되었다!

출처 : 김영한 스프링MVC2편

profile
꾸준히 하자!

0개의 댓글