스프링 MVC 2편 (10), 스프링 타입 컨버터

2025. 12. 23. 02:12Spring 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으로 날짜 포맷을 맞추고 싶다면?

  1. Jackson 라이브러리가 제공하는 설정(@JsonFormat)을 써야 한다.
  2. @DateTimeFormat은 스프링의 기능이라 JSON 변환에는 영향이 없지만, 스프링 부트가 이를 Jackson과 연결해주는 설정을 일부 제공하긴 한다. 하지만 기본 원칙은 "JSON 변환은 Jackson 설정으로, 파라미터 변환은 ConversionService로" 구분해서 이해해야 한다.

정리

  1. Converter: source -> target으로 타입을 변환하는 범용 인터페이스. 쓰레드 세이프하다.
  2. ConversionService: 수많은 컨버터를 모아두고 편리하게 사용하는 통합 서비스. 스프링 MVC는 WebDataBinder를 통해 이를 사용한다.
  3. Formatter: Locale 정보를 활용하여 날짜, 숫자 등의 형식(문자열 패턴)을 처리하는 특화된 인터페이스.
  4. 스프링 부트: WebMvcConfigurer의 addFormatters를 통해 컨버터와 포맷터를 쉽게 등록할 수 있다.
  5. 실무 팁: 클래스 전체에 적용할 포맷터보다는, 필드별로 적용 가능한 @NumberFormat, @DateTimeFormat이 훨씬 유용하다.
  6. 주의: 이 모든 기능은 @RequestParam 같은 파라미터 바인딩에 적용된다. JSON 바디(@RequestBody) 처리 시에는 Jackson 라이브러리의 설정(@JsonFormat)이 우선된다는 점을 명심하자.

이제 컨트롤러로 들어오는 모든 데이터의 타입을 자유자재로 다룰 수 있게 되었다. 다음 섹션에서는 파일을 업로드하고 다운로드하는 파일 업로드 기능에 대해 알아본다.