[Spring] 타입 컨버터, 포맷터

imcool2551·2022년 3월 12일
0

Spring

목록 보기
15/15
post-thumbnail

본 글은 인프런 김영한님의 스프링 완전 정복 로드맵을 기반으로 정리했습니다.

0. 들어가며


애플리케이션을 개발하면 문자,숫자처럼 다른 타입을 상호 변환해야 하는 경우가 많다. 스프링은 타입변환을 해주는 다양한 컨버터 및 포맷터를 제공해준다. 개발자가 직접 만든 타입의 경우 직접 컨버터 및 포맷터를 구현해서 등록할 수도 있다.

1. 타입 컨버터


@RestController
public class HelloController {

    @GetMapping("/hello")
    public String hello(HttpServletRequest request) {
        String data = request.getParameter("data");
        Integer intValue = Integer.valueOf(data);
        return "ok";
    }
}

클라이언트가 보내는 쿼리 파라미터는 문자열이기 때문에 서블릿을 통해서 받아오면 직접 원하는 타입으로 형변환을 해줘야 한다. 그러나 스프링 MVC 의 @RequestParam 은 자동으로 형변환을 해주기 때문에 개발자가 직접 형변환을 해주는 수고가 줄어든다. @RequestParam 뿐 아니라 @ModelAttribute, @PathVariable 도 스프링이 개발자가 지정한 변수의 타입으로 알아서 형변환 해준다.

GetMapping("/hello")
public String hello(@RequestParam Integer data) {
    return "ok";
}

Integer, String 과 같은 클래스는 자바가 제공하는 타입이라 스프링에서 타입 컨버터를 미리 구현해뒀다.

개발자가 직접 만든 타입에 타입 컨버터를 적용하려면 org.springframework.core.convert.converterConverter<S, T> 인터페이스를 구현해야한다. 여기서 S는 변환 전 타입, T는 변환 후 타입이다.

@Getter
@EqualsAndHashCode
public class IpPort {

    private String ip;
    private int port;

    public IpPort(String ip, int port) {
        this.ip = ip;
        this.port = port;
    }
}

ip, port를 나타내는 IpPort 타입과 String간에 타입 컨버터를 적용하고 싶으면 컨버터를 직접 구현해야한다. "127.0.0.1:8080" 과 같은 문자열과 new IpPort("127.0.0.1", 80) 객체간 상호 변환이 가능하도록 하기 위해 컨버터를 2개 만들어보자.

참고로, 롬복의 @EqualsAndHashCodeequals(), hashcode() 를 생성해주기 때문에 ip, port 필드가 같은 객체는 equals() 를 통해 동등성 비교를 할 수 있다.

public class StringToIpPortConverter implements Converter<String, IpPort> {
  @Override
  public IpPort convert(String source) {
      String[] split = source.split(":");
      String ip = split[0];
      int port = Integer.parseInt(split[1]);
      return new IpPort(ip, port);
  }
}

문자열을 객체로 변환해주는 컨버터다. 문자열을 ":" 기준으로 분리해서 각각 IpPort 객체의 ip, port 필드에 넣어준 다음 객체를 반환하였다.

public class IpPortToStringConverter implements Converter<IpPort, String> {

    @Override
    public String convert(IpPort source) {
        return source.getIp() + ":" + source.getPort();
    }
}

객체를 문자열로 변환해주는 컨버터다. 객체의 getter 메서드를 통해 ip, port 필드를 가져온 후 ":" 문자로 이어 붙인다.

2. 컨버터 등록


직접 정의한 컨버터를 편리하게 사용하는 방법을 알아보자. 컨버터를 스프링 빈으로 등록해서 의존 주입 받아 사용하는 방법도 있지만 그러기엔 너무 불편하다. 컨버팅 하는 기능을 컨버터 내부로 숨기긴 할 수 있지만, 컨버터가 필요한 곳마다 의존 주입 받고 직접 convert() 메서드를 호출하는 것은 실용성이 떨어진다.

스프링은 내부적으로 ConversionService 인터페이스를 통해 컨버팅 기능을 제공한다.

ConversionService 는 컨버터 사용에 초점이 맞춰져 있는 반면 ConversionRegistry 는 컨버터 등록에 초점이 맞춰져있다. WebMvcConfigurer 를 구현한 설정 클래스에서 addFormatters(FormatterRegistry registry) 메서드를 오버라이딩해서 컨버터를 등록하면 ConversionService 에서 컨버터를 사용할 수 있다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

참고로, 스프링은 이처럼 객체의 등록과 사용을 분리하는 방식을 많이 사용한다. 컨버터를 사용하는 ConversionService 는 구체적으로 어떤 컨버터를 사용하는지 몰라도 된다. 구체적인 컨버터들의 등록은 ConversionRegisgtry가 담당한다. 컨버터들은 ConversionService 내부에 숨어서 제공된다. 따라서, 타입 변환을 원하는 클라이언트는 ConversionService 에만 의존해도 된다. 이처럼 컨버터를 사용하는 관심사와 컨버터를 등록하는 관심사를 나누어서 인터페이스를 설계한 것은 ISP 원칙을 잘 지켰다고 볼 수도 있다.

@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
    return "ok";
}

컨버터를 등록했다면 스프링이 내부적으로 @RequestParam 과 같은 곳에서 컨버터를 사용할 수 있다. /ip-port?ip=127.0.0.1&port=8080 으로 요청하면 컨버터가 동작해서 new IpPort("127.0.0.1", 8080) 객체를 반환해준다.

내부적으로 @RequestParam 을 처리하는 ArgumentResolverRequestParamMethodArgumentResolver 에서 ConversionService 를 사용해서 타입을 변환한다.

3. 포맷터 - Formatter


Converter 는 객체A, 객체B와 사이의 범용적인 타입 변환 기능을 제공한다.

Formatter는 객체와 문자열 간 변환에 특화되어 있다. 대부분의 타입 변환은 문자열과 다른 객체간의 변환이다. 숫자 1000을 문자 "1000" 간에 변환하거나 또는 "20222-03-12 03:03:03"와 같은 문자열과 날짜 객체간의 변환 같은 것이다. 즉, FormatterConverter 의 특별한 버전이라고 볼 수 있다.

org.springframework.format.Formatter 인터페이스는 Printer<T>Parser<T> 인터페이스를 상속 받는다. Printer<T>String print(T object, Locale locale) 메서드를 가지고 있다. 이름 그대로 객체->문자열 변경을 담당한다. Parser<T>T parse(String text, Locale locale) 메서드를 가지고 있다. 이름 그대로 문자열->객체 변경을 담당한다. 두 메서드 모두 Locale 을 매개변수로 받기 때문에 지역정보를 활용할 수 있다.

문자열 "1,000" 과 숫자 1000 간에 상호변환을 할 수 있는 Formatter를 구현해보자.

public class MyNumberFormatter implements Formatter<Number> {

    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        return NumberFormat.getInstance(locale).parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        return NumberFormat.getInstance(locale).format(object);
    }
}

"1,000" 처럼 중간에 쉼표를 넣기 위해 java.text.NumberFormat 객체를 사용했다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.

4. 포맷터 등록


컨버터와 마찬가지로 구현한 다음, 등록을 해줘야한다. 그런데 ConversionService 는 이름 그대로 컨버터만 등록할 수 있다. 그런데 포맷터는 객체와 문자열 간 변환을 해주는 특수한 형태의 컨버터일 뿐이다. 그래서 FormatterConverter 처럼 동작할 수 있도록 어댑터 패턴을 사용하는 DefaultForamttingConversionService 구현체가 존재한다. 스프링 부트는 DefaultForamttingConversionService 를 상속 받은 WebConversionService를 내부적으로 사용한다.

@Configuration
public class WebConfig implements WebMvcConfigurer {

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

        registry.addFormatter(new MyNumberFormatter());
    }
}

컨버터처럼 addFormatters() 에서 포맷터를 등록한다. 컨버터는 addConverter() 를 통해 등록하고 포맷터는 addFormatter() 를 통해 등록하면 된다.

포맷터를 등록하면 숫자 객체를 문자로 변경하는 부분에 쉼표가 들어간다.

5. 기본 포맷터 - @NumberFormat, @DateTimeFormat


스프링은 자바에서 기본으로 제공하는 타입들에 대해 다양한 컨버터를 이미 구현해놨다고 했다. 포맷터 또한 마찬가지다. 문자열과 객체간에 상호 변환하는 일은 개발하면서 매우 자주 필요하다.

타입 컨버터가 특정한 객체의 타입을 바꾸는데 특화되어 있다면 포맷터는 객체를 특정한 문자열로 포맷팅 하는데 특화되어 있다.

문제는, 포맷터는 기본 형식이 지정되어 있기 때문에 같은 타입의 필드마다 다른 포맷을 지정하기 어렵다는 것이다. 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 동작하는 아주 강력한 두 가지 포맷터를 제공한다.

@Controller
public class FormatterController {

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

컨트롤러 내부에서 사용할 커맨드 객체다. @NumberFormat 애노테이션은 숫자와 관련된 객체의 포맷을 지정하는데 사용된다. @DateTimeFormat 은 날짜와 관련된 객체의 포맷을 지정하는데 사용된다.

위와 같은 컨트롤러에서 /formatter/edit?number=10,000&localDateTime=2022-03-12%2023:59:59 와 같은 요청을 보내면 숫자 10000과 2022년 3월 12일 23시 59분 59초 를 나타내는 객체를 커맨드 객체를 컨트롤러에서 바로 받을 수 있다. 참고로, "%20" 은 공백을 utf-8 인코딩한 결과다.

두 애노테이션의 자세한 사용법은 다음 링크에 나와 있다.

https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#format-CustomFormatAnnotations

6. 정리


컨버터와 포맷터는 그 개념이 유사하다. 포맷터는 객체와 문자열 간 변환을 담당하는 특수한 컨버터라고 이해하면 된다. 등록하는 방법에는 약간 차이가 있지만 ConversionService를 통해 일관성있게 사용할 수 있다.

주의할 점이 한 가지 있다. HttpMessageConverterConversionService 가 적용되지 않는다.

ArgumentResolver 같은 경우는 스프링이 내부적으로 처리하는 것이라서 컨버터와 포맷터를 이용할 수 있다. HttpMessageConverter 는 HTTP 바디(주로 JSON)<-> 객체 간에 변환을 수행한다. 내부적으로 Jackson과 같은 라이브러리를 통해 변환을 수행하기 때문에 해당 라이브러리에는 컨버터와 포맷터가 적용이 되지 않는다. JSON 결과로 만들어지는 숫자/날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 해줘야 한다.

profile
아임쿨

0개의 댓글