1. 스프링 타입 컨버터 소개
애플리케이션을 개발하다 보면 문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환하는 등 타입을 변환해야 하는 경우가 매우 많습니다. HTTP 요청 파라미터는 기본적으로 모두 문자(String) 형태로 처리되기 때문에, 이를 자바의 다른 타입으로 변환하는 과정이 필수적입니다.
1.1 고전적인 타입 변환 방식 (HttpServletRequest)
서블릿이 제공하는 HttpServletRequest를 사용하면 다음과 같이 직접 문자 타입을 숫자 타입으로 변환해야 합니다.
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"); // 문자 타입 조회 ("10")
Integer intValue = Integer.valueOf(data); // 숫자 타입으로 직접 변환
System.out.println("intValue = " + intValue);
return "ok";
}
}
- 실행: http://localhost:8080/hello-v1?data=10
- 분석: HTTP 쿼리 스트링으로 전달되는 data=10에서 10은 문자열 "10"입니다. 따라서 자바 코드 내에서 Integer.valueOf(data)를 통해 직접 숫자로 변환해야 하는 번거로움이 있습니다.
1.2 스프링 MVC의 자동 타입 변환 (@RequestParam)
스프링 MVC가 제공하는 @RequestParam을 사용하면 이 변환 과정을 스프링이 대신 처리해 줍니다.
// HelloController 내에 추가
@GetMapping("/hello-v2")
public String helloV2(@RequestParam Integer data) {
System.out.println("data = " + data);
return "ok";
}
- 실행: http://localhost:8080/hello-v2?data=10
- 분석: HTTP 요청 파라미터로 넘어온 문자 "10"을 스프링이 중간에서 Integer 타입의 숫자 10으로 자동 변환해 줍니다.
1.3 스프링의 타입 변환 적용 범위
스프링은 다음과 같은 다양한 위치에서 타입 변환을 자동으로 적용합니다.
- 스프링 MVC 요청 파라미터: @RequestParam, @ModelAttribute, @PathVariable
- @ModelAttribute UserData data 호출 시, 내부 필드의 타입에 맞추어 문자열을 자동 변환합니다.
- @PathVariable("userId") Integer data 호출 시, URL 경로인 문자열 /users/10에서 10을 Integer로 자동 변환합니다.
- 설정 정보 읽기: @Value 등으로 YML 파일의 정보를 자바 객체 타입으로 읽어들일 때 사용됩니다.
- 스프링 빈 정보 변환: XML 등에 설정된 스프링 빈 정보를 변환할 때 사용됩니다.
- 뷰 템플릿 렌더링: 뷰를 렌더링할 때 객체를 문자열로 변환하여 출력할 때 사용됩니다.
2. 타입 컨버터 인터페이스 (Converter)
스프링은 개발자가 직접 커스텀한 타입 변환 로직을 추가할 수 있도록 확장 가능한 컨버터 인터페이스를 제공합니다.
package org.springframework.core.convert.converter;
public interface Converter<S, T> {
T convert(S source);
}
- 모든 타입 변환에 이 인터페이스를 구현하여 등록할 수 있습니다. 예를 들어 String $\rightarrow$ Boolean, Boolean → String 등의 양방향 변환 컨버터를 각각 만들어 등록하면 됩니다.
참고 (PropertyEditor와의 비교)
과거에는 PropertyEditor라는 것을 사용해 타입을 변환했습니다. 하지만 PropertyEditor는 **동시성 문제(Thread-safe 하지 않음)**가 있어 타입 변환 시마다 매번 객체를 새로 생성해야 하는 치명적인 단점이 있었습니다. 반면, 스프링이 새롭게 도입한 Converter는 상태를 가지지 않아(Stateless) thread-safe하며, 기능 확장에도 훨씬 유리합니다.
2.1 기본 타입 컨버터 구현 예제
StringToIntegerConverter (문자 → 숫자)
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);
}
}
IntegerToStringConverter (숫자 → 문자)
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");
}
}
2.2 사용자 정의 타입 컨버터 구현 예제
단순한 기본 타입 외에 복잡한 객체 타입의 변환도 가능합니다. 문자열 "127.0.0.1:8080"과 같은 형식을 파싱하여 IP와 PORT 정보를 가진 IpPort 객체로 상호 변환하는 컨버터를 만들어 보겠습니다.
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;
}
}
- 롬복의 @EqualsAndHashCode를 사용하여 모든 필드가 같으면 equals()와 hashCode()가 동일한 결과를 반환하도록 처리했습니다. 이를 통해 new IpPort("127.0.0.1", 8080).equals(new IpPort("127.0.0.1", 8080)) 결과가 true가 됩니다.
StringToIpPortConverter (문자 → 객체)
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);
// "127.0.0.1:8080" -> ip: "127.0.0.1", port: 8080
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
IpPortToStringConverter (객체 → 문자)
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);
// IpPort 객체 -> "127.0.0.1:8080" 문자열 반환
return source.getIp() + ":" + source.getPort();
}
}
사용자 정의 컨버터 테스트 코드 추가
// ConverterTest 클래스 내에 추가
@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: 일반적인 1:1 변환을 담당하는 기본 타입 컨버터입니다.
- ConverterFactory: 전체 클래스 계층 구조에 대해 일괄 변환 처리가 필요할 때 사용합니다.
- GenericConverter: 정교한 구현이 가능하며, 대상 필드의 어노테이션 정보까지 활용할 수 있는 컨버터입니다.
- ConditionalGenericConverter: 특정 조건이 참인 경우에만 선택적으로 실행되는 조건부 컨버터입니다.
3. 컨버전 서비스 (Conversion Service)
타입 컨버터를 개별적으로 직접 호출하여 사용하는 것은 번거롭고 객체 간의 결합도가 높습니다. 스프링은 개별 컨버터들을 모아 한 곳에서 관리하고 편리하게 사용할 수 있도록 컨버전 서비스(ConversionService)를 제공합니다.
3.1 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);
}
- 기능: 특정 타입 간의 변환이 가능한지 여부를 판단(canConvert)하고, 실제로 변환 로직을 수행(convert)하는 직관적인 메서드를 제공합니다.
3.2 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() {
// 1. 등록 과정 (DefaultConversionService는 ConverterRegistry 인터페이스도 구현함)
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter());
conversionService.addConverter(new IntegerToStringConverter());
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
// 2. 사용 과정
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");
}
}
3.3 등록과 사용의 분리 및 인터페이스 분리 원칙 (ISP)
DefaultConversionService는 다음 두 가지 역할을 모두 수행할 수 있도록 인터페이스 두 개를 상속 및 구현했습니다.
- ConversionService: 컨버터를 사용하는 클라이언트 입장을 고려한 인터페이스 (조회 전용)
- ConverterRegistry: 컨버터를 등록하고 관리하는 시스템 입장을 고려한 인터페이스 (등록 전용)
이처럼 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않도록 인터페이스를 작게 나누어 분리하는 원칙을 인터페이스 분리 원칙(ISP, Interface Segregation Principle)이라고 합니다.
덕분에 컨버터를 사용하는 애플리케이션 코드는 구체적인 컨버터 클래스를 전혀 몰라도 되며, 오직 ConversionService 인터페이스에만 의존하여 간단히 타입을 변환할 수 있습니다.
4. 스프링에 Converter 적용하기
우리가 만든 커스텀 컨버터를 웹 애플리케이션에 실제로 등록하여 스프링이 내부적으로 호출할 수 있도록 설정해 보겠습니다.
4.1 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 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의 addFormatters() 메서드를 오버라이드하여 추가하고 싶은 컨버터를 손쉽게 등록할 수 있습니다.
4.2 동작 및 로그 확인
기존 코드 실행 확인 (StringToIntegerConverter)
- 호출 URL: http://localhost:8080/hello-v2?data=10
- 콘솔 출력 로그:
- StringToIntegerConverter : convert source=10 data = 10
- 분석: 이전에 스프링이 내부 기본 컨버터로 처리했던 @RequestParam Integer data에 직접 만든 StringToIntegerConverter가 먼저 매핑되어 동작합니다. 직접 등록한 컨버터가 스프링의 기본 탑재 컨버터보다 높은 우선순위를 가집니다.
사용자 정의 타입 IpPort 컨트롤러 등록 및 테스트
// HelloController 내에 추가
@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";
}
- 호출 URL: http://localhost:8080/ip-port?ipPort=127.0.0.1:8080
- 콘솔 출력 로그:
- StringToIpPortConverter : convert source=127.0.0.1:8080 ipPort IP = 127.0.0.1 ipPort PORT = 8080
- 처리 과정: @RequestParam 어노테이션을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResolver 내부에서 ConversionService를 호출하여 "127.0.0.1:8080" 문자열을 IpPort 객체로 적절하게 파싱 및 바인딩해 줍니다.
5. 뷰 템플릿에 컨버터 적용하기
타임리프는 뷰 템플릿 렌더링 시 스프링의 컨버전 서비스와 통합되어 자동으로 컨버팅된 결과를 출력하는 특수한 문법을 지원합니다. 이번에는 그 반대 방향인 객체 $\rightarrow$ 문자 변환 처리를 뷰에 적용해 보겠습니다.
5.1 ConverterController 추가
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";
}
}
5.2 뷰 템플릿 (converter-view.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>
<li>${{ipPort}}: <span th:text="${{ipPort}}"></span></li>
</ul>
</body>
</html>
- 타임리프의 일반 변수 식은 ${...}를 사용하지만, 중괄호를 한 번 더 감싸서 ${{...}}를 사용하면 스프링의 컨버전 서비스를 거쳐서 변환된 문자를 출력합니다.
실행 및 결과
- 호출 URL: http://localhost:8080/converter-view
- 화면 렌더링 결과:
- ${number}: 10000
- ${{number}}: 10000 (내부적으로 IntegerToStringConverter 호출)
- ${ipPort}: hello.typeconverter.type.IpPort@59cb0946 (객체의 주소값 또는 toString)
- ${{ipPort}}: 127.0.0.1:8080 (내부적으로 IpPortToStringConverter 호출)
- 콘솔 출력 로그:
- IntegerToStringConverter : convert source=10000 IpPortToStringConverter : convert source=hello.typeconverter.type.IpPort@59cb0946
5.3 폼(Form)에 컨버터 적용하기
타임리프의 th:field 기능은 HTML input의 id, name, value를 자동으로 맞춰 주는 것뿐만 아니라, 스프링 컨버전 서비스의 타입 변환 기능도 자동으로 함께 처리해 줍니다.
ConverterController 코드 보강 (폼 전송 기능 추가)
// ConverterController 클래스 내에 추가 및 수정
import lombok.Data;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
@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";
}
@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;
}
}
}
폼 입력 뷰 템플릿 (converter-form.html)
<!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>
실행 메커니즘 분석
- GET /converter/edit 호출 시 (서버 → 뷰):
- th:field="*{ipPort}" 부분에서 컨버전 서비스가 자동으로 돌며 IpPortToStringConverter가 실행됩니다.
- 이에 따라 최종 렌더링된 HTML 소스 코드에는 value="127.0.0.1:8080" 값이 자연스럽게 주입되어 노출됩니다.
- POST /converter/edit 전송 시 (뷰 → 서버):
- 유저가 입력란의 문자열 "127.0.0.1:8080"을 담아 폼을 서브밋합니다.
- 서버 단에서 수신하는 @ModelAttribute Form form 객체의 ipPort 필드 데이터 형식이 원래 IpPort 타입이므로, 스프링이 내부의 StringToIpPortConverter를 실행하여 폼 안의 필드를 자동으로 객체로 복원합니다.
6. 포맷터 (Formatter)
Converter는 객체에서 객체로 변환하는 매우 범용적이고 제한 없는 전천후 타입 변환을 지원합니다. 하지만 일반적인 웹 환경을 살펴보면 문자 <-> 객체 간의 상호 변환 수요가 거의 대부분을 차지합니다.
- 예시 1: 숫자 1000 → 문자 "1,000" (천 단위 쉼표 포맷팅) / 문자 "1,000" → 숫자 1000 파싱
- 예시 2: 날짜 객체 → 문자 "2021-01-01 10:50:11" 포맷팅 / 문자 "2021-01-01 10:50:11" → 날짜 객체 파싱
- 여기에 더해 각 나라/언어권별(Locale)로 고유하게 선호하는 표현 방식까지 적용해야 합니다.
이러한 객체를 특정한 형식(Format)에 맞춰 문자로 변환하거나, 문자로부터 객체로 파싱하는 기능에 특화된 것이 바로 포맷터(Formatter)입니다. 포맷터는 컨버터의 특별한 형태(문자 특화 버전)로 이해하면 됩니다.
6.1 Converter vs Formatter
- Converter: 범용적 (객체 → 객체)
- Formatter: 문자 특화 (객체 → 문자, 문자 → 객체) + 현지화(Locale) 적용 기능 내장
6.2 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> {
}
6.3 포맷터 구현 예제 (MyNumberFormatter)
자바가 내장하는 NumberFormat 객체를 사용해 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);
// "1,000" -> 1000L (Long타입으로 변환됨)
return format.parse(text);
}
@Override
public String print(Number object, Locale locale) {
log.info("object={}, locale={}", object, locale);
// 1000 -> "1,000" 문자열로 변환됨
return NumberFormat.getInstance(locale).format(object);
}
}
- parse()에서 문자 형태의 숫자를 Locale에 부합하는 형식으로 실제 Number 객체(상황에 따라 Long, Double 등)로 파싱합니다.
- print()는 숫자형 객체를 입력받아 Locale에 특화된 포맷으로 가공된 포맷팅 문자열을 만들어 반환합니다.
포맷터 단위 테스트 코드
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); // NumberFormat.parse() 결과는 기본적으로 Long이므로 L을 붙여 비교
}
@Test
void print() {
String result = formatter.print(1000, Locale.KOREA);
assertThat(result).isEqualTo("1,000");
}
}
7. 포맷터를 지원하는 컨버전 서비스
기본적인 ConversionService는 내부적으로 컨버터만 등록 가능하며, 포맷터를 직접 관리할 수는 없습니다.
하지만 포맷터 역시 문자 → 객체 간 변환 로직을 가지는 특별한 형태의 컨버터일 뿐이므로, 어댑터 패턴을 응용하여 포맷터를 컨버터처럼 동작하도록 포장해 함께 등록할 수 있습니다.
스프링이 지원하는 FormattingConversionService는 포맷터를 내부에 등록하고 컨버전 서비스와 동일하게 convert() 메서드로 일관되게 활용하도록 해 주는 특화된 컨버전 서비스입니다.
7.1 구조적 특징
- FormattingConversionService: ConversionService와 ConverterRegistry 기능을 상속받았기에, 컨버터와 포맷터 모두 다 등록하고 사용할 수 있습니다.
- DefaultFormattingConversionService: FormattingConversionService에 기본 통화, 숫자 관련 유용한 기본 내장 포맷터들을 사전에 세팅해 두고 제공합니다.
- 추가로 스프링 부트는 내부적으로 DefaultFormattingConversionService를 한 번 더 상속받은 WebConversionService를 탑재하여 구동합니다.
7.2 포맷터를 지원하는 컨버전 서비스 테스트
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.FormattingConversionService;
import org.springframework.format.support.DefaultFormattingConversionService;
import static org.assertj.core.api.Assertions.assertThat;
public class FormattingConversionServiceTest {
@Test
void formattingConversionService() {
DefaultFormattingConversionService conversionService = new DefaultFormattingConversionService();
// 1. 일반 컨버터 등록
conversionService.addConverter(new StringToIpPortConverter());
conversionService.addConverter(new IpPortToStringConverter());
// 2. 포맷터 등록 (addFormatter 사용)
conversionService.addFormatter(new MyNumberFormatter());
// 3. 컨버터 호출 사용
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
// 4. 포맷터 호출 사용 (MyNumberFormatter가 어댑팅되어 convert 메서드로 투명하게 수행됨)
assertThat(conversionService.convert(1000, String.class)).isEqualTo("1,000");
assertThat(conversionService.convert("1,000", Long.class)).isEqualTo(1000L);
}
}
8. 웹 애플리케이션에 포맷터 적용하기
이제 만든 MyNumberFormatter를 웹 설정 파일인 WebConfig에 등록하고 연동시켜 사용해 봅니다.
8.1 WebConfig 수정 및 기존 컨버터 주석 해제 (중요)
package hello.typeconverter;
import hello.typeconverter.converter.IpPortToStringConverter;
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 StringToIntegerConverter());
// registry.addConverter(new IntegerToStringConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
// 커스텀 포맷터 등록
registry.addFormatter(new MyNumberFormatter());
}
}
★ 주의! 우선순위 충돌 해결하기StringToIntegerConverter와 MyNumberFormatter 둘 다 결국 "문자 ↔ 숫자" 상호 변환 작업을 타겟으로 동작합니다. 스프링의 내부 작동 매커니즘 상 "컨버터가 포맷터보다 우선순위가 더 높습니다." 따라서 기존에 작성한 컨버터를 그대로 두면 MyNumberFormatter가 절대 작동하지 않으므로, 테스트 시 해당 두 컨버터 등록 코드를 반드시 주석 처리해야 합니다.
8.2 웹 연동 테스트
1) 뷰 템플릿 렌더링 확인 (객체 → 문자)
- 호출 URL: http://localhost:8080/converter-view
- 출력 렌더링 결과:
- ${number}: 10000
- ${{number}}: 10,000
- 결과 분석: ${{number}} 출력 시 등록한 포맷터인 MyNumberFormatter가 동작하여 천 단위 구분 기호가 추가되어 깔끔하게 노출되는 것을 볼 수 있습니다.
2) 컨트롤러 요청 파라미터 파싱 확인 (문자 $\rightarrow$ 객체)
- 호출 URL: http://localhost:8080/hello-v2?data=10,000
- 서버 콘솔 로그:
- MyNumberFormatter : text=10,000, locale=ko_KR data = 10000
- 결과 분석: @RequestParam Integer data 수신 시 문자열 "10,000"을 정상적으로 캐치하여 콤마가 제거된 정수값 10000 객체로 안정적으로 수신해 줍니다.
9. 스프링이 제공하는 기본 포맷터
스프링은 자바 기본 데이터 포맷들을 포맷팅할 수 있는 풍부한 사후 기본 포맷터들을 내장하고 있습니다.
하지만 포맷터의 특성상 일괄 규칙(Global)이 정해져 있어 객체의 각 특정 개별 필드마다 정교하고 상이하게 다른 형식의 포맷을 강제하는 것이 어렵다는 난관이 존재합니다.
스프링은 이를 말끔하게 풀기 위해 어노테이션 정의만으로 각각 원하는 형식의 포맷팅 형태를 타겟 필드에 개별 선언해 사용할 수 있는 초강력 어노테이션 기반 기본 포맷터 두 가지를 기본 탑재하고 있습니다.
- @NumberFormat: 숫자 관련 형식 지정을 돕는 포맷터 (내부적으로 NumberFormatAnnotationFormatterFactory에 바인딩됨)
- @DateTimeFormat: 날짜/시간 관련 형식 지정을 돕는 포맷터 (내부적으로 Jsr310DateTimeFormatAnnotationFormatterFactory에 바인딩됨)
9.1 어노테이션 포맷터 실무 적용 예제
FormatterController 및 Form 객체 정의
package hello.typeconverter.controller;
import lombok.Data;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.format.annotation.NumberFormat;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import java.time.LocalDateTime;
@Controller
public class FormatterController {
@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";
}
@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;
}
}
폼 작성 입력 뷰 (formatter-form.html)
<!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">
number <input type="text" th:field="*{number}"><br/>
localDateTime <input type="text" th:field="*{localDateTime}"><br/>
<input type="submit"/>
</form>
</body>
</html>
렌더링 결과 확인 뷰 (formatter-view.html)
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<ul>
<li>${form.number}: <span th:text="${form.number}"></span></li>
<li>${{form.number}}: <span th:text="${{form.number}}"></span></li>
<li>${form.localDateTime}: <span th:text="${form.localDateTime}"></span></li>
<li>${{form.localDateTime}}: <span th:text="${{form.localDateTime}}"></span></li>
</ul>
</body>
</html>
실행 및 결과 확인
- 최초 폼 로딩 (GET /formatter/edit):
- 숫자 필드 인풋 박스에는 10,000 형태로 콤마가 포함되어 노출됩니다.
- 시간 필드 인풋 박스에는 지정된 패턴인 2021-01-01 00:00:00 형태로 파싱 및 포맷팅되어 노출됩니다.
- 폼 수정 전송 시 (POST /formatter/edit):
- 전달된 파라미터가 패턴에 맞게 자동으로 Integer 및 LocalDateTime 객체 타입으로 매끄럽게 역바인딩되어 가공 처리됩니다.
- 최종 렌더링 뷰 결과:
- ${form.number}: 10000
- ${{form.number}}: 10,000
- ${form.localDateTime}: 2021-01-01T00:00:00 (객체의 기본 ISO 형식 포맷 출력)
- ${{form.localDateTime}}: 2021-01-01 00:00:00 (지정된 날짜 포맷 패턴으로 완벽 가공 출력)
10. 스프링 타입 컨버터 강의 핵심 요약 및 정리
- 일관성 있는 사용 방식: 내부 등록 방식이 일반적인 Converter이든 특화된 Formatter이든, 실제 프로그램 내에서 활용할 때는 일률적으로 ConversionService를 통해서 투명하고 일관성 있게 꺼내 사용할 수 있습니다.
- 컨버전 서비스 지원 영역: 스프링 컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable 등의 파라미터 영역과 뷰 템플릿(Thymeleaf 등) 및 설정 정보 가공 바인딩 시점에서 원활하게 작동합니다.
⚠️ 주의: HTTP 메시지 컨버터(HttpMessageConverter)와의 무관함
가장 많이 하는 오해 중 하나는 "Jackson 라이브러리(HttpMessageConverter) 등이 객체를 JSON으로 바꿀 때도 스프링 컨버전 서비스가 적용된다"는 생각입니다.
이는 완전히 틀린 오해입니다.
- HttpMessageConverter (대표적으로 JSON 바인딩 담당 라이브러리)는 HTTP 요청/응답 바디 내용 자체를 자바 객체로 변환하거나 객체를 문자열 바디에 통째로 기입하기 위한 것으로, 스프링 컨버전 서비스와는 전혀 독립적이며 무관하게 작동합니다.
- JSON 바인딩에 영향을 끼치는 객체 직렬화/역직렬화 및 특정 날짜 포맷 변경은 Jackson 라이브러리가 주도하는 변환 영역이기 때문에, 포맷을 변경하고 싶다면 스프링 컨버전 서비스가 아니라 해당 라이브러리 자체 설정을 수정해야 합니다. (예: Jackson 어노테이션 활용 등)
'INFLEARN' 카테고리의 다른 글
| [스프링 DB 1편 - 데이터 접근 핵심 원리] 1. JDBC 이해 (0) | 2026.07.07 |
|---|---|
| [스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술] 11. 파일 업로드 (0) | 2026.07.05 |
| [스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술] 9. API 예외 처리 (0) | 2026.07.04 |
| [스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술] 8. 예외 처리와 오류 페이지 (0) | 2026.07.04 |
| [스프링 MVC 2편 - 백엔드 웹 개발 핵심 기술] 7. 로그인처리1 - 필터, 인터셉트 (0) | 2026.07.02 |