스프링 타입 컨버터

2025. 9. 21. 23:57·Spring

스프링 타입 컨버터 소개

문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환해야하는 것 처럼 애플리케이션을 개발하다 보면 타입을 변환해야하는 경우가 상당히 많다.

 

@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";
    }

    @GetMapping("/hello-v2")
    public String helloV2(@RequestParam Integer data) {
        System.out.println("data = " + data);
        return "ok";
    }
}

 

helloV1()을 보면, 타입을 변경하는 코드를 작성했고, V2를 보면 파라미터에 타입을 지정하면 자동으로 변환을 해준다.

너무 당연한 내용이지만, 이런 예시 말고도 타입을 변환해야하는 경우는 상당히 많다. 개발자가 직접 타입을 변환해야 한다면 너무 힘든 작업이다. 문자를 숫자로, 숫자를 문자로, boolean을 숫자로도 가능하다. 만약 새로운 타입을 만들어서 변환하고싶으면?

 

컨버터 인터페이스

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.core.convert.converter;

import org.springframework.lang.Nullable;
import org.springframework.util.Assert;

@FunctionalInterface
public interface Converter<S, T> {
    @Nullable
    T convert(S source);

    default <U> Converter<S, U> andThen(Converter<? super T, ? extends U> after) {
        Assert.notNull(after, "'after' Converter must not be null");
        return (s) -> {
            T initialResult = this.convert(s);
            return initialResult != null ? after.convert(initialResult) : null;
        };
    }
}

 

스프링은 확장 가능한 컨버터 인터페이스를 제공한다.

개발자는 추가적인 타입 변환이 필요하면 컨버터 인터페이스를 구현해서 등록하면 된다.

이 컨버터 인터페이스는 모든 타입에 적용할 수 있다. X -> Y, Y -> X, String -> Boolean, Boolean -> String 등등..

 

타입 컨버터 - Converter

타입 컨버터를 사용하려면 Converter 인터페이스를 구현하면 된다.

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.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()를 생성한다.

따라서 모든 필드의 값이 같다면 a.equals(b)는 true.

@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();
    }
}
@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);
    }
}

 

테스트 코드

@Test
void StringToIpPort() {
    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");
}

@Test
void IpPortToString() {
    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));
}

 

단순해서 이해하는데 크게 어려움은 없다. 그런데 이렇게 하나하나 개발해서 쓰면 직접 컨버팅하는거랑 무슨 차이가 있나?

타입 컨버터를 등록하고 관리하면서 편하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다.

참고로 Converter, ConverterFactory, GenericConverter, ConditionalGenericConverter 등 여러 타입 컨버터가 있는데, 궁금하면 검색해보라고 한다 (굳이 볼 필요는 없을듯 싶음)

 

스프링은 문자, 숫자, 불린, Enum 등 일반적인 타입에 대한 컨버터를 기본으로 제공한다. 위 인터페이스의 구현체를 찾아보면 확인가능.

 

컨버전 서비스 - ConversionService

타입 컨버터를 하나하나 직접 찾아서 타입 변환에 사용하는것은 매우 불편하다. 컨버전 서비스를 사용하면 개별 컨버터를 모아두고 묶어서 편리하게 사용할 수 있다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

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);

    @Nullable
    <T> T convert(@Nullable Object source, Class<T> targetType);

    @Nullable
    default Object convert(@Nullable Object source, TypeDescriptor targetType) {
        return this.convert(source, TypeDescriptor.forObject(source), targetType);
    }

    @Nullable
    Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType);
}

 

컨버전 서비스는 단순히 컨버팅이 가능한가? 확인하는 기능, 컨버팅 기능을 제공한다.

그래서 사용하는데 convert만 알면 쉽게 사용이 가능하다.

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);
        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 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.

 

컨버터를 등록할 땐 StringToIntegerConverter같은 타입 컨버터를 명확하게 알아야한다. 반면 사용하는 입장에선 타입 컨버터를 몰라도된다. 컨버전 서비스 내부에 숨어서 제공되기 때문에 타입 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다.

물론 컨버전 서비스를 등록하고 사용하는 부분을 분리하고 의존관계 주입을 사용해야한다.

 

ISP - 인터페이스 분리 원칙

클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야한다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

package org.springframework.core.convert.support;

import org.springframework.core.convert.ConversionService;
import org.springframework.core.convert.converter.ConverterRegistry;

public interface ConfigurableConversionService extends ConversionService, ConverterRegistry {
}

 

DefaultConversionService를 타고타고 넘어가면 위의 인터페이스가 있는데,

ConversionService는 컨버터 사용에 초점, ConverterRegistry는 등록에 초점되어있다.

 

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 사용하는 클라이언트는 ConversionService만 의존하면 되고, 어떻게 등록되고 관리되는지 몰라도 상관없다. 사용만 하면되니까

결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다. 이런걸 ISP라 한다.

 

스프링에 Converter 적용하기

@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());
    }
}

 

WebConfig에 컨버터를 등록했다.

스프링은 내부에서 ConversionService를 제공한다. WebMvcConfigurer가 제공하는 addFormatters()를 사용해서 추가하고 싶은 컨버터를 등록해서 사용하면 된다. 이렇게 하면 COnversionService에 컨버터가 등록이 된다.

 

helloV2()를 실행해서 로그를 확인해보자

 

StringToIntegerConverter가 작동되어서 정상적으로 변환이 되었다.

그런데 컨버터를 설정하기 전에도 타입 변환은 잘 됐는데?

- 스프링 내부엔 수많은 기본 컨버터를 제공하기 때문에, 컨버터를 추가하면 추가한 컨버터가 기본 컨버터보다 우선순위가 높기때문에 StringToIntegerConverter가 작동된것이다.

 

@GetMapping("/ip-port")
public String ipPort(@RequestParam IpPort ipPort) {
    System.out.println("ipPort = " + ipPort.getIp());
    System.out.println("ipPort PORT = " + ipPort.getPort());
    return "ok";
}

 

직접 정의한 IpPort는 어떻게 변환될까?

 

URLdㅢ ?ipPort=127.0.0.1:8080 쿼리 스트링이 @RequestParam IpPort ipPort에서 객체 타입으로 잘 변환되었다.

 

처리 과정

@RequestParam은 ArgumentResolver인 RequestParamMethodArgumentResolver에서 ConversionService를 사용해서 타입을 변환한다. 부모 클래스와 다양한 외부 클래스를 호출하는 등 복잡한 내부 과정을 거치기때문에 이정도만 이해하자.

 

뷰 템플릿에 컨버터 적용하기

타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.

@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";
    }
}
<!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>

${..} : 컨버터 호출 안함 / 변수 표현식

${{..}} : 컨버전 서비스 적용

(두번 째 넘버는 숫자를 문자로 변환한 것)

 

폼에 적용

@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;
    }
}
<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>

 

form을 th:field는 {..}만 있는데 변환이 된다. 

th:field는 자동으로 컺ㄴ버전 서비스를 적용해주어서 ${{ipPort}}처럼 적용이 되었다.

 

포맷터 - Formatter

Converter는 입력과 출력 타입 제한이 없는, 범용 타입 변환 기능을 제공한다.

그런데 평상시에, 흔히 자주 쓰이는건 범용기능보단 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는일이 많다.

이럴 땐 포맷터를 사용하는게 좋다.

(예로 "1,000" -> 숫자 1000로 변환, 혹은 그 반대의 상황)

 

Converter : (객체 -> 객체)

Formatter : (객체 -> 문자 or 문자 -> 객체) + 현지화(Locale)

 

Formatter 만들기

@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);
        return NumberFormat.getInstance(locale).format(object);
    }
}

 

T parse() : 문자를 객체로 변환

Strin gprint() : 객체를 문자로 변환

locale은 해당 국가에 대한 표기법으로 변환해준다. (한국,US는 ,을 사용하는데 독일은 .을 사용함, 프랑스는 띄어쓰기로 씀)

 

포맷터를 지원하는 ConversionService

컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터는 등록할 수 없다. 그런데 포맷터는 객체 -> 문자, 문자 -> 객체로 변환하는 특별한 컨버터일 뿐이다.

 

포맷터를 지원하는 컨버전 서비스를 사용하면 포맷터를 추가할 수 있다. 내부에서 어댑터 패턴을 사용해서 Formatter가 Converter처럼 동작하도록 지원한다.

 

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", Integer.class)).isEqualTo(1000);

    }
}

 

DefaultFormattingConversionService는 컨버터, 포맷터 모두 등록 및 사용이 가능하고, FormattingConversionService에 기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가로 제공하니 이걸 쓰자

 

DefaultFormattingConversionService는 왜 컨버터도, 포맷터도 등록이 가능한가?

- ConversionSErvice 관련 기능을 상속받기 때문이다. 인터페이스를 타고타고 가다보면 ConversionService, ConversionRegistry를 상속받는다. (ISP 관련 부분 참조)

 

참고로 스프링 부트는 DefulatFormattingConversionService를 상속받은 WebConversionService를 내부에서 사용한다.

 

포맷터 적용

@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());
    }
}

 

주석처리한 코드도 마찬가지로 문자열 -> 숫자, 숫자 -> 문자열인데, MyNumberFormatter도 같은 역할을 한다.

그러나 converter가 우선순위에 올라가기때문에 주석처리 한 것

실행해보면 이전에 했던 내용과 다르게 출력된다. 10,000으로 포맷팅이 된 모습

 

추가로 localhost:8080/hello-v2?data=10,000을 요청해보자

 

MyNumberFormatter가 호출되고, 10,000으로 입력된 값을 정상적으로 숫자 10000으로 포맷팅되었다.

 

스프링이 제공하는 기본 포맷터

Formatter 인터페이스를 보면, 수많은 날짣나 시간 관련 포맷터가 있다.

그런데 포맷터는 기본 형식이 지정되어있기 때문에 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.

이럴때 어노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있다.

 

@NumberFormat : 숫자 관련 형식 지정 포맷터 사용

@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용

 

@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;
    }
}

 

설명 생략. 단순함

 

컨버터를 사용하던, 포맷터를 사용하던 등록 방법은 다르지만, 사용할 때는 컨버전 서비스를 통해 일관성 있게 사용할 수 있다.

 

참고로 MessageConverter에는 컨버전 서비스가 적용되지 않는다. 둘은 아무 상관이 없다.

MessageConverter는 객체를 HTTP 바디에 입력하거나, HTTP 바디의 내용을 객체로 변환하는것이다.

JSON -> 객체는 MessageConverter 내부에 Jackson 라이브러리를 통해 변환된다. JSON 결과로 만들어지는 숫자나 날짜 데이터 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야한다.

쉽게 말해서 둘은 아무 상관이 없기때문에 MessageConverter에서 ConversionService의 기능을 기대하지마라

'Spring' 카테고리의 다른 글

JDBC  (0) 2025.09.28
파일 업로드  (0) 2025.09.23
API 예외처리  (0) 2025.09.20
예외처리와 오류 페이지  (0) 2025.09.19
로그인 처리2 - 필터, 인터셉터  (0) 2025.09.18
'Spring' 카테고리의 다른 글
  • JDBC
  • 파일 업로드
  • API 예외처리
  • 예외처리와 오류 페이지
공부처음하는사람
공부처음하는사람
  • 공부처음하는사람
    lazzzykim
    공부처음하는사람
  • 전체
    오늘
    어제
    • 분류 전체보기 (159)
      • Kotlin (31)
      • Java (56)
      • Spring (44)
      • JPA (6)
      • Algorithm (3)
      • TroubleShooting (1)
      • 내일배움캠프 프로젝트 (14)
      • Setting (2)
      • ... (0)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
    • 글쓰기
  • 링크

  • 인기 글

  • 태그

    jpa
    캡슐화
    내일배움캠프
    싱글톤
    빈 생명주기
    다형성
    제네릭
    언체크예외
    배열
    java
    트랜잭션
    중첩클래스
    OCP
    Di
    김영한의 실전 자바
    spring
    김영한
    kotlin
    예외처리
    김영한의 실전자바
  • hELLO· Designed By정상우.v4.10.3
공부처음하는사람
스프링 타입 컨버터
상단으로

티스토리툴바