백엔드/스프링

스프링 MVC 2편 - [10] 스프링 타입 컨버터

RE-Heat 2023. 7. 28. 23:32

 

https://www.inflearn.com/course/%EC%8A%A4%ED%94%84%EB%A7%81-mvc-2/dashboard

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 - 인프런 | 강의

웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습할 수 있

www.inflearn.com

인프런 김영한님의 스프링 MVC 2편 강의를 토대로 정리한 내용입니다.

 

[1] 스프링 타입 컨버터 소개

HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 요청 파라미터를 다른 타입으로 변환해서 사용하고 싶으면 문자를 원하는 타입으로 바꾸는 과정을 거쳐야 한다.

 

 

HelloController - 문자를 숫자타입으로 변경

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

localhost:8080/hello-v1?data=10을 실행하면 문자타입으로 들어온 data를 Integer로 바꿔야 한다.

하지만 스프링 MVC가 제공하는 @RequestParam을 사용하면 알아서 타입이 변경된다.

 

 

HelloController - @RequestParam Integer data

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

이러면 문자 10을 Integer타입의 숫자로 10으로 받을 수 있다. 스프링에서 타입을 변환해 줬기 때문이다.

@RequestParam뿐만 아니라 @ModelAttribute @PathVaribale에서도 자동으로 타입을 변환해 준다.

 

 

■ 스프링의 타입 변환 적용 예

  • 스프링 MVC 요청파라미터
    @RequestParam , @ModelAttribute , @PathVariable 
  • @Value 등으로 YML 정보 읽기
  • XML에 넣은 스프링 빈 정보를 변환
  • 뷰를 렌더링 할 때

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

이런 요구를 충족하기 위해 스프링은 확장 가능한 컨버터 인터페이스를 제공한다.

 

 

컨버터 인터페이스

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

문자를 숫자로 변환하고 싶다면 S에 String, T에 Integer를 넣어주면 된다. 구체적인 구현은 [2]에서 할 예정이다.

 

 

[2] 타입 컨버터 - Converter

 

■ 문자 <> 숫자로 바꾸는 타입 컨버터

 

StringToIntegerConverter & IntegerToStringConverter

문자를 숫자(좌)로 숫자를 문자(우)로 바꿔주는 컨버터를 구현했다.

각 클래스는 Converter인터페이스를 상속받은 후 convert() 메서드를 오버라이딩해 구현했다.

 

 

ConverterTest - 타입 컨버터 테스트(문자<>숫자)

public class ConverterTest {
    @Test
    void stringToInteger(){
        StringToIntegerConverter converter = new StringToIntegerConverter();
        Integer result = converter.convert("10");
        assertThat(result).isEqualTo(10);
    }

    @Test
    void integerToString(){
        IntegerToStringConverter converter = new IntegerToStringConverter();
        String result = converter.convert(10);
        assertThat(result).isEqualTo("10");
    }
}

문제없이 잘 동작한다.

 

 

■ 사용자 타입 컨버터

문자, 숫자가 아닌 사용자가 직접 정의한 객체를 변환하는 컨버터를 만들어 보자.

 

IpPort

@Getter
@EqualsAndHashCode
public class IpPort {
    private String ip;
    private int port;

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

참고]

① @EqulasAndHashCode를 넣으면 모든 필드를 사용해 equals(), hashcode()를 생성한다. 따라서 모든 필드 값이 같다면 a.equals(b)의 결과는 참이 된다.

② hashCode는 일반적으로 각 객체의 주소값을 반환하여 생성한 객체의 고유한 정수값이다. 두 객체가 동일한 객체인지 비교할 때 사용할 수 있다는 뜻이다.

 

 

StringToIpPortConverter & IpPortToStringConverter

 

 

ConverterTest - IpPort <> String

@Test
void stringToIpPort(){
    StringToIpPortConverter converter = new StringToIpPortConverter();
    String source = "127.0.0.1:8080";
    IpPort result = converter.convert(source);
    assertThat(result).isEqualTo(result);
}

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

사용자가 정의한 IpPort의 Converter도 스프링이 제공하는 Converter 인터페이스를 활용해 구현할 수 있다.

 

그런데 이렇게 타입 컨버터를 하나하나 적용하면 개발자가 직접 컨버팅 하는 것과 차이가 없다.

그래서 스프링은 타입 컨버터를 등록하고 관리하면서 편리하게 변환 기능을 제공하는 여러 기능을 제공한다.

 

 

참고] 스프링은 용도에 따라 다양한 컨버터를 제공한다.

① Converter : 기본 타입 컨버터
② ConverterFactory : 전체 클래스 계층 구조가 필요할 때
③ GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
④ ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행

 

 


[3] 컨버전 서비스 - ConversionService

타입 컨버터를 하나하나 찾아서 반환하는 건 불편하므로 스프링은 개별 컨버터를 모아두고 묶어서 사용하는 편리한 기능을 제공한다. 그것이 바로 ConversionService다.

 

■ 컨버전 서비스

 

ConversionService 인터페이스

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

컨버전 서비스 인터페이스는 컨버팅이 가능한지 확인하는 기능(canConvert)과 컨버팅 기능(convert)을 제공한다.

 

 

ConversionServiceTest

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

        assertThat(conversionService.convert("127.0.0.1:8080", IpPort.class)).isEqualTo(new IpPort("127.0.0.1", 8080));
        assertThat(conversionService.convert(new IpPort("127.0.0.1", 8080), String.class)).isEqualTo("127.0.0.1:8080");

    }
}

ConversionService를 구현한 DefaultConversionService는 컨버터를 등록하는 기능도 제공한다. 여기선 앞서 만든 컨버터들을 등록하고 직접 테스트해 봤다.

 

■ 등록과 사용 분리

등록 : 컨버터를 등록할 땐 StringToIntergerConverter 같은 타입을 명확히 알아야 한다.

사용 : 반면 사용자 입장에선 타입 컨버터가 구체적으로 무엇인지 전혀 몰라도 된다.

    예) Integer value = conversionService.convert("10", Integer.class)

    => StringToIntergerConverter가 쓰이는지 사용자 입장에선 모른다.

 

그리고 DefaultConversionService는 사용에 초점을 둔 ConversionService, 컨버터 등록에 초점을 둔 ConverterRegistry를 분리해 인터페이스 분리의 원칙을 준수했다.

 


[4] 스프링에 Converter 적용하기

 

■ 웹 애플리케이션에 Converter 적용하기

WebConfig - 컨버터 등록

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

WebMvcConfigurer가 제공하는 addFormatters()를 사용해 추가하고 싶은 컨버터를 등록했다. 이렇게 하면 스프링은 내부에서 사용하는 ConversionService에 새 컨버터를 추가해 준다.

 

 

HelloController & 실행 로그

기존에 잘됐던 String <> Interger 컨버터 외에 직접 정의한 타입인 IpPort도 잘 컨버팅 되는 것을 확인할 수 있다.

 

■ 처리 과정

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


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

목표 : 뷰 템플릿에 컨버터를 적용하는 방법을 알아보자.

 

■ 뷰 템플릿에 적용하기

 

ConverterController

@Controller
public class ConverterController {

    @GetMapping("/converter-view")
    public String convertView(Model model){
        model.addAttribute("number", 10000);
        model.addAttribute("ipPort", new IpPort("127.0.0.1", 8080));

        return "converter-view";
    }
}

number와 ipPort를 model객체에 담아 뷰로 보낸다.

 

 

브라우저 화면 & converter-view.html

① 타임리프는 ${{...}}을 쓰면 자동으로 컨버전 서비를 사용해 변환된 결과를 출력해 준다. 

② ${{ipPort}} : IpPortToStringConverter가 적용돼 127.0.0.1:8080이 출력되는 것을 확인할 수 있다.

    반면 컨버터가 적용되지 않은 ${ipPort}는 toString이 호출된 것을 출력 결과를 통해 알 수 있다.

 

변수 표현식 : ${...}

컨버전 서비스 적용 : ${{...}}

 

■ 폼에 적용하기

 

ConvertController - 추가 부분

@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] & converter-form.html

th:field가 컨버전 서비스도 해 주는 것을 확인할 수 있다.

 

 

브라우저 화면 [POST /converter/edit]

@ModelAttribute를 사용해 String -> IpPort로 변환한다. 

 

 

[6] 포맷터 - Formatter

객체를 특정한 포맷에 맞춰 문자로 출력하거나 그 반대의 역할을 하는 것에 특화된 기능이 포맷터다.

범용 타입 변환 기능을 제공하는 Converter의 특별한 버전으로 이해하면 된다.

ex) 1,000<>1000, 2023-01-01 10:55:10

 

■ Converter vs Formatter

  • Converter는 범용(객체 → 객체)
  • Formatter는 문자에 특화(객체 문자, 문자   객체) + 현지화(Locale)

 

■ 포맷터 만들기

 

Formatter 인터페이스

public interface Printer<T> {
    String print(T object, Locale locale); 
}
public interface Parser<T> {
    T parse(String text, Locale locale) throws ParseException; 
}
public interface Formatter<T> extends Printer<T>, Parser<T> { 
}

String print() 객체를 문자로 변경한다.

T parse() : 문자를 객체로 변경한다. Locale은 현지화 정보

 

 

MyNumberFormatter

@Slf4j
public class MyNumberFormatter implements Formatter<Number> {
    @Override
    public Number parse(String text, Locale locale) throws ParseException {
        log.info("text={}, locale={}", text, locale);
        //"1,000" -> 1000
        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);
    }
}

① 1,000처럼 쉼표를 적용하려면 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해 나라별로 다른 숫자 포맷을 만들어 준다.

② parse() : 문자를 숫자로 변환한다. 

    참고] Number타입은 Integer, Long과 같은 숫자 타입의 부모 클래스

③ print() : 객체를 문자로 변환한다. 

 

 

MyNumberFormatterTest

class MyNumberFormatterTest {
    MyNumberFormatter formatter = new MyNumberFormatter();

    @Test
    void parse() throws ParseException {
        Number result = formatter.parse("1,000", Locale.KOREA);
        assertThat(result).isEqualTo(1000L);//Long 타입 주의
    }

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

주의 : parse()의 결과가 Long타입이므로 1000L을 써줘야 한다.

 

 

[7] 포맷터를 지원하는 컨버전 서비스

목표 : 포맷터를 지원하는 컨버전 서비스에 대해 알아보자.

 

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

DefaultFormattingConversionService는 FormattingConversionSerivce에 기본적인 통화, 숫자 관련 몇 가지 기본 포맷터를 추가해 제공한다.

 


FormatterConversionServiceTest

public class FormatterConversionServiceTest {

    @Test
    void formattingConversionService() {
        DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();

        //컨버터 등록
        conversionService.addConverter(new StringToIpPortConverter());
        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);
    }
}

포맷터를 등록해서 사용

FormattingConversionService는 ConversionService 관련 기능을 상속받기 때문에 컨버터도 포맷터도 모두 등록할 수 있다. 



[8] 포맷터 적용하기

목표 : 포맷터를 웹 애플리케이션에 적용해 보자.

 

WebConfig - 포맷터 등록

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

        //추가
        registry.addFormatter(new MyNumberFormatter());//얘도 숫자<>문자이므로 위의 것 빼줘야 함.
    }
}

MyNumberFormatter도 숫자<>문자 변경이기 때문에 StringtoIntegerConverter·IntegerToStringConverter와 겹친다.

우선순위에서 컨버터가 포맷터보다 앞서므로 포맷터를 적용하기 위해 주석처리 했다.

 

실행 : 객체 → 문자

포맷터가 적용돼 10,000 문자가 출력됐다.

 

실행 : 문자 → 객체

"10,000"이라는 포맷팅된 문자가 Integer 타입 숫자 10000으로 변환된 것을 확인할 수 있다.

 


[9] 스프링이 제공하는 기본 포맷터

포맷터는 기본 형식이 지정돼 있어 객체의 각 필드마다 다른 형식으로 포맷을 지정하기 어렵다. 

이런 문제를 해결하기 위해 스프링은 원하는 형식을 지정할 수 있는 애노테이션 두 가지를 제공한다.

 

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

  • @NumberFormat : 숫자 관련 형식지정포맷터사용, NumberFormatAnnotationFormatterFactory
  • @DateTimeFormat : 날짜 관련 형식 지정 포맷터사용, Jsr310DateTimeFormatAnnotationFormatterFactory

 

FormatterController

@Controller
public class FormatterController {

    @GetMapping("/formatter/edit")
    public String formatterFrom(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;

        @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
        private LocalDateTime localDateTime;
    }
}

참고]

Java 숫자형식

# 10진수, 빈자리는 채우지 않는다.

, 단위 구분 기호 표시 : ex) #,##.#

 

날짜 형식 

M : Month in year

m : Minute in year

 

실행화면

 

 

주의사항: 

메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 

JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson 같은 라이브러리를 사용하므로 JSON 결과로 만들어지는 수자나 날짜 포맷을 변경하고 싶으면 컨버전 서비스가 아닌 해당 라이브러리가 제공하는 설정을 통해 포맷을 지정해야 한다.

 

컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있으며

위 같은 이유로 @ResponseBody엔 적용할 수 없다.