[스프링 MVC 2편] 스프링 타입 컨버터
포스트
취소

[스프링 MVC 2편] 스프링 타입 컨버터

프로젝트 생성

  • 스프링 이니셜라이저를 통해 프로젝트를 생성하자.
    • 프로젝트 선택
      • Project
        • Gradle - Groovy Project
      • Language
        • Java
      • Spring Boot
        • 3.x.x
    • Project Metadata
      • Group
        • hello
      • Artifact
        • typeconverter
      • Name
        • typeconverter
      • Package name
        • hello.typeconverter
      • Packaging
        • Jar (주의!)
      • Java
        • 17 또는 21
    • Dependencies
      • Spring Web
      • Thymeleaf
      • Lombok
      • Validation

스프링 타입 컨버터 소개

  • 개발을 하다보면 형변환을 해야할 경우가 상당히 많다.
String data = request.getParameter("data"); //문자 타입 조회
Integer intValue = Integer.valueOf(data); //숫자 타입으로 변경

형변환을 하는 이유

  • HTTP 요청 파라미터는 모두 문자로 처리된다.
    • 그래서 다른 타입으로 사용하고 싶으면 해당 타입으로 변환하는 과정을 거쳐야 한다.

파라미터를 정수로 받기기

  • 우리는 이미 @RequestParam이라는 애노테이션을 알고 있다.
  • 그냥 @RequestParam Integer data처럼 받으면 되지 않나? 싶을 것이다.
    • 그렇다면 왜 @RequestParam Integer data처럼 받으면 정수형으로 바로 받을 수 있는 것일까?
  • 우리가 @RequestParam Integer data으로 데이터를 받았을 때 정수형인 것은 스프링이 중간에서 타입을 변환해주었기 때문이다.
  • 이는 @RequestParam뿐만이 아니라 @ModelAttribute@PathVariable도 해당한다.
    • @ModelAttribute UserData data로 파라미터가 명시되어 있을 때 UserData 클래스 내부에 정수형 필드가 있다면 정수형으로 받을 것이다.
    • @PathVariable("userId") Integer data로 파라미터가 명시되어 있다면 data를 정수형으로 받을 것이다.

스프링이 타입 변환을 해 주는 경우

  • 스프링 MVC 요청 파라미터
    • @RequestParam
    • @ModelAttribute
    • @PathVariable
  • @Value 등으로 YML 정보 읽기
  • XML에 넣은 스프링 빈 정보를 변환
  • 뷰를 렌더링 할 때

문자와 숫자만 가능한걸까?

  • Boolean 타입을 숫자로 변경하는 것도 가능하다.
    • 그럴 때는 스프링이 제공하는 확장 가능한 컨버터 인터페이스를 구현해서 등록해두면 된다.
package org.springframework.core.convert.converter;

public interface Converter<S, T> {
  T convert(S source);
}
  • 만약 문자열인 “true”를 Boolean인 true로 받고 싶다면,
    String에서 Boolean으로 변환하는 컨터버 인터페이스를 만들어서 등록하면 된다.
  • 만약 그 반대로 Boolean인 true를 문자열인 “true”로 받고 싶다면,
    그저 Boolean에서 String으로 변환하는 컨터버 인터페이스를 만들어서 등록하면 된다.

  • 과거에는 PropertyEditor라는 것으로 타입을 변환했다.
  • PropertyEditor는 동시성 문제가 있어서 타입을 변환할 때 마다 객체를 계속 생성해야 하는 단점이 있었다.
  • 지금은 Converter 의 등장으로 해당 문제들이 해결되었고, 기능 확장이 필요하면 Converter 를 사용하면 된다.

타입 컨버터 - Converter

  • 타입 컨버터를 사용하려면 org.springframework.core.convert.converter.Converter 인터페이스를 구현하면 된다.

문자 to 숫자

  • 문자를 숫자로 변환해주는 컨버터를 만들어보자.
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);
    }
}

숫자 to 문자

  • 숫자를 문자로 변환해주는 컨버터를 만들어보자.
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");
    }
}

컨버전의 방식은 다양하다.

  • 단순히 자료형끼리만 바꾸는 건 아니다.
  • 127.0.0.1:8080처럼 아이피와 포트가 합쳐져 있는 문자열을 전달받으면 객체로 만드는 컨버터도 가능하다.

객체 생성하기

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

문자 to (아이피 + 포트)

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

(아이피 + 포트) to 문자

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

테스트

@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
      • 기본 타입 컨버터
    • ConverterFactory
      • 전체 클래스 계층 구조가 필요할 때 사용
    • GenericConverter
      • 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
    • ConditionalGenericConverter
      • 특정 조건이 참인 경우에만 실행
  • 공식 문서
  • 스프링은 문자, 숫자, 불린, Enum등 일반적인 타입에 대한 대부분의 컨버터를 기본으로 제공한다.

컨버전 서비스 - ConversionService

  • 다양한 타입 컨버터를 제공하긴 하지만 그걸 일일이 찾아서 사용하는 것도 불편하다.
  • 그래서 스프링은 개별 컨버터들을 모아두고 그것들을 묶어서 편리하게 사용할 수 있는 기능을 제공한다.
    • 그것이 바로 컨버전 서비스 (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);
}

테스트

  • DefaultConversionServiceConversionService 인터페이스를 구현했는데, 추가로 컨버터를 등록하는 기능도 제공한다.
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() {
        //컨버전 서비스에 컨버터 등록
        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");
    }
}

등록과 사용 분리

  • 컨버터를 등록할 때는 StringToIntegerConverter같은 타입 컨버터를 명확하게 알아야 한다.
    • 반면에 컨버터를 사용하는 입장에서는 타입 컨버터를 전혀 몰라도 된다.
  • 타입 컨버터들은 모두 컨버전 서비스 내부에 숨어서 제공된다.
    • 따라서 타입을 변환을 원하는 사용자는 컨버전 서비스 인터페이스에만 의존하면 된다.
    • 물론 컨버전 서비스를 등록하는 부분과 사용하는 부분을 분리하고 의존관계 주입을 사용해야 한다.

컨버전 서비스 사용

Integer value = conversionService.convert("10", Integer.class)

인터페이스 분리 원칙 (ISP)

  • 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다.
  • 그래서 DefaultConversionService는 2가지 인터페이스를 분리해서 구현했다.
    • ConversionService
      • 컨버터 사용에 초점
    • ConverterRegistry
      • 컨버터 등록에 초점
  • 그래서 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다.
    • 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 된다.
    • 컨버터를 사용하는 클라이언트는 컨버터를 어떻게 등록하고 관리하는지는 전혀 몰라도 된다.
    • 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게된다.

스프링에 Converter 적용하기

애플리케이션에 컨버터를 적용해보자.

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

컨트롤러 만들기

  • 테스트를 위해 임의의 컨트롤러를 만들어서 아래 메소드를 추가해주자.
@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";
}

테스트

  • 이제 서버를 실행해서 http://localhost:8080/ip-port?ipPort=127.0.0.1:8080를 호출하고 콘솔을 확인해보면 잘 동작하는 것을 알 수 있다.
ipPort IP = 127.0.0.1
ipPort PORT = 8080

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

  • 타임리프는 렌더링 시에 컨버터를 적용해서 렌더링 하는 방법을 편리하게 지원한다.
  • 타임리프는 $를 사용하면 자동으로 컨버전 서비스를 사용해서 변환된 결과를 출력해준다.
    • 물론 스프링과 통합 되어서 스프링이 제공하는 컨버전 서비스를 사용하므로, 우리가 등록한 컨버터들을 사용할 수 있다.
  • 기본적인 변수 표현식과는 중괄호 한 단계가 차이난다.
    • 기본 변수 표현식
      • ${...}
    • 컨버전 서비스 적용
      • $

컨버터를 폼에 적용하기

  • 컨버터는 폼 객체의 정보를 쉽게 다룰수 있게 도와주는 역할도 한다.
  • th:object="${form}"처럼 명시하면 해당 태그 및 하위 태그에서 전달받은 객체 데이터를 사용할 수 있다.
  • th:field="*{ipPort}"처럼 명시하면 해당 태그의 id, name, value를 지정할 수 있다.
    • 만약에 아이피를 127.0.0.1로, 포트를 8080로 생성한 IpPort 객체가 있고 그걸 폼으로 넘겼다고 가정해보자.
    • 그걸 <input type="text" th:field="*{ipPort}">처럼 사용할 것이다.
    • 그러면 해당 태그는 <input type="text" id="ipPort" name="ipPort" value="127.0.0.1:8080">으로 랜더링될 것이다.
    • IpPort로 넘겼는데 127.0.0.1:8080이 되는 이유는 컨버터로 등록한 IpPortToStringConverter 때문이다.
  • 그런데 th:value="*{ipPort}"처럼 명시하면 아래처럼 랜더링된다.
    • <input type="text" value="hello.typeconverter.type.IpPort@59cb0946">

포맷터 - Formatter

  • Converter는 입력과 출력 타입에 제한이 없는, 범용 타입 변환 기능을 제공한다.
  • 다만 개발자 입장에서 일반적인 웹 애플리케이션 환경을 생각해보자.
    • Boolean을 Integer로 바꾸는 범용 기능 보다는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.
    • 예시
      1. 숫자 1000과 문자 1,000 간에 서로 변환하는 경우
      2. 날짜 객체를 문자로 바꾸거나, 문자를 날짜 객체로 변환하는 경우
  • 게다가 숫자의 경우에는 현지화 정보인 Locale도 포함되어 있을 수도 있다.

포맷터의 등장

  • 포맷터는 객체를 특정한 포멧에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능을 가지고 있다.
  • 특별한 버전의 컨버터라고 이해하면 된다.

Converter vs Formatter

  • Converter
    • 객체 <>=> 객체
  • Formatter
    • 문자에 특화
    • 객체 <=> 문자 + 현지화(Locale)
    • Converter 의 특별한 버전

인터페이스

  • Formatter의 인터페이스를 확인해보면 문자에 특화되어 있다는 것을 확인할 수 있다.
  • String print(T object, Locale locale)
    • 객체를 문자로 변경한다.
  • T parse(String text, Locale locale)
    • 문자를 객체로 변경한다.
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> {

}

문자 <=> 숫자 포맷터 만들기

  • 문자 1,000은 숫자 1000으로, 숫자 1000은 문자 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);
        return format.parse(text);
    }

    @Override
    public String print(Number object, Locale locale) {
        log.info("object={}, locale={}", object, locale);
        return NumberFormat.getInstance(locale).format(object);
    }
}

테스트

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); //parse의 결과값이 Long 타입이라서 L 명시
    }

    @Test
    void print() {
        String result = formatter.print(1000, Locale.KOREA);
        assertThat(result).isEqualTo("1,000");
    }
}

포맷터의 종류

  • 스프링은 용도에 따라 다양한 방식의 포맷터를 제공한다.
    • Formatter
      • 기본 포맷터터
    • AnnotationFormatterFactory
      • 필드의 타입이나 애노테이션 정보를 활용할 수 있는 포맷터
  • 공식 문서

포맷터를 지원하는 컨버전 서비스

  • 컨버전 서비스에는 컨버터만 등록할 수 있고, 포맷터를 등록할 수 는 없다.
  • 다만 포맷터는 문자에 특화된 특별한 컨버터일 뿐이다.
  • 그래서 포맷터를 지원하는 컨버전 서비스를 사용하면 컨버전 서비스에 포맷터를 추가할 수 있다.
    • 내부에서 어댑터 패턴을 사용해서 FormatterConverter 처럼 동작하도록 지원한다.
  • 포맷터를 지원하는 컨버전 서비스인 FormattingConversionService를 사용하면 된다.
  • DefaultFormattingConversionServiceFormattingConversionService
    기본적인 통화, 숫자 관련 몇가지 기본 포맷터를 추가해서 제공한다.

테스트

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.DefaultFormattingConversionService;
import static org.assertj.core.api.Assertions.assertThat;

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);
    }
}
  • FormattingConversionServiceConversionService 관련 기능을 상속받는다.
    • 그래서 컨버터도 포맷터도 모두 등록할 수 있다.
    • 사용할 때는 ConversionService가 제공하는 convert를 사용하면 된다.
  • 추가로 스프링 부트는 DefaultFormattingConversionService를 상속 받은 WebConversionService를 내부에서 사용한다.

포맷터 적용하기

포맷터 등록하기

  • WebConfig의 addFormatters에 포맷터를 등록해보자.
    • 메소드명을 보면 컨버터와 포맷터를 등록하는 방법의 차이점을 알 수 있다.
    • 컨버터는 addConverter를 통해 등록한다.
    • 포맷터는 addFormatter를 통해 등록한다.
registry.addFormatter(new MyNumberFormatter());
  • 포맷터를 등록하든 컨버터를 등록하든 주의해야할 사항이 있다.
    • 바로 중복되는 기능이 있는지 확인해야 한다는 것이다.
    • MyNumberFormatter는 문자와 숫자를 서로 변환해주는 기능이다.
      • StringToIntegerConverterIntegerToStringConverter의 기능과 중복되니 이 둘을 주석처리 해줘야 한다.

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

  • 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.
  • Formatter 인터페이스의 구현 클래스를 찾아보면 수 많은 날짜나 시간 관련 포맷터가 제공되는 것을 확인할 수 있다.
    • 다만 포맷터는 기본 형식이 지정되어 있기 때문에, 객체의 각 필드마다 다른 형식으로 포맷을 지정하기는 어렵다.
  • 스프링은 이런 문제를 해결하기 위해 애노테이션 기반의 포맷터 두 가지를 제공한다.
    • @NumberFormat
      • 숫자 관련 형식 지정 포맷터 사용
      • NumberFormatAnnotationFormatterFactory
    • @DateTimeFormat
      • 날짜 관련 형식 지정 포맷터 사용
      • Jsr310DateTimeFormatAnnotationFormatterFactory

포맷터 적용 객체 만들기

@Data
static class Form {
  @NumberFormat(pattern = "###,###")
  private Integer number;
  
  @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss")
  private LocalDateTime localDateTime;
}

컨트롤러 만들기

  • 포맷터가 적용됬는지 확인하기 위해 컨트롤러 메소드를 만들자.
@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";
}

데이터 출력을 위한 페이지 만들기

  • 데이터 확인을 위해서 간단한 페이지도 하나 만들어주자.
<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>

테스트

  • 이제 서버를 실행해서 http://localhost:8080/formatter/edit로 접속해보자.
    • number는 포맷터가 적용되서 10,000로 출력되는 것을 확인할 수 있다.
    • localDateTime은 포맷터가 적용되서 2025-02-01 19:22:52로 출력되는 것을 확인할 수 있다.

출처

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.