프로젝트 생성
- 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
- 프로젝트 선택
- Project
- Gradle - Groovy Project
- Language
- Java
- Spring Boot
- 3.x.x
- Project
- Project Metadata
- Group
- hello
- Artifact
- typeconverter
- Name
- typeconverter
- Package name
- hello.typeconverter
- Packaging
- Jar (주의!)
- Java
- 17 또는 21
- Group
- Dependencies
- Spring Web
- Thymeleaf
- Lombok
- Validation
- 프로젝트 선택
스프링 타입 컨버터 소개
- 개발을 하다보면 형변환을 해야할 경우가 상당히 많다.
String data = request.getParameter("data"); //문자 타입 조회
Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경
형변환을 하는 이유
- HTTP 요청 파라미터는 모두 문자로 처리된다.
- 그래서 다른 타입으로 사용하고 싶으면 해당 타입으로 변환하는 과정을 거쳐야 한다.
파라미터를 정수로 받기기
- 우리는 이미
@RequestParam
이라는 애노테이션을 알고 있다. - 그냥
@RequestParam Integer data
처럼 받으면 되지 않나? 싶을 것이다.- 그렇다면 왜
@RequestParam Integer data
처럼 받으면 정수형으로 바로 받을 수 있는 것일까?
- 그렇다면 왜
- 우리가
@RequestParam Integer data
으로 데이터를 받았을 때 정수형인 것은 스프링이 중간에서 타입을 변환해주었기 때문이다. - 이는
@RequestParam
뿐만이 아니라@ModelAttribute
와@PathVariable
도 해당한다.@ModelAttribute UserData data
로 파라미터가 명시되어 있을 때UserData
클래스 내부에 정수형 필드가 있다면 정수형으로 받을 것이다.@PathVariable("userId") Integer data
로 파라미터가 명시되어 있다면 data를 정수형으로 받을 것이다.
스프링이 타입 변환을 해 주는 경우
- 스프링 MVC 요청 파라미터
@RequestParam
@ModelAttribute
@PathVariable
@Value
등으로 YML 정보 읽기- XML에 넣은 스프링 빈 정보를 변환
- 뷰를 렌더링 할 때
문자와 숫자만 가능한걸까?
- Boolean 타입을 숫자로 변경하는 것도 가능하다.
- 그럴 때는 스프링이 제공하는 확장 가능한 컨버터 인터페이스를 구현해서 등록해두면 된다.
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
- 만약 문자열인 “true”를 Boolean인 true로 받고 싶다면,
String에서 Boolean으로 변환하는 컨터버 인터페이스를 만들어서 등록하면 된다. 만약 그 반대로 Boolean인 true를 문자열인 “true”로 받고 싶다면,
그저 Boolean에서 String으로 변환하는 컨터버 인터페이스를 만들어서 등록하면 된다.- 과거에는 PropertyEditor라는 것으로 타입을 변환했다.
- PropertyEditor는 동시성 문제가 있어서 타입을 변환할 때 마다 객체를 계속 생성해야 하는 단점이 있었다.
- 지금은 Converter 의 등장으로 해당 문제들이 해결되었고, 기능 확장이 필요하면 Converter 를 사용하면 된다.
타입 컨버터 - Converter
- 타입 컨버터를 사용하려면
org.springframework.core.convert.converter.Converter
인터페이스를 구현하면 된다.
문자 to 숫자
- 문자를 숫자로 변환해주는 컨버터를 만들어보자.
package hello.typeconverter.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class StringToIntegerConverter implements Converter<String, Integer> {
@Override
public Integer convert(String source) {
log.info("convert source={}", source);
return Integer.valueOf(source);
}
}
숫자 to 문자
- 숫자를 문자로 변환해주는 컨버터를 만들어보자.
package hello.typeconverter.converter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.convert.converter.Converter;
@Slf4j
public class IntegerToStringConverter implements Converter<Integer, String> {
@Override
public String convert(Integer source) {
log.info("convert source={}", source);
return String.valueOf(source);
}
}
테스트
package hello.typeconverter.converter;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
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");
}
}
컨버전의 방식은 다양하다.
- 단순히 자료형끼리만 바꾸는 건 아니다.
127.0.0.1:8080
처럼 아이피와 포트가 합쳐져 있는 문자열을 전달받으면 객체로 만드는 컨버터도 가능하다.
객체 생성하기
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;
}
}
문자 to (아이피 + 포트)
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);
}
}
(아이피 + 포트) to 문자
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();
}
}
테스트
@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");
}
컨버터의 종류
- 스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
Converter
- 기본 타입 컨버터
ConverterFactory
- 전체 클래스 계층 구조가 필요할 때 사용
GenericConverter
- 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter
- 특정 조건이 참인 경우에만 실행
- 공식 문서
- 스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.
컨버전 서비스 - 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);
}
테스트
DefaultConversionService
는ConversionService
인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.
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");
}
}
등록과 사용 분리
- 컨버터를 등록할 때는
StringToIntegerConverter
같은 타입 컨버터를 명확하게 알아야 한다.- 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다.
- 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다.
- 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다.
- 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
컨버전 서비스 사용
Integer value = conversionService.convert("10", Integer.class)
인터페이스 분리 원칙 (ISP)
- 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
- 그래서
DefaultConversionService
는 2가지 인터페이스를 분리해서 구현했다.ConversionService
- 컨버터 사용에 초점
ConverterRegistry
- 컨버터 등록에 초점
- 그래서 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.
- 컨버터를 사용하는 클라이언트는
ConversionService
만 의존하면 된다. - 컨버터를 사용하는 클라이언트는 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다.
- 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.
- 컨버터를 사용하는 클라이언트는
스프링에 Converter 적용하기
애플리케이션에 컨버터를 적용해보자.
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());
}
}
컨트롤러 만들기
- 테스트를 위해 임의의 컨트롤러를 만들어서 아래 메소드를 추가해주자.
@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
를 호출하고 콘솔을 확인해보면 잘 동작하는 것을 알 수 있다.
ipPort IP = 127.0.0.1
ipPort PORT = 8080
뷰 템플릿에 컨버터 적용하기
- 타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.
- 타임리프는
$
를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.- 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.
- 기본적인 변수 표현식과는 중괄호 한 단계가 차이난다.
- 기본 변수 표현식
${...}
- 컨버전 서비스 적용
$
- 기본 변수 표현식
컨버터를 폼에 적용하기
- 컨버터는 폼 객체의 정보를 쉽게 다룰수 있게 도와주는 역할도 한다.
th:object="${form}"
처럼 명시하면 해당 태그 및 하위 태그에서 전달받은 객체 데이터를 사용할 수 있다.th:field="*{ipPort}"
처럼 명시하면 해당 태그의 id, name, value를 지정할 수 있다.- 만약에 아이피를
127.0.0.1
로, 포트를8080
로 생성한IpPort
객체가 있고 그걸 폼으로 넘겼다고 가정해보자. - 그걸
<input type="text" th:field="*{ipPort}">
처럼 사용할 것이다. - 그러면 해당 태그는
<input type="text" id="ipPort" name="ipPort" value="127.0.0.1:8080">
으로 랜더링될 것이다. - IpPort로 넘겼는데
127.0.0.1:8080
이 되는 이유는 컨버터로 등록한IpPortToStringConverter
때문이다.
- 만약에 아이피를
- 그런데
th:value="*{ipPort}"
처럼 명시하면 아래처럼 랜더링된다.<input type="text" value="hello.typeconverter.type.IpPort@59cb0946">
포맷터 - Formatter
Converter
는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.- 다만 개발자 입장에서 일반적인 웹 애플리케이션 환경을 생각해보자.
- Boolean을 Integer로 바꾸는 범용 기능 보다는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
- 예시
- 숫자
1000
과 문자1,000
간에 서로 변환하는 경우 - 날짜 객체를 문자로 바꾸거나, 문자를 날짜 객체로 변환하는 경우
- 숫자
- 게다가 숫자의 경우에는 현지화 정보인
Locale
도 포함되어 있을 수도 있다.
포맷터의 등장
- 포맷터는 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능을 가지고 있다.
- 특별한 버전의 컨버터라고 이해하면 된다.
Converter vs Formatter
Converter
- 객체 <>=> 객체
Formatter
- 문자에 특화
- 객체 <=> 문자 + 현지화(Locale)
- Converter 의 특별한 버전
인터페이스
- Formatter의 인터페이스를 확인해보면 문자에 특화되어 있다는 것을 확인할 수 있다.
String print(T object, Locale locale)
- 객체를 문자로 변경한다.
T parse(String text, Locale locale)
- 문자를 객체로 변경한다.
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> {
}
문자 <=> 숫자 포맷터 만들기
- 문자
1,000
은 숫자1000
으로, 숫자1000
은 문자1,000
으로 바꿔주는 포맷터를 만들어보자.
package hello.typeconverter.formatter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.format.Formatter;
import java.text.NumberFormat;
import java.text.ParseException;
import java.util.Locale;
@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);
}
}
테스트
package hello.typeconverter.formatter;
import org.junit.jupiter.api.Test;
import java.text.ParseException;
import java.util.Locale;
import static org.assertj.core.api.Assertions.*;
class MyNumberFormatterTest {
MyNumberFormatter formatter = new MyNumberFormatter();
@Test
void parse() throws ParseException {
Number result = formatter.parse("1,000", Locale.KOREA);
assertThat(result).isEqualTo(1000L); //parse의 결과값이 Long 타입이라서 L 명시
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
포맷터의 종류
- 스프링은 용도에 따라 다양한 방식의 포맷터를 제공한다.
Formatter
- 기본 포맷터터
AnnotationFormatterFactory
- 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터
- 공식 문서
포맷터를 지원하는 컨버전 서비스
- 컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다.
- 다만 포맷터는 문자에 특화된 특별한 컨버터일 뿐이다.
- 그래서 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다.
- 내부에서 어댑터 패턴을 사용해서
Formatter
가Converter
처럼 동작하도록 지원한다.
- 내부에서 어댑터 패턴을 사용해서
- 포맷터를 지원하는 컨버전 서비스인
FormattingConversionService
를 사용하면 된다. DefaultFormattingConversionService
는FormattingConversionService
에
기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.
테스트
package hello.typeconverter.formatter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.type.IpPort;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;
import static org.assertj.core.api.Assertions.assertThat;
public class FormattingConversionServiceTest {
@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
관련 기능을 상속받는다.- 그래서 컨버터도 포맷터도 모두 등록할 수 있다.
- 사용할 때는
ConversionService
가 제공하는convert
를 사용하면 된다.
- 추가로 스프링 부트는
DefaultFormattingConversionService
를 상속 받은WebConversionService
를 내부에서 사용한다.
포맷터 적용하기
포맷터 등록하기
- WebConfig의 addFormatters에 포맷터를 등록해보자.
- 메소드명을 보면 컨버터와 포맷터를 등록하는 방법의 차이점을 알 수 있다.
- 컨버터는
addConverter
를 통해 등록한다. - 포맷터는
addFormatter
를 통해 등록한다.
registry.addFormatter(new MyNumberFormatter());
- 포맷터를 등록하든 컨버터를 등록하든 주의해야할 사항이 있다.
- 바로 중복되는 기능이 있는지 확인해야 한다는 것이다.
MyNumberFormatter
는 문자와 숫자를 서로 변환해주는 기능이다.StringToIntegerConverter
와IntegerToStringConverter
의 기능과 중복되니 이 둘을 주석처리 해줘야 한다.
스프링이 제공하는 기본 포맷터
- 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
- Formatter 인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.
- 다만 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
- 스프링은 이런 문제를 해결하기 위해 애노테이션 기반의 포맷터 두 가지를 제공한다.
@NumberFormat
- 숫자 관련 형식 지정 포맷터 사용
NumberFormatAnnotationFormatterFactory
@DateTimeFormat
- 날짜 관련 형식 지정 포맷터 사용
Jsr310DateTimeFormatAnnotationFormatterFactory
포맷터 적용 객체 만들기
@Data
static class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
컨트롤러 만들기
- 포맷터가 적용됬는지 확인하기 위해 컨트롤러 메소드를 만들자.
@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";
}
데이터 출력을 위한 페이지 만들기
- 데이터 확인을 위해서 간단한 페이지도 하나 만들어주자.
<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>
테스트
- 이제 서버를 실행해서
http://localhost:8080/formatter/edit
로 접속해보자.number
는 포맷터가 적용되서10,000
로 출력되는 것을 확인할 수 있다.localDateTime
은 포맷터가 적용되서2025-02-01 19:22:52
로 출력되는 것을 확인할 수 있다.