728x90
반응형
SMALL

참고자료

 

스프링 MVC 2편 - 백엔드 웹 개발 활용 기술 강의 | 김영한 - 인프런

김영한 | 웹 애플리케이션 개발에 필요한 모든 웹 기술을 기초부터 이해하고, 완성할 수 있습니다. MVC 2편에서는 MVC 1편의 핵심 원리와 구조 위에 실무 웹 개발에 필요한 모든 활용 기술들을 학습

www.inflearn.com

 

스프링 타입 컨버터 소개

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

package cwchoiit.converter.controller;

import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ConverterController {

    @GetMapping("/v1")
    public String hello(HttpServletRequest request) {
        String data = request.getParameter("data");
        Integer i = Integer.valueOf(data);
        log.debug("i : {}", i);
        return "ok";
    }
}
  • HTTP 요청 파라미터는 모두 문자로 처리된다. 따라서 request.getParameter("data");를 통해 가져온 데이터도 당연히 문자로 받게 된다. 이 값을 다른 타입으로 변환해서 사용하고 싶으면 다음과 같이 숫자 타입으로 변환하는 과정을 거쳐야 한다. 
  • Integer.valueOf(data);

이번엔 스프링 MVC가 제공하는 @RequestParam을 사용해보자.

@GetMapping("/v2")
public String hello2(@RequestParam Integer data) {
    log.debug("i : {}", data);
    return "ok";
}
  • 스프링이 제공하는 @RequestParam을 사용하면 data를 처음부터 Integer 타입의 숫자로 가져올 수 있다. 이건 그냥 되는게 아니라 스프링이 중간에서 타입을 변환해주었기 때문이다! 이러한 예는 @ModelAttribute, @PathVariable도 마찬가지다.

 

스프링이 제공하는 이 타입 변환 기능은 개발자가 원한다면 새로운 타입을 만들어서 변환하고 싶으면 변환할 수 있다.

Converter 인터페이스

package org.springframework.core.convert.converter;

public interface Converter<S, T> {
   T convert(S source);
}

 

이처럼 스프링은 확장 가능한 컨버터 인터페이스를 제공한다. 개발자는 스프링에 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다. 이 컨버터 인터페이스는 모든 타입에 적용할 수 있다. 필요하면 X → Y 타입으로 변환하는 컨버터 인터페이스를 만들고, 또 Y → X 타입으로 변환하는 컨버터 인터페이스를 만들어서 등록하면 된다. 

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

 

실제 코드를 통해서 타입 컨버터를 이해해보자.

 

타입 컨버터 - Converter

일단, 가장 단순한 형태부터 시작해보자. 직접 우리가 컨버터를 만들고 등록을 해서 자유롭게 사용해보는 것이다.

 

문자를 숫자로 변환하는 컨버터를 만들어보자.

StringToIntegerConverter

package cwchoiit.converter.controller.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);
    }
}
  • Converter<S, T>를 구현한다. 여기서 패키지를 반드시 `org.springframework.core.convert.converter`로 사용해야 한다.
  • 이 인터페이스는 convert()라는 메서드를 구현해야 한다. 이건 단순하게 SourceTarget 타입으로 변환하는 메서드이다.

문자를 숫자로 변환했다면, 숫자를 문자로 변환하는 컨버터도 만들어보자.

IntegerToStringConverter

package cwchoiit.converter.controller.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);
    }
}

 

굉장히 간단하게 구현할 수 있다. 이건 그냥 맛보기니까 이렇게 하면 된다는 정도만 이해하고 실제로 뭐랄까.. 진짜 의미있는 컨버터를 만들어야 이걸 왜 배우고 있지?에 대한 의문이 해소될 것이다. 그래서 어떤 애플리케이션이 사용자의 IP와 Port정보를 받아서 문자열로 변환하고 반대도 변환이 가능한 그런 기능이 필요하다고 가정해보자.

 

그럼 먼저, 변환할 수 있도록 변환 대상 객체가 필요하다.

package cwchoiit.converter.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;
    }
}

위 클래스는, `127.0.0.1:8080`과 같은 ip, port 정보를 이 객체로 변환해주기 위해 필요한 객체이다.

 

StringToIpPortConverter

package cwchoiit.converter.controller.converter;

import cwchoiit.converter.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);
    }
}
  • `127.0.0.1:8080`과 같은 문자열을 IpPort 객체로 변환해주는 컨버터이다.

IpPortToStringConverter

package cwchoiit.converter.controller.converter;

import cwchoiit.converter.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();
    }
}
  • 반대로 IpPort 객체로 `127.0.0.1:8080`과 같은 문자열로 변환해주는 컨버터이다.

 

이제 이렇게 만들어 둔 컨버터들을 등록하고 관리하면서 편리하게 변환 기능을 제공하는 역할을 하는 무언가가 필요하다. 이제 그것에 대해 알아보자. 

 

컨버전 서비스 - 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);

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

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

 

ConversionServiceTest  - 컨비전 서비스 테스트

package cwchoiit.converter.converter;

import cwchoiit.converter.controller.converter.IntegerToStringConverter;
import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIntegerConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.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 defaultConversionService = new DefaultConversionService();
        defaultConversionService.addConverter(new StringToIntegerConverter());
        defaultConversionService.addConverter(new IntegerToStringConverter());
        defaultConversionService.addConverter(new IpPortToStringConverter());
        defaultConversionService.addConverter(new StringToIpPortConverter());

        assertThat(defaultConversionService.convert("10", Integer.class)).isEqualTo(10);
        assertThat(defaultConversionService.convert(10, String.class)).isEqualTo("10");

        IpPort ipPort = defaultConversionService.convert("127.0.0.1:8080", IpPort.class);
        assertThat(ipPort).isEqualTo(new IpPort("127.0.0.1", 8080));

        String stringIpPort = defaultConversionService.convert(new IpPort("127.0.0.1", 8080), String.class);
        assertThat(stringIpPort).isEqualTo("127.0.0.1:8080");
    }
}
  • DefaultConversionServiceConversionService 인터페이스를 구현한 구현체다. 그리고 추가적으로 컨버터를 등록하는 기능도 제공한다.

등록과 사용의 분리

 

등록

defaultConversionService.addConverter(new StringToIntegerConverter());

사용

defaultConversionService.convert("10", Integer.class)

 

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

 

이 부분에서 인터페이스 분리 원칙 - ISP(Interface Segregation Principle)이 또 나오게 된다. 인터페이스 분리 원칙은 클라이언트가 자신이 이용하지 않는 메서드에 의존하지 않아야 한다는 원칙이다. 무슨 말이냐면,

 

DefaultConversionService는 다음 두 인터페이스를 구현했다.

  • ConversionService: 컨버터 사용에 초점
  • ConversionRegistry: 컨버터 등록에 초점

이렇게 인터페이스를 분리하면 컨버터를 사용하는 클라이언트와 컨버터를 등록하고 관리하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 특히 컨버터를 사용하는 클라이언트는 ConversionService만 의존하면 되므로, 컨버터를 어떻게 등록하고 관리하는지 전혀 몰라도 된다. 결과적으로 컨버터를 사용하는 클라이언트는 꼭 필요한 메서드만 알게 된다. 이렇게 인터페이스를 분리하는 것을 ISP라고 한다. 

 

스프링은 내부에서 ConversionService를 사용해서 타입을 변환한다. 예를 들어서, 앞서 살펴본 @RequestParam 같은 곳에서 이 기능을 사용해서 타입을 변환한다. 이제 컨버전 서비스를 스프링에 적용해보자.

 

스프링 컨버터에 만든 커스텀 컨버터 적용하기

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

 

WebConfig

package cwchoiit.converter;

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

 

등록한 컨버터가 잘 동작하는지 확인해보자.

ConverterController

package cwchoiit.converter.controller;

import cwchoiit.converter.IpPort;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@RestController
public class ConverterController {

    @GetMapping("/convert")
    public String convert(@RequestParam Integer data) {
        log.info("data: {}", data);
        return "ok";
    }

    @GetMapping("/convert-ip")
    public String convertIp(@RequestParam IpPort ipPort) {
        log.info("ipPort: {}", ipPort);
        return "ok";
    }
}
  • 첫번째 경로인 `/convert`data라는 쿼리파라미터를 넣어보자.

 

  • 두번째 경로인 `/convert-ip`ipPort 라는 쿼리파라미터로 이렇게 넣어보자.

 

우리가 등록한 것을 잘 사용하고 있다. 근데 문자를 숫자로 변환하는 기능은 원래도 스프링이 제공했는데 우리가 등록한것을 사용하고 있다. 그 이유는 언제나 우리가 직접 등록한게 먼저이기 때문이다. 만약, StringToIntegerConverter를 등록한것을 빼버리면 원래 기존의 스프링이 가지고 있는 기본 컨버터가 동작할 것이다. 

 

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

컨버터는 뷰 템플릿과도 상호작용한다. 한번 확인해보자!

ConverterViewController

package cwchoiit.converter.controller;

import cwchoiit.converter.IpPort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

@Controller
public class ConverterViewController {

    @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";
    }
}
  • 우선, 뷰를 보여주는 컨트롤러 하나를 새로 만들고, 다음과 같이 모델 객체에 number, ipPort를 넘겨주자. 
  • number는 숫자 타입으로 넘어가고, ipPortIpPort라는 객체타입으로 넘어간다.

 

뷰 템플릿을 만들자!

src/main/resources/templates/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>
  • Thymeleaf를 사용하면, 이렇게 `${}` 하나만 있는것과 `${{}}` 두개가 있는게 있는데, 하나만 사용하면 그냥 변수를 담는 변수 표현식이고 두개를 사용하면 컨버전 서비스를 적용하는 것이다. 이 결과는 어떻게 나올까?

  • number의 경우, 하나를 사용하나 두 개를 사용하나 동일하게 10000으로 찍혔다. 일단 두 개를 사용한 경우 컨버터가 적용이 된 것이다. 하나를 사용해도 결국 숫자는 뷰 템플릿에서는 무조건 문자로 표현된다. 
  • ipPort의 경우, 하나를 사용했더니 그냥 IpPorttoString() 메서드가 그대로 호출된 모습이다. 말 그대로 변수 표현식에 객체를 넘겼으니 객체가 찍히는게 맞다. 근데 두개를 넘겼더니 문자로 바꿔주는 컨버터가 적용된 모습을 확인할 수 있다.

 

이렇게 변수도 컨버터를 이용할 수 있고, 폼에도 컨버터를 적용할 수가 있다.

컨트롤러에 폼을 사용하는 경로를 추가해서 확인해보자.

package cwchoiit.converter.controller;

import cwchoiit.converter.IpPort;
import lombok.Data;
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;

@Controller
public class ConverterViewController {

    ...

    @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 converterSubmit(@ModelAttribute("form") 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;
        }
    }
}
  • 가장 간단한 형태의 GET, POST 두 개를 만들었다. GET에는 모델에 `form`Form 객체를 담아서 화면에 보여주고, POST에선 폼에 담긴 데이터를 @ModelAttribute가 받는다.

이제 뷰 템플릿을 만들어보자.

src/main/resources/templates/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:field 이 녀석은 컨버터를 자동 적용해준다.-->
    th:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/>
    <input type="submit"/>
</form>

</body>
</html>

 

실행을 해보면, 우선 다음 화면을 보자.

  • `th:field`의 경우 컨버터가 적용이 됐다. 이 `th:field`는 무조건 컨버터 적용을 할 수 있으면 해주는 좋은 녀석이다.
  • `th:value`의 경우 객체 그대로가 출력된다. 그래서 만약, 컨버터 적용을 하고 싶지 않다면 이 녀석을 사용하면 된다.

 

이것을 `Submit`을 통해 POST 요청을 날려보면 어떻게 될까? 마찬가지로 컨버터가 적용된다.

왜냐하면, @RequestParam과 같이 @ModelAttribute도 컨버터가 자동으로 적용되기 때문이다.

 

포맷터 만들기

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

이번에는 일반적인 웹 애플리케이션 환경을 생각해보자. 불린 타입을 숫자로 바꾸는 것 같은 범용 기능보다는 개발자 입장에서는 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 상황이 대부분이다.

 

웹 애플리케이션에서 객체를 문자로, 문자를 객체로 변환하는 예

  • 화면에 숫자를 출력해야 하는데, Integer → String 출력 시점에 숫자 1000 → "1,000" 이렇게 단위에 쉼표를 넣어서 출력하거나 또는 "1,000" → 1000 이라는 숫자로 변경해야 한다. 
  • 날짜 객체를 문자인 "2024-09-11 10:50:11"과 같이 출력하거나 그 반대의 상황

Locale

여기에 추가로 날짜 숫자의 표현 방법은 Locale 현지화 정보가 사용될 수 있다.

 

 

이렇게 객체를 특정한 포맷에 맞추어 문자로 출력하거나 또는 그 반대의 역할을 하는 것에 특화된 기능이 바로 포맷터이다.

Converter vs Formatter

  • Converter는 범용 (객체 → 객체)
  • Formatter는 문자에 특화(객체 → 문자, 문자 → 객체) + 현지화(Locale)

Formatter 인터페이스

package org.springframework.format;

public interface Formatter<T> extends Printer<T>, Parser<T> {
}
package org.springframework.format;

import java.util.Locale;

@FunctionalInterface
public interface Printer<T> {
    String print(T object, Locale locale);
}
package org.springframework.format;

import java.text.ParseException;
import java.util.Locale;

@FunctionalInterface
public interface Parser<T> {
    T parse(String text, Locale locale) throws ParseException;
}
  • String print(T object, Locale locale): 객체를 문자로 변경한다.
  • T parse(String text, Locale locale): 문자를 객체로 변환한다.

숫자 1000을 문자 "1,000"으로 그러니까 1000단위로 쉼표가 들어가는 포맷을 적용해보자. 그리고 그 반대도 처리해주는 포맷터를 만들어보자.

MyNumberFormatter

package cwchoiit.converter.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);
        NumberFormat instance = NumberFormat.getInstance(locale);
        return instance.format(object);
    }
}
  • "1,000"처럼 숫자 중간의 쉼표를 적용하려면 자바가 기본으로 제공하는 NumberFormat 객체를 사용하면 된다. 이 객체는 Locale 정보를 활용해서 나라별로 다른 숫자 포맷을 만들어준다.
  • parse()를 사용해서 문자를 숫자로 변환한다. 참고로 Number 타입은 Integer, Long과 같은 숫자 타입의 부모 클래스이다.

잘 동작하는지 테스트 코드를 작성해보자.

package cwchoiit.converter.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);

        Number result2 = formatter.parse("1,000,000", Locale.KOREA);
        assertThat(result2).isEqualTo(1_000_000L);
    }

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

 

 

이제 이 만든 포맷터를 등록해서 여기저기서 쉽게 사용할 수 있도록 해보자.

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

package cwchoiit.converter.formatter;

import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.type.IpPort;
import org.junit.jupiter.api.Test;
import org.springframework.format.support.DefaultFormattingConversionService;

import static org.assertj.core.api.Assertions.*;

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(1000L);
    }
}
  • DefaultFormattingConversionServiceFormatter는 물론, Converter도 등록하고 사용할 수 있다.
  • 그 이유는 DefaultFormattingConversionServiceConversionService관련 기능을 상속받기 때문이다. 또한 ConversionService가 제공하는 convert를 사용해서 컨버터 또는 포맷터를 사용할 수 있다.

DefaultFormattingConversionService는 다음과 같은 상속 구조를 가지고 있다.

  • FormattingConversionService를 상속받는다. 그리고 이 FormattingConversionServiceGenericConversionServiceFormatterRegistry를 상속받는다. 그 위로 더 올라가면, ConversionServiceConversionRegistry가 있다. 그렇기 때문에 등록은 물론 사용도 가능하게 되는 것이다. 

 

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

 

포맷터 적용하기

이제 이 포맷터를 웹 애플리케이션에 적용해보자. 스프링이 제공하는 WebMvcConfigurer에서 작업하면 된다.

WebConfig

package cwchoiit.converter;

import cwchoiit.converter.controller.converter.IpPortToStringConverter;
import cwchoiit.converter.controller.converter.StringToIpPortConverter;
import cwchoiit.converter.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 IpPortToStringConverter());
        registry.addConverter(new StringToIpPortConverter());

        registry.addFormatter(new MyNumberFormatter());
    }
}
  • 주의할 점은, 만약 컨버터로 문자를 숫자로, 숫자를 문자로 변환하는 컨버터를 등록하면 우선순위 때문에 포맷터가 적용되지 않는다. 우선순위는 컨버터가 포맷터보다 우선순위가 높기 때문에 숫자를 문자로, 문자를 숫자로 변환하는 우리가 만든 포맷터는 컨버터에 의해 적용되지 않는다. 그래서 나같은 경우 아예 해당 컨버터들은 등록하지 않고 지워버렸다.

 

그럼 이제 실행해보자. 이전에 사용했던 뷰 템플릿을 보여주는 컨트롤러이다.

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

컨버터를 적용한 `number`"10,000" 이렇게 출력됐음을 알 수 있다. 포맷터가 적용된 것이다. 

 

 

뷰 템플릿 뿐 아니라, @RequestParam과 같은 녀석들도 모두 적용된다. 아래 경로에 한번 테스트해보자.

@GetMapping("/v2")
public String hello2(@RequestParam Integer data) {
    log.debug("i : {}", data);
    return "ok";
}

보면, 쿼리 파라미터로 "1,000"을 넘겼고 그 값을 숫자 1000으로 변환한 로그가 찍힌 모습이다.

 

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

이 부분이 제일 중요하다. 사실 스프링은 자바에서 기본으로 제공하는 타입들에 대해 수 많은 포맷터를 기본으로 제공한다.

그런데, 포맷터는 기본 형식이 지정이 되어 있다. 그래서 객체의 각 필드마다 다른 형식으로 포맷을 지정하기가 어렵다. 스프링은 이런 문제를 해결하기 위해 애노테이션 기반으로 원하는 형식을 지정해서 사용할 수 있는 매우 유용한 포맷터 두 가지를 기본으로 제공한다.

 

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

무슨말인지는 예제를 통해 알아보자.

FormatterController

package cwchoiit.converter.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;
    }
}
  • 저기보면, 정적 내부 클래스로 Form이 있다. 그리고 각 필드에 @NumberFormat, @DateTimeFormat 애노테이션이 달려있고 그 패턴을 지정해두었다. 이렇게 되면 이제 이 객체를 사용할 때 저 형식으로 포맷이 적용되는 것이다.

테스트 하기 위해 뷰 템플릿을 만들자.

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>

 

실행해보자.

  • 분명 `form.setNumber(10000);`으로 값을 넣었지만, th:field를 사용해서 값을 출력해보니 `10,000`이렇게 보여진다. 포맷터가 잘 적용된 모습이다. LocalDateTime도 마찬가지.

[Submit]버튼을 클릭해서 POST 요청을 날려보면, 다음과 같이 보여진다.

@ModelAttribute도 포맷터가 잘 적용된 모습이다. 이렇게 각 객체의 필드별로 포맷을 자유자재로 정의할수도 있다. 애노테이션을 활용해서! 

 

정리를 하자면

컨버터와 포맷터를 알아보았다. 컨버터를 사용하든, 포맷터를 사용하든 등록 방법은 달라도 사용할 때는 컨버전서비스를 통해서 일관성있게 사용할 수 있었다. 포맷터는 여기서 더 나아가서 애노테이션 기반으로도 포맷을 지정할 수가 있었다. 이로 인해 조금 더 편하고 유연하게 적용을 할 수 있었다. 

 

주의할 점은, 메시지 컨버터(HttpMessageConverter)에는 컨버전 서비스가 적용되지 않는다. 특히 객체를 JSON으로 변환할 때 메시지 컨버터를 사용하면서 이 부분을 많이 오해하는데, HttpMessageConverter의 역할은 HTTP 메시지 바디의 내용을 객체로 변환하거나 객체를 HTTP 메시지 바디에 입력하는 것이다. 예를 들어 JSON을 객체로 변환하는 메시지 컨버터는 내부에서 Jackson과 같은 라이브러리를 사용한다. 객체를 JSON으로 변환한다면 그 결과는 이 라이브러리에 달린 것이다. 따라서 JSON 결과로 만들어지는 숫자나 날짜 포맷을 변경하고 싶으면 해당 라이브러리가 제공하는 설정을 통해서 포맷을 지정해야 한다. 결과적으로 이것은 컨버전 서비스와 전혀 관계가 없다.

 

컨버전 서비스는 @RequestParam, @ModelAttribute, @PathVariable, 뷰 템플릿 등에서 사용할 수 있다.

728x90
반응형
LIST

+ Recent posts