Spring/Spring MVC

Spring Type Converter

민철킹 2021. 8. 3. 19:36

문자를 숫자로 변환하거나 숫자를 문자로 변환해야하는 것과 같은 타입을 변환하는 경우가 많다.

 

예를 들면 아래와 같다.

위와 같이 쿼리스트링으로 넘어온 파라미터를 받고 싶다. 이를 위해 "/hello-v1?data=5"라는 요청을 보내면 getParameter를 통해 값을 받을 수 있다. 하지만 이는 숫자타입가 아니라 문자타입이다(String). 따라서 직접 Integer타입으로 변환하는 과정을 거쳐야한다.

 

하지만 무의식 중에 우리가 아무렇지 않게 사용하던 @RequestParam을 떠올려보자.

@RequestParam Integer data

이와 같이 별도의 타입 변환과정 없이 손쉽게 개발자가 원하는 타입으로 변환된다.

이는 스프링이 중간에서 타입을 변환해주기 때문이다! 이러한 역할을 하는 것이 바로 Type Converter이다!

 

이것은 @ModelAttribute@PathVariable에서도 마찬가지이다.

 

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

 

컨버터 인터페이스 : org.springframework.core.convert.converter

스프링은 확장 가능한 컨버터 인터페이스를 제공하기 때문에 이 인터페이스를 구현하여 등록하면 된다.

==> ex : String타입 "true"가 들어오면 Boolean타입 True로 변환되도록 할 수 있음

 

과거에는 PropertyEditor를 사용해 타입을 변환했지만 이는 동시성 문제가 있었고 현재는 Converter의 등장으로 해당 문제가 모두 해결되었다.

 


Converter 인터페이스를 구현하여 간단한 타입 컨버터를 직접 만들어보자.

String을 Integer로 변환하는 타입 컨버터이다. (source는 String, 반환 타입이 Integer)

Interger를 String으로 변환하는 타입 컨버터이다.

 

간단한 테스트 코드를 만들어 컨버터가 정상적으로 동작하는지를 확인해보자.

 

이번에는 문자열을 객체로 객체를 문자열로 변환하는 Converter를 만들어보겠다.

먼저 객체 IpPort이다. ip주소와 port번호를 필드로 가지고 있다.

만약 "127.0.0.1:8080"이라는 문자열이 들어오면 "127.0.0.1"은 ip주소로 8080을 port번호로 파싱해 객체를 생성해주고,

ip주소 "127.0.0.1"과 port번호 8080으로 생성된 IpPort객체를 넣으면 "127.0.0.1:8080"이라는 문자열로 변환해줄 것이다.

":"을 기준으로 문자열을 파싱하고 새로운 객체를 생성해준다.

객체의 필드값을 얻어 문자열로 이어붙혀준다.

 

테스트 코드를 작성해 원하는대로 동작하는지 테스트해보자.

IpPort 클래스에 @EqualsAndHashCode 애노테이션을 붙혀놨기 때문에 위와 같이 참조 값이 다른 객체라도 동등성을 비교할 수 있다. 참조 값이 달라도 내부 필드 값이 동일하다면 참이 됨

 

사실 이렇게 개발자가 타입 컨버터를 하나하나 직접 사용하는 것은 컨버터를 만들지 않고 컨버팅을 하는 것과 차이가 없다.

 

스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.

- Converter : 기본 타입 컨버터

- ConverterFactory : 전체 클래스 계층 구조가 필요할 때

- GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능

- ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행

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

 

Core Technologies

In the preceding scenario, using @Autowired works well and provides the desired modularity, but determining exactly where the autowired bean definitions are declared is still somewhat ambiguous. For example, as a developer looking at ServiceConfig, how do

docs.spring.io

스프링은 String, Integer, Enum 등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공함.

 


위에서 언급한 바와 같이 타입 컨터버를 등록하고 관리하며 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요한데 이는 컨버전 서비스(ConversionService)가 지원한다.

 

ConversionService

스프링은 개별 컨버터를 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 컨버전 서비스 기능을 제공한다.

내부 메서드를 살펴보면 ("org.springframework.core.convert") 

컨버팅이 가능한지 확인하는 canConvert와 컨버팅을 하는 convert 메서드가 존재한다.

 

컨버전 서비스를 사용해보자.

구현체 중 하나인 DefaultConversionService를 사용하였다. 여기에 addConverter를 통해 앞에서 만든 4개의 컨버터를 추가한다.

그 후 컨버팅을 진행하는데 source를 넣어주고 원하는 반환타입을 입력해주면 자동으로 어떤 컨버터가 사용될지를 찾아서 컨버팅을 수행한다.

타입 정보를 보고 StringToIntergerConverter가 사용된 것을 확인할 수 있다.

숫자를 source로 넣고 String을 반환타입으로 지정하면 IntegerToStringConverter가 사용된다.

객체를 문자로 변환하는 컨버터도 정상 동작

 

컨버전 서비스에 컨버터만 등록해놓고 실제 사용할 서비스 로직에서 이를 주입받아 편리하게 사용할 수 있게 되는 것이다.

==> 스프링 빈으로 등록시켜 놓고 주입받아 사용하면 됨

 

등록과 사용 분리

컨버터를 컨버전 서비스에 등록할 때는 어떤 타입 컨버터인지를 명확하게 알아야하지만 사용하는 곳에서는 타입 컨버터를 전혀 몰라도 된다. 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공되기 때문에 컨버전 서비스의 인터페이스에만 의존하게 되는 것이다.

 

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

클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.

DefaultConversionService는 아래 그림과 같이 ConverterRegistryConversionService를 구현한다.

- ConverterRegistry : 컨버터 등록에 초점, 컨버터를 등록하는 메서드 존재

- ConversionService : 컨버터 사용에 초점, 위에서 살펴본 canConvertconvert 메서드 존재

 

이렇게 인터페이스를 분리해 컨버터를 사용하는 클라이언트등록하고 관리하는 클라이언트관심사를 명확히 분리할 수 있다.(관심사의 분리!!!) 즉, 컨버터를 사용하는 클라이언트는 ConversionService에만 의존하고 컨버터를 어떻게 등록하고 관리하는지는 관심도 없고 몰라도 되므로 꼭 필요한 메서드에만 의존하게 된 것이다. 이것이 바로 ISP

https://ko.wikipedia.org/wiki/%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4_%EB%B6%84%EB%A6%AC_%EC%9B%90%EC%B9%99

 

인터페이스 분리 원칙 - 위키백과, 우리 모두의 백과사전

 

ko.wikipedia.org

 

스프링은 내부에서 ConversionService를 사용해 타입을 변환한다. 이를 통해 @RequestParam, @ModelAttribute같은 곳에서 타입을 변환하는 것!!

 


웹 애플리케이션Converter를 적용해보자.

 

Config 클래스를 만들고 WebMvcConfigurer가 제공하는 addFormatters 메서드를 사용해 추가하고 싶은 Converter를 등록하면 스프링이 내부에서 사용하는 ConversionService에 추가된다.

컨트롤러

등록된 Converter가 정상 동작하는지를 알아보기 위해 "/hello-v2?data=10"으로 쿼리 스트링을 전달해보겠다.

이 때 문자 "10"이 넘어오지면 받는 @RequestParam이 Integer타입이기 때문에 Converter가 동작할 것이다.

방금 등록한 StringToIntegerConverter가 사용되어 StringInterger로 타입 변환이 수행된 것을 확인할 수 있다.

이는 컨트롤러가 호출되기 이전에 값을 변환하고 컨트롤러를 호출하면서 변환된 값을 넘겨준다.

 

기본 Converter 중에 String을 Integer로 변환해주는 Converter가 있지만 직접 등록한 Converter가 더 높은 우선순위를 가지기 때문에 등록한 것이 호출된 것임.이는 @ModelAttribute@PathVariable에서도 동일하게 동작한다.

 

@RequestParamRequestParamMethodArgumentResolver에서 ConversionService를 사용해 타입을 변환한다.

 

"/ip-port?ipPort=127.0.0.1:8080"과 같이 문자열 쿼리 파라미터를 전송하면 IpPort 객체로 변환해주는 Converter인 위에서 직접 등록한 StringToIpPortConverter가 사용될 것이다.

당연한 얘기이지만 Config 클래스의 해당 컨버터를 등록하는 addConverter부분을 주석처리하면 예외가 발생한다.

 

스프링을 사용할 때 쿼리 파라미터를 통해 객체로 변환할 때는 대부분 위와 같이 컨버터를 직접 등록하는 것이 아니라

@ModelAttribute를 사용한다. 이또한 컨버터가 사용된다고 하였는데 어떻게 사용되는지를 로그로 찍어보자.

먼저, Config 클래스의 StringToIpPortConverter를 등록하는 부분을 주석처리했다.

그 후 컨트롤러의 코드를 아래와 같이 변경하였다.

@ModelAttribute를 생략해도 되지만 명시적으로 @ModelAttribute를 쓴다는 것을 보여주기 위해 생략하지 않았다.

현재 IpPort는 필드로 String타입의 ip와 int 타입의 port를 가지고 있다.

 

먼저 내가 예상하는 결과는 다음과 같다.

    - @ModelAttribute로 파라미터를 객체로 변환하기위해 멤버 변수를 넘겨준다.

    - "/ip-port2?ip=127.0.0.1&port=8080" 과 같이 요청을 전달한다.

    - @ModelAttribute는 해당 애노테이션이 붙은 객체의 프로퍼티를 찾고 넘어온 값을 Setter를 통해 값을 바인딩한다.

    - 이때 쿼리 파라미터로 넘어오는 값은 String 타입이다.

    - 멤버 변수 ip는 String타입이므로 변환 과정없이 바로 바인딩되지만, port는 int타입이므로 변환 과정이 필요함.

    - 원래는 Spring의 기본 Converter가 사용될 것이지만 현재는 내가 직접 등록한 StringToIntegerConverter가 사용

    - 결과적으로 새로운 객체가 생성

 

내가 예상한 결과와 같이 동작하고 있다는 것을 확인할 수 있다.

@ModelAttribute도 내부적으로 컨버전 서비스 사용!!

확실히 내부 구조를 까보고 맞물려 돌아가는 과정을 이해하려하니 어떻게 동작하는지를 확실하게 알겠다.

 


View Template에 Converter 적용

 

Thymeleaf는 렌더링 시에 Converter를 적용하여 편리하게 지원해준다.컨트롤러에서 전달되는 객체를 문자로 변환하여 출력해준다는 의미이다.

 

Controller에서 model에 숫자와 객체를 담아 View로 전달한다.

${number}와 같이 중괄호가 하나만 있다면 컨버터를 적용하지 않지만 ${{number}}과 같이 중괄호가 두개라면 컨버터를 적용한다.

- ${number} : 숫자를 문자로 변환하는 과정을 Thymeleaf가 자동으로 해줌

- ${{number}} : 숫자를 문자로 변환하여 출력(컨버터 사용) ==> 쓸 필요없음 숫자를 문자로 바꾸는 것은

- ${ipPort} : 객체를 그대로 출력하였기 때문에 프로퍼티가 출력됨, 즉 ipPort.toString()과 같은 결과

- ${ipPort.ip} / ${ipPort.port} : Thymeleaf는 프로퍼티 접근법을 허용하므로 위와 같이 필드에 직접 접근하여 값을 출력

- ${{ipPort}} : 객체를 문자로 변환하는 컨버터 사용

변수 표현식 : ${...}
컨버전 서비스 적용 : ${{...}}

 

model에 담긴 값을 꺼낼 때 사용하는 Thymeleaf의 th:field는 자동으로 컨버터를 사용하는 기능이 존재한다.

컨트롤러 내부에 DTO 역할을 하는 Form 클래스를 static으로 만들었다.(예제니까.. ㅎㅎ)

원래 HTML Form의 데이터를 입력받기 위해 빈 Form 객체를 넘기지만 th:field의 자동 컨버팅을 확인하기 위해 임의로 위와 같이 컨트롤러에서 Form객체의 ipPort에 값을 담아 전달하였다.

View

View는 위와 같다.

th:field는 우리가 등록한 IpPortToStringConverter가 자동으로 사용되었고 th:value는 사용되지 않고 객체 인스턴스 자체를 출력하였다.

 


Formatter

 

Converter는 입출력 타입에 제한 없는 범용 타입 변환 기능을 제공한다.

 

하지만 실제 개발환경에서는 이러한 범용 기능보다는 특정 포맷으로 변환하는 상황이 대부분일 것이다.

 

예를 들자면, 숫자 1000을 문자 "1000"으로 변환하는 것이 아닌 화폐 단위로 "1,000"과 같이 변환하거나 "1,000"을 숫자 1000으로 변환하는 상황 또는 날짜 객체를 문자인 "2021-08-09 12:54:11 KST"와 같이 출력하는 상황이다.

 

여기에 추가로 날짜의 경우에는 Locale 국가 정보가 사용될 수 있다.

 

이렇게 객체를 특정한 포멧에 맞춰 출력하거나 그 반대의 역할을 하는 것이 바로 Formatter이다.

Formatter는 Converter의 특별 버전으로 이해하자.
Converter : 범용 (객체 -> 객체)
Formatter : 문자에 특화( 객체 -> 문자, 문자 -> 객체) + 현지화(Locale)

 

Formatter는 객체를 문자로 문자를 객체로 변경하는 두 기능을 수행한다.

    - String print(T object, Locale locale) : 객체를 문자로

    - T parse(String text, Locale locale) : 문자를 객체로

 

 

먼저 숫자 중간에 쉼표를 넣은 문자를 만들고 쉼표가 들어간 문자를 숫자로 변환하는 Formatter를 만들어보겠다.

Spring이 제공하는 Formatter 인터페이스를 구현한다. Formatter는 기본적으로 String 타입을 지원하기 때문에 자바에서 제공하는 Number 타입만 제네릭 타입으로 넣어준다.

    - Number 타입에는 int, long, float, double, byte, short 타입이 모두 포함되어 있다.

 

"1,000"처럼 숫자 중간에 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용한다.

NumberFormatLocale을 파라미터로 받아 나라별로 다른 숫자 포맷을 만들어준다.

 

문자를 숫자로 변환할 때는 Locale정보를 넣어 NumberFormat을 만들고 해당 포맷을 parse메서드를 통해 숫자로 변환숫자를 문자로 변환할 때는 Locale정보를 넣어 NumberFormat을 만들고 해당 포맷을 format메서드를 통해 문자로 변환

 

정상 동작하는지 테스트해보자.

Formatter가 정상 동작하여 원하는 결과로 포맷된 것을 확인할 수 있다.

 

Formatter 인터페이스는 가장 단순한 Formatter이고, AnnotationFormatterFactory필드 타입이나 애노테이션 정보를 활용할 수 있는 Formatter이다.

추가 사항은 공식 문서를 참고https://docs.spring.io/spring-framework/docs/current/reference/html/core.html#format

 

Core Technologies

In the preceding scenario, using @Autowired works well and provides the desired modularity, but determining exactly where the autowired bean definitions are declared is still somewhat ambiguous. For example, as a developer looking at ServiceConfig, how do

docs.spring.io


Formatter를 지원하는 Conversion Service

 

Conversion Service에는 Converter만 등록할 수 있고 Formatter를 등록할 수 없다. 하지만 Formatter도 특별한 버전의 Converter일 뿐인데 Conversion Service에 등록할 수는 없을까?

 

Formatter를 지원하는 Conversion Service를 사용하면 등록할 수 있다. 내부에서 Adaptor 패턴을 사용하여 FormatterConverter처럼 동작하게 지원해준다.

 

FormattingConversionService는 Formatter를 지원하는 Conversion Service이다!!

DefaultFormattingConversionServiceFormattingConversionService에 기본적인 통화, 숫자 관련 몇가지 기본 Formatter를 추가하여 제공한다.

다이어그램

위와 같이 DefaultFormattingConversionServiceConverterFormatter를 모두 사용할 수 있도록 위와 같은 상속 구조를 가지고 있다.

 

 

등록할 때만 addConverter, addFormatter로 다르게 등록하지만 사용하는 입장에서는 동일하게 convert메서드를 사용하면 된다.

ConversionService에서도 Formatter를 사용할 수 있게 되었다.

 

Spring Boot는 DefaultFormattingConversionService를 상속받는 WebConversionSerivce를 내부적으로 사용한다.

 

따라서 실제 사용을 위해선 그냥 registryaddFormatter를 통해 등록해주기만하면 된다.

이 때 위에서 만든 MyNumberFormatter 또한 결과적으로는 숫자를 문자로, 문자를 숫자로 변환하는 것이기 때문에 StringToIntegerConverter, IntegerToStringConverter와 동일한 기능을 수행하는 것으로 인식된다.따라서 우선순위가 적용되게되는데 Converter가 Formatter보다 높은 우선순위를 가지기 때문에 Formatter를 사용해보기 위해 주석처리하였다.

 

view에서 Formatter가 사용되어 숫자 사이에 쉼표가 찍혀있는 것을 확인할 수 있고, 로그에서도 MyNumberFormatter가 사용되었음을 확인할 수 있다.

 

이번에는 반대로 쿼리 파라미터로 "1,000"을 받아 숫자로 변환이 되는지를 확인해보자.

매핑될 컨트롤러

만약 이를 처리하는 Formatter가 없었다면 TypeMismatch가 발생해야하지만 현재는 Formatter가 정상 동작하므로 오류없이 처리된다.

로그

 


Spring이 제공하는 기본 Formatter

 

Spring은 자바 기본 타입에 대한 수 많은 Formatter를 제공하고 있다. Spring의 Formatter 인터페이스를 구현한 수많은 클래스들이 존재한다.

Formatter 구현체

하지만 이는 기본 형식이 지정되어 있어 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다는 문제가 존재하는데 스프링은 이를 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 Formatter 두 가지를 제공한다.

 

1. @NumberFormat : 숫자 관련 형식 지정 포맷터 사용(NumberFormatAnnotationFormatterFactory)

2. @DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용(Jsr310DateTimeFormatAnnotationFormatterFactory)

 

사용하는 법은 굉장히 간단하다.

각 애노테이션의 pattern 속성을 사용하여 포맷팅을 할 패턴을 지정해주기만하면 된다.

폼 객체를 하나만들어 값을 넘겨주는 식으로 구현하였다.

위와 같이 pattern 속성을 통해 포맷을 설정해준다.

임의로 setter를 사용하여 Form객체에 값을 설정하고 model에 담아 값을 넘겨준 후 formatter-form.html을 호출하고 해당 html에서 submit 버튼을 클릭하면 "/formatter/edit"가 POST방식으로 호출되어 @ModelAttribute를 통해 값을 Form객체로 받고 이를 model에 담아 formatter-view.html에게 넘겨주는 전체적인 구조로 동작한다.

 

그럼 실제 화면을 보며 살펴보자.

formatter-form.html

Thymeleaf를 통해 th:field를 사용했기 때문에 Form객체에 setter로 설정한 값을 Formatter가 포맷팅하여 출력해주었다.

==> 숫자 10000을 문자 10,000으로 LocalDateTime을 날짜 포맷의 문자로

 

submit 버튼을 클릭하여 값을 전달한다.

formatter-view.html

이 전체적인 흐름을 이해하는 것이 중요하다. 생각해보면 submit 버튼을 눌러 form 데이터를 전송하게 되면

number와 localDateTime에는 문자로 아래와 같이 값이 전달된다.

이 문자를 @ModelAttribute는 Form객체에 집어넣으려 할 때, @NumberFormat과 @DateTimeFormat을 보고

이를 숫자 10000과 LocalDateTime객체로 변환시켜준다.

 

컨트롤러에서 다시 formatter-view.html을 호출하여 최종적으로 위와 같은 화면이 렌더링되었다.

중괄호를 하나만 쓴 ${form.number}와 ${form.localDateTime}은 해당 값을 그대로 출력하였고,

중괄호를 두개 사용한 ${{form.number}}와 ${{form.localDateTime}}은 Formatter가 동작하여 설정된 pattern에 맞춰 포맷팅되어 출력된 것이다.

반응형

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

파일 업로드  (0) 2021.08.12
API 예외 처리  (0) 2021.07.28
예외 처리, 오류 페이지  (0) 2021.07.24
필터, 인터셉터  (0) 2021.07.21
쿠키, 세션  (0) 2021.07.17