김영한 님의 '스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술'을 듣고 적은 글입니다.
먼저 스프링 부트 스타터로 이동해 스프링 프로젝트를 만들어준다. 이때 Spring Web, Lombok, Thymeleaf를 추가해주자.
문자를 숫자로 변환하거나 숫자를 문자로 변환해야 하는 것처럼 타입을 변환해야 하는 경우가 상당히 많다.
package hello.typeconverter.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
@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";
}
}
String data = request.getParameter("data"), Integer.valueOf(data)
HTTP 요청 파라미터는 모두 문자로 처리되기에 요청 파라미터를 다른 타입으로 변환해주고 싶으면 이렇게 숫자 타입으로 변환하는 과정을 거쳐야 한다.
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
스프링 MVC가 제공하는 @RequestParam을 사용할 수도 있다.
ex) http://localhost:8080/hello-v2?data=10
HTTP 쿼리 스트링으로 전달하는 data=10부분에서 10은 문자이다. 스프링이 제공하는 @RequestParam을 사용하면 문자 10을 Integer 타입의 10으로 받을 수 있다.
이것은 스프링이 중간에서 타입을 변환해주었기 때문이다.
이런 예는 @ModelAttribute, @PathVariable 에서 확인할 수도 있다.
이렇게 타입을 변환해야 하는 경우는 상당히 많다. 스프링이 중간에 타입 변환기를 사용해 타입을 변환해주기에 개발자는 편리하게 해당 타입을 바로 받을 수 있다.
만약 개발자가 새로운 타입을 만들어 변환하고 싶다면 컨버터 인터페이스를 이용하면 된다.
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source); //S를 T로
}
스프링은 확장 가능한 컨버터 인터페이스를 제공한다.
스프링에 추가적 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해 등록하면 된다. 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다.
타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다.
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);
}
}
String 타입을 Integer로 변환하기에 이 메서드의 매개변수는 String 타입이 된다.
Integer.valueOf()를 사용해 숫자로 변경한 후 변경된 숫자를 리턴한다.
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);
}
}
앞의 컨버터와 정반대의 역할을 하기에 위의 로직을 반대로 바꿔주면 된다. 이번엔 숫자가 입력되기에 입력한 숫자를 String.valueOf()를 사용해 문자로 변경한 뒤 반환한다.
127.0.0.1:8080과 같은 IP와 PORT를 입력하면 IpPort 객체로 변환하는 컨버터를 만든다.
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;
}
}
Lombok의 @EqualsAndHashCode를 넣으면 모든 필드를 사용해 equals(), hashcode()를 생성한다. 모든 필드의 값이 같다면 a.equals(b)의 결과가 참이 된다.
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);
}
}
127.0.0.1:8080과 같은 문자를 입력하면 : 기호를 기준으로 문자열을 분리한 뒤 IpPort 객체를 만들어 반환한다.
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();
}
}
IpPort 객체를 입력하면 127.0.0.1:8080 같은 문자를 반환한다.
스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다
Converter : 기본 타입 컨버터ConverterFactory : 전체 클래스 계층 구조가 필요할 때GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능ConditionalGenericConverter : 특정 조건이 참일때만 실행타입 컨버터를 하나씩 직접 찾아 변환하는 것은 불편하다. 그래서 스프링은 개별 컨테이너를 모아두고 묶어서 편리하게 사용가능한 컨버전 서비스를 제공한다.
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);
}
컨버전 서비스 인터페이스는 컨버팅을 확인하는 기능과 컨버팅 기능을 제공한다.
package hello.typeconverter.converter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
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); //문자 10을 숫자 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");
}
}
이렇게 우리가 앞서 만든 컨버터들을 등록하고 필요한 컨버터들만 사용할 수 있다.
컨버터를 등록할 때는 타입 컨버터를 명확히 알아야한다. 반면 컨버터를 사용하는 입장에선 타입 컨버터를 몰라도 된다. 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어 제공된다. 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다. 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.
DefaultConversionService는 다음 두 인터페이스를 구현했다.
ConversionService : 컨버터 사용ConversionRegistry : 컨버터 등록이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록/관리하는 클라이언트의 관심사를 명확히 분리할 수 있다. 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되기에 컨버터를 어떻게 등록하고 관리하는지 몰라도 된다.
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());
}
}
스프링은 내부에서 ConversionService를 제공한다. 우리는 WebMvcConfigurer가 제공하는 addConverter()를 사용해 추가하고 싶은 컨버터에 등록하면 된다. 이렇게 하면 스프링 내부에서 사용하는 ConversionService에 컨버터를 추가해준다.
?data=10의 쿼리 파라미터는 문자이고 이것을 Integer형으로 변환하는 과정이 필요한데 실행해보면 우리가 등록한 StringToIntegerConverter가 작동하는 로그를 확인할 수 있다.
물론 우리가 만든 컨버터를 등록하기 전에도 잘 돌아갔다. 스프링이 내부에서 수많은 기본 컨버터들을 제공하기 때문이다. 컨버터를 추가하면 추가한 컨버터가 기본 컨버터보다 높은 우선순위를 가진다.
타임리프는 렌더링 시 컨버터를 적용해 렌더링하는 방법을 편리하기 지원한다.
이전까진 문자를 객체로 변환했다면, 이번엔 객체를 문자로 변환하는 작업을 한다.
package hello.typeconverter.controller;
import hello.typeconverter.type.IpPort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class ConverterController {
@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";
}
}
Model에 1000과 ipPort 객체를 담아 뷰 템플릿에 전달한다.
그 후 해당되는 HTML파일을 만들어준다.
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>${number}: <span th:text="${number}" ></span></li>
<li>${{number}}: <span th:text="${{number}}" ></span></li><!-- 컨버터 적용. 문자로 바꿈 -->
<li>${ipPort}: <span th:text="${ipPort}" ></span></li> <!-- 그냥 출력시 toString() 호출됐기에 복잡한 문자열이 출력 -->
<li>${{ipPort}}: <span th:text="${{ipPort}}" ></span></li> <!-- 컨버터 적용. ipPort를 문자로 바꿈 -->
</ul>
</body>
</html>
${...} : 변수 표현식${{...}} : 컨버터 서비스 적용. 이때 th:field=*{...} 는 괄호 한쌍만 적어도 컨버전 서비스가 적용된다.@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;
}
}
<!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">
th:field <input type="text" th:field="*{ipPort}"><br/>
th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
<input type="submit"/>
</form>
</body>
</html>
앞서 만든 ConverterController에 위의 코드를 추가해준다.
GET /converter/edit : IpPort를 뷰 템플릿 폼에 출력. th:field가 자동으로 컨버전 서비스를 적용해 ${{...}} 처럼 적용됨. POST /converter/edit : 뷰 템플릿 폼의 IpPort 정보 받아 출력. @ModelAttribute를 사용해 String -> IpPort로 변환객체를 특정한 포맷에 맞춰 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터이다. 포맷터는 컨버터의 특별한 버전으로 이해하면 된다.
ex) 문자 "1,000" -> 숫자 1000
컨버터는 범용적으로 사용 가능하고 포맷터는 문자에 특화되어 있다고 생각하면 된다.
포맷터는 객체를 문자로 변경하고 문자를 객체로 변경하는 두가지 기능을 모두 수행한다.
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 object, Loale locale) : 객체 -> 문자T parse(String text, Locale locale) : 문자 -> 객체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);
}
}
"1,000"처럼 숫자 중간 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해 나라별로 다른 숫자 포맷을 만들어준다.
컨버전 서비스에는 컨버터만 등록할 수 있고 포맷터를 등록할 순 없다. 그런데 포맷터는 특별한 컨버터일 뿐이다.
포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해 Formatter가 Converter처럼 동작하도록 지원한다.
FormattingConversionService는 포맷터를 지원하는 컨버전 서비스이다.
package hello.typeconverter.formatter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.type.IpPort;
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;
import static org.assertj.core.api.Assertions.*;
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 관련 기능을 상속받기에 결과적으로 컨버터도 포맷터도 모두 등록할 수 있다. 그리고 컨버터/포맷터 사용 시에 convert()를 사용하면 된다.
포맷터를 적용하기 위해선 WebConfig를 수정해줘야 한다.
package hello.typeconverter;
import hello.typeconverter.converter.IntegerToStringConverter;
import hello.typeconverter.converter.IpPortToStringConverter;
import hello.typeconverter.converter.StringToIntegerConverter;
import hello.typeconverter.converter.StringToIpPortConverter;
import hello.typeconverter.formatter.MyNumberFormatter;
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 StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
/* registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new IntegerToStringConverter());
둘 다 숫자를 문자로 바꾸는데 우리가 만든 포맷터도 숫자를 문자로 바꿈.
똑같은건데 컨버터의 우선순위가 높기에 주석처리 함.*/
//새로 추가
registry.addFormatter(new MyNumberFormatter());
}
}
스프링은 자바에서 기본으로 제공하는 타입들에 대해 수많은 포맷터를 기본으로 제공한다.
그런데 포맷터는 기본 형식이 지정되어 있기에 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다. 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해 사용할 수 있는 포맷터 두가지를 제공한다
@NumberFormat : 숫자 관련 형식 지정 포맷터 사용@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용pattern을 사용해 형식을 지정해줄 수 있다.
EX)
@Data
static class Form {
@NumberFormat(pattern = "###,###")
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime localDateTime;
}
메시지 컨버터에는 컨버전 서비스가 적용되지 않는다. HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다.
JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해 포맷을 지정해야 한다.