2025. 12. 23. 02:12ㆍSpring RoadMap/Spring MVC
백엔드 웹 개발 핵심 기술, 스프링 MVC - 타입 컨버터 (Type Converter)

HTTP 요청 파라미터는 모두 문자열(String)이다. ?age=10이라고 보내도 서버 입장에서는 문자열 "10"이 넘어온다. 하지만 우리는 컨트롤러에서 @RequestParam Integer age라고 적기만 하면 숫자 10을 받는다.
누군가가 중간에서 타입을 변환해주고 있다는 뜻이다. 스프링이 제공하는 타입 컨버터가 그 역할을 한다.
1. 스프링의 기본 타입 변환
스프링은 이미 수많은 컨버터를 내장하고 있다.
- String <-> Integer, Long, Double
- String <-> Boolean
- String <-> Enum
- String <-> IpPort (직접 만들 예제)
1-1. 과거의 방식: PropertyEditor
스프링 초기에는 PropertyEditor라는 것을 사용했다. 하지만 얘는 동시성 문제가 있어서 쓰레드에 안전하지 않다. 따라서 빈으로 등록해서 쓸 수 없고, 매번 객체를 새로 생성해야 했다. (지금은 거의 안 쓴다. Deprecated 취급해도 무방하다.)
1-2. 새로운 방식: Converter
스프링 3.0부터 등장한 Converter 인터페이스는 **쓰레드에 안전(Thread-safe)**하다. 따라서 빈으로 등록해서 싱글톤으로 관리할 수 있다.
2. 직접 컨버터 만들기 (Converter 인터페이스)
사용자 정의 타입 변환을 위해 Converter 인터페이스를 구현해보자. 예시로 "127.0.0.1:8080"이라는 문자열을 IpPort라는 객체로 변환하는 과정을 만든다.
IpPort 객체
@Getter
@EqualsAndHashCode // 테스트를 위해 equals 자동 생성
@AllArgsConstructor
public class IpPort {
private String ip;
private int port;
}
2-1. 문자를 객체로 (StringToIpPortConverter)
@Slf4j
public class StringToIpPortConverter implements Converter<String, IpPort> {
@Override
public IpPort convert(String source) {
log.info("convert source={}", source);
// "127.0.0.1:8080" -> IpPort 객체
String[] split = source.split(":");
String ip = split[0];
int port = Integer.parseInt(split[1]);
return new IpPort(ip, port);
}
}
- org.springframework.core.convert.converter.Converter를 import 해야 한다.
2-2. 객체를 문자로 (IpPortToStringConverter)
@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();
}
}
3. 컨버전 서비스 (ConversionService)
이렇게 만든 컨버터들을 하나하나 직접 호출해서 쓰는 것은 불편하다. 스프링은 "컨버터들을 모아두고, 묶어서 편리하게 쓸 수 있는 기능"인 ConversionService를 제공한다.
3-1. ConversionService 인터페이스
public interface ConversionService {
boolean canConvert(Class<?> sourceType, Class<?> targetType);
<T> T convert(Object source, Class<T> targetType);
}
- ISP(인터페이스 분리 원칙): 스프링은 등록하는 인터페이스(ConverterRegistry)와 사용하는 인터페이스(ConversionService)를 분리해 두었다. 우리는 사용할 때 ConversionService만 의존하면 된다.
3-2. 사용 예시 (테스트 코드)
@Test
void conversionService() {
// 1. 컨버전 서비스 생성 및 등록
DefaultConversionService conversionService = new DefaultConversionService();
conversionService.addConverter(new StringToIntegerConverter()); // 기본 제공
conversionService.addConverter(new StringToIpPortConverter()); // 직접 만든 것
conversionService.addConverter(new IpPortToStringConverter());
// 2. 사용
// "10" -> 10
Integer result = conversionService.convert("10", Integer.class);
assertThat(result).isEqualTo(10);
// "127.0.0.1:8080" -> IpPort 객체
IpPort ipPort = conversionService.convert("127.0.0.1:8080", IpPort.class);
assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));
}
4. 스프링 부트에 적용하기 (WebConfig)
우리가 만든 컨버터가 스프링 MVC에서 실제로 동작하도록(@RequestParam 등에서) 설정해보자.
WebConfig
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 우선순위 때문에 주석처리 (뒤에 나올 포맷터가 우선순위가 낮아서 컨버터가 먼저 먹혀버림)
// registry.addConverter(new StringToIntegerConverter());
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
}
}
- addFormatters 메서드를 오버라이딩하지만, 여기서 컨버터와 포맷터 둘 다 등록한다.
- 스프링 부트는 내부적으로 ConversionService를 웹용으로 확장한 WebConversionService를 빈으로 등록해서 사용한다.
Controller 사용 확인
@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";
}
- 요청: GET /ip-port?ipPort=127.0.0.1:8080
- 결과: IpPort 객체가 정상적으로 생성되어 로그에 찍힌다.
5. 포맷터 (Formatter) - "보여주기"용 변환
Converter는 범용적인 타입 변환을 수행한다. 하지만 웹 애플리케이션에서는 단순히 타입만 바꾸는 게 아니라 "형식(Format)"이 중요한 경우가 많다.
- 객체 -> 문자: 숫자 10000을 "10,000"(쉼표 추가)으로 출력.
- 문자 -> 객체: 문자 "10,000"을 숫자 10000으로 파싱.
- 날짜: 2023-01-01 10:00:00 같은 포맷 처리.
이런 기능은 Locale(현지화) 정보가 필요한데, Converter는 이를 지원하지 않는다. 그래서 Formatter가 등장했다.
5-1. Formatter 인터페이스
public interface Formatter<T> extends Printer<T>, Parser<T> {
}
public interface Printer<T> {
String print(T object, Locale locale); // 객체 -> 문자
}
public interface Parser<T> {
T parse(String text, Locale locale) throws ParseException; // 문자 -> 객체
}
5-2. MyNumberFormatter 구현 (1,000 단위 쉼표)
@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);
// 1000 -> "1,000"
return NumberFormat.getInstance(locale).format(object);
}
}
5-3. 포맷터 등록 (WebConfig)
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Override
public void addFormatters(FormatterRegistry registry) {
// 컨버터가 포맷터보다 우선순위가 높다.
// 따라서 StringToIntegerConverter가 등록되어 있으면 MyNumberFormatter는 무시된다.
// 포맷터를 테스트하려면 해당 기본 컨버터를 주석 처리해야 한다.
registry.addConverter(new StringToIpPortConverter());
registry.addConverter(new IpPortToStringConverter());
// 포맷터 등록
registry.addFormatter(new MyNumberFormatter());
}
}
6. 애노테이션 기반 포맷팅 (@NumberFormat, @DateTimeFormat)
실무에서는 모든 필드에 같은 포맷을 적용하지 않는다. 어떤 가격은 쉼표가 필요하고, 어떤 숫자는 그냥 보여줘야 한다. 이를 위해 스프링은 필드마다 다른 포맷을 지정할 수 있는 애노테이션을 제공한다.
FormatterController
@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 = "###,###") // 10,000 형식
private Integer number;
@DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") // 날짜 포맷
private LocalDateTime localDateTime;
}
}
- @NumberFormat: 숫자 관련 포맷 지정.
- @DateTimeFormat: 날짜 관련 포맷 지정.
이 애노테이션들이 붙어 있으면, 스프링 내부의 AnnotationFormatterFactory가 동작해서 해당 필드에만 특수한 포맷터를 적용해준다. 실무에서 가장 많이 쓰는 방식이다.
7. 주의: API 개발과 메시지 컨버터
매우 중요한 내용이다. 프론트엔드와 JSON으로 통신할 때 HttpMessageConverter가 동작한다고 배웠다.
타입 컨버터(ConversionService)는 언제 적용될까?
- @RequestParam: 적용 됨 (쿼리 파라미터 변환)
- @ModelAttribute: 적용 됨 (뷰 템플릿 폼 데이터 변환)
- @PathVariable: 적용 됨 (URL 경로 변환)
- @RequestBody (JSON): 적용 안 됨!
왜? JSON 데이터를 객체로 바꿀 때는 Jackson 라이브러리가 동작한다. Jackson은 자기만의 방식으로 JSON을 파싱해서 객체를 만든다. 따라서 우리가 만든 ConversionService나 Formatter가 개입할 틈이 없다.
만약 JSON으로 날짜 포맷을 맞추고 싶다면?
- Jackson 라이브러리가 제공하는 설정(@JsonFormat)을 써야 한다.
- @DateTimeFormat은 스프링의 기능이라 JSON 변환에는 영향이 없지만, 스프링 부트가 이를 Jackson과 연결해주는 설정을 일부 제공하긴 한다. 하지만 기본 원칙은 "JSON 변환은 Jackson 설정으로, 파라미터 변환은 ConversionService로" 구분해서 이해해야 한다.
정리
- Converter: source -> target으로 타입을 변환하는 범용 인터페이스. 쓰레드 세이프하다.
- ConversionService: 수많은 컨버터를 모아두고 편리하게 사용하는 통합 서비스. 스프링 MVC는 WebDataBinder를 통해 이를 사용한다.
- Formatter: Locale 정보를 활용하여 날짜, 숫자 등의 형식(문자열 패턴)을 처리하는 특화된 인터페이스.
- 스프링 부트: WebMvcConfigurer의 addFormatters를 통해 컨버터와 포맷터를 쉽게 등록할 수 있다.
- 실무 팁: 클래스 전체에 적용할 포맷터보다는, 필드별로 적용 가능한 @NumberFormat, @DateTimeFormat이 훨씬 유용하다.
- 주의: 이 모든 기능은 @RequestParam 같은 파라미터 바인딩에 적용된다. JSON 바디(@RequestBody) 처리 시에는 Jackson 라이브러리의 설정(@JsonFormat)이 우선된다는 점을 명심하자.
이제 컨트롤러로 들어오는 모든 데이터의 타입을 자유자재로 다룰 수 있게 되었다. 다음 섹션에서는 파일을 업로드하고 다운로드하는 파일 업로드 기능에 대해 알아본다.
'Spring RoadMap > Spring MVC' 카테고리의 다른 글
| 스프링 MVC 2편 (11 - 마지막), 파일 업로드 (0) | 2025.12.23 |
|---|---|
| 스프링 MVC 2편 (9), API 예외 처리 (0) | 2025.12.23 |
| 스프링 MVC 2편 (8), 예외 처리와 오류 페이지 (1) | 2025.12.23 |
| 스프링 MVC 2편 (7), 로그인 2 - 필터, 인터셉터 (0) | 2025.12.23 |
| 스프링 MVC 2편 (6), 로그인 1 - 쿠키, 세션 (0) | 2025.12.21 |