Search

스프링 타입 컨버터와 파일 업로드

Created
2024/11/06 05:03
상태
Done
태그
spring
web service

스프링 타입 컨버터

문자를 숫자로 변환하거나, 반대로 숫자를 문자로 변환하는 것처럼 애플리케이션을 개발하다보면 타입을 변환해야하는 경우가 상당히 많다.
@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"; }
Java
복사
이와 같이 요청 파라미터를 변환하는 작업이 필요할 수 있다.
@GetMapping("hello-v1") public String helloV1(@RequestParam Integer data) { System.out.println("data = " + data); return "ok"; }
Java
복사
하지만 스프링 MVC가 제공하는 @RequestParam을 사용하면 이처럼 간단하게 스프링에서 변환해서 넘겨준다. 이는 @ModelAttribute와 @PathVariable도 마찬가지이다. 그 외에도 @Value를 통해 YML 정보를 읽는 작업이나, XML 스프링 빈 정보를 변환하는 때, 뷰를 렌더링하는 때 등 다양한 상황에서 스프링에서 자동으로 변환하는 작업을 수행한다.
스프링은 용도에 따라 다양한 방식의 타입 컨버터를 제공한다.
Converter : 기본 타입 컨버터
ConverterFactory : 전체 클래스 계층 구조가 필요할 때
GenericConverter : 정교한 구현, 대상 필드의 애노테이션 정보 사용 가능
ConditionalGenericConverter : 특정 조건이 참인 경우에만 실행

타입 컨버터

스프링에서는 개발자가 새로운 타입을 만들어 변환할 수 있게,
package org.spring.framework.core.convert.converter; // 수 많은 convert가 존재하니 package 주의! public interface Converter<S, T> { T convert(S source); }
Java
복사
이와 같은 확장 가능한 컨버터 인터페이스를 제공한다. 따라서 추가적인 타입 변환이 필요하면 이 컨버터 인터페이스를 구현해서 등록하면 된다.
@Slf4j public class StringToIntegerConverter implements Converter<String, Integer> { @Override public Integer convert(String source) { log.info("convert source={}", source); return Integer.valueOf(source); } } @Slf4j public class IntegerToStringConverter implements Converter<Integer, String> { @Override public String convert(Integer source) { log.info("convert source={}", source); return String.valueOf(source); } }
Java
복사
이와 같이 Integer → String / String → Integer 간에 변환하는 컨버터를 구현할 수 있다.
@Getter @EqualsAndHashCode public class IpPort { private String ip; private int port; public IpPort(String ip, int port) { this.ip = ip; this.port = port; } }
Java
복사
@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); } } @Slf4j public class IpPortToStringConverter implements Converter<IpPort, String> { @Override public String convert(IpPort source) { log.info("convert source={}", source); return source.getIp() + ":" + source.getPort(); } }
Java
복사
또한 이와 같이 새로운 객체를 선언하여 해당 객체에 맞는 컨버터를 만들 수도 있다.
하지만 이와 같은 컨버터를 선언만 해두고 직접 호출해서 사용하면, 우리가 직접 변환해서 사용하는 것과 다를 바 없다. 때문에 이런 타입 컨버터들을 등록하고 관리하여, 편리하게 변환 기능을 제공하는 컨버전 서비스를 같이 사용해야 한다.

컨버전 서비스(Conversion Service)

스프링은 개별 컨버터를 모아두고 묶어서 편하게 사용할 수 있는 컨버전 서비스 기능을 제공한다.
public interface ConversionService { boolean canConvert(@Nullable Class<?> sourceType, Class<?> targetType); boolean canConvert(@Nullalbe TypeDscriptor sourceType, TypeDescriptor targetType); <T> T convert(@Nuallable Object source, Class<T> targetType); Object convert(@Nullable Object source, @Nullable TypeDescriptor sourceType, TypeDescriptor targetType); }
Java
복사
컨버전 서비스의 인터페이스는 이와 같이 구성되어 있어, 변환이 가능한지 확인하는 기능과 변환하는 기능을 제공한다.
@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(ipPort, String.class); assertThat(ipPortString).isEqualTo("127.0.0.1:8080"); }
Java
복사
컨버전 서비스는 이와 같이 컨버터를 등록하는 부분과 사용하는 부분으로 구성되어 있다. 이는 DefaultConversionService가 컨버터 사용에 초점을 맞춘 ConversionService와 컨버터 등록에 초점을 맞춘 ConverterRegistry 두 인터페이스를 모두 구현했기 때문에 가능하다.
이렇게 인터페이스를 분리하면, 컨버터를 등록하고 관리하는 클라이언트와 컨버터를 사용하는 클라이언트의 관심사를 명확하게 분리할 수 있다. 컨버터를 사용하는 쪽에서는 컨버터를 어떻게 등록하고 관리하는지 몰라도 상관없다. 이와 같이 인터페이스를 분리하는 것을 ISP라 한다.

스프링에 Converter 적용하기

스프링은 내부에서 ConversionService를 제공한다.
@Configuration public class WebConfig implements WebMvcConfigurer { @Override public void addFormatters(FormatterRegistry registry) { registry.addConverter(new StringToIntegerConverter()); registry.addConverter(new IntegerToStringConverter()); registry.addConverter(new IpPortToStringConverter()); registry.addConverter(new StringToIpPortConverter()); } }
Java
복사
위와 같이 WebMvcConfigurer가 제공하는 addFormatters를 통해 컨버터를 등록할 수 있다. 이렇게 등록하고 나면, 이후 스프링에서 내부적으로 변환이 필요한 작업에서 자동으로 등록된 컨버터를 사용한다.
@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"; }
Java
복사
이와 같이 IpPort 객체로 받아오는 컨트롤러를 추가하고 실행시키면,
StringToIpPortConverter : convert source=127.0.0.1:8080 ipPort IP = 127.0.0.1 ipPort PORT = 8080
Plain Text
복사
이와 같이 스프링이 내부적으로 우리가 만든 컨버터를 호출하여 사용한 것을 확인할 수 있다.
구체적인 처리 과정은 @RequestParam을 처리하는 ArgumentResolver인 RequestParamMethodArgumentResovler에서 ConversionService를 사용해 타입을 변환한다. 거기에 우리가 만든 StringToIpPortConverter가 사용된 것이다.

뷰 템플릿에 Converter 적용하기

@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"; } }
Java
복사
이와 같이 뷰를 반환하는 컨트롤러를 추가하고,
<!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>
HTML
복사
타임리프의 ${{...}} 문법을 사용하면, 스프링에서 컨버전 서비스를 사용하여 변환된 결과를 출력한다.
• ${number}: 10000 • ${{number}}: 10000 • ${ipPort}: hello.typeconverter.type.IpPort@59cb0946 • ${{ipPort}}: 127.0.0.1:8080
Plain Text
복사
이처럼 일반적인 변수 표현식인 ${...}을 사용하면 toString이 호출된 결과만 출력하게 된다.
@Data static class Form { private IpPort ipPort; public Form(IpPort ipPort) { this.ipPort = ipPort; } }
Java
복사
추가적으로 이와 같이 특정 객체 내부에 있는 필드라도, 스프링에서 컨버터를 사용할 수 있다면 가져다가 사용한다.
<!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:value <input type="text" th:value="*{ipPort}">(보여주기 용도)<br/> <input type="submit"/> </form> </body> </html>
HTML
복사
타임리프의 th:field는 id와 name 등을 출력하는 기능이 있는데, 이 역시 마찬가지로 컨버전 서비스를 호출하여 변환된 값을 출력한다. 반면 th:value는 컨버전 서비스를 호출하여 변환된 값이 아닌 그냥 toString을 호출한 결과를 출력한다.

Formatter

Converter는 입력과 출력 타입에 제한이 없는 범용 타입 변환 기능을 제공한다. 하지만 대부분 변환하는 상황은 문자를 다른 타입으로 변환하거나, 다른 타입을 문자로 변환하는 것이다. 예를 들어 1000이라는 숫자를 “1,000”과 같이 특정 형태로 변환하는 경우, 또는 날짜 객체를 문자인 “2021-01-01 10:50:11”와 같이 변환하는 경우가 있다. 추가적으로 이러한 날짜의 표현 방법은 Locale 현지화 정보가 적용될 여지도 있다.
이렇게 문자 객체 간의 변환하는 것에 특화된 기능이 포맷터(Formatter)이다.
Converter : 범용(객체 객체)
Formatter : 문자 특화(객체 → 문자, 문자 → 객체) + 현지화(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> { }
Java
복사
이와 같이 formatter는 문자로 변환하는 Printer 인터페이스와 문자를 변환하여 객체를 만드는 Parser 인터페이스를 모두 포함하고 있다.
@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); } }
Java
복사
따라서 위처럼 객체 문자 변환 로직을 모두 작성해주어 포맷터를 만들 수 있다.
@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); }
Java
복사
DefaultFormattingConversionService의 경우 ConversionService와 Formatter 모두 확장하고 있기 때문에, 포맷터를 이처럼 직접 호출하여 사용할 수 있다.

스프링에 포맷터 적용하기

@Override public void addFormatters(FormatterRegistry registry) { registry.addFormatter(new MyNumberFormatter()); }
Java
복사
포맷터를 등록하는 방법은 위와 같다. 여기서 주의할 점은 String → Integer 변환하는 Converter가 등록되어 있다면, 컨버터가 포맷터보다 우선순위가 높아 우선순위에 따라 해당 포맷터는 적용되지 않는다.
이처럼 등록하고 나면 이후 우리가 호출하여 사용할 때 자동으로 해당 포맷터를 적용하여 값을 변환하여 전달한다.
사실 이런 포맷터를 직접 만드는 일은 굉장히 어렵고 복잡한 일이다. 때문에 스프링은 자바에서 기본으로 제공하는 여러 타입들에 대한 포맷터를 기본으로 제공하고 있다.
@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"; } @Data static class Form { @NumberFormat(pattern = "###,###") private Integer number; @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime localDateTime; } }
Java
복사
@NumberFormat : 숫자 관련 형식 지정 포맷터 사용
@DateTimeFormat : 날짜 관련 형식 지정 포맷터 사용
이와 같이 애노테이션을 통해 간단하게 포맷터와 형식을 지정하여, 해당 형식으로 데이터를 전달받도록 지정할 수 있다.
${form.number}: 10000 ${{form.number}}: 10,000 ${form.localDateTime}: 2021-01-01T00:00:00 ${{form.localDateTime}}: 2021-01-01 00:00:00
Plain Text
복사
여기서 주의할 점은 메세지 컨버터(HttpMessageConverter) 동작 시에는 컨버전 서비스가 적용되지 않는다는 것이다. 객체를 JSON으로 변활할 때, HTTP 메세지 바디의 내용을 객체로 변환하거나 객체를 HTTP 바디에 넣는 과정에서 메세지 컨버터가 사용된다. 여기서 메세지 컨버터가 사용되기 때문에, 컨버전 서비스와는 전혀 관계가 없고 Jackson과 같은 라이브러리의 설정에 따라 포맷을 지정해야 한다.

파일 업로드

일반적으로 HTTP 메세지를 전송할 때 application/x-www-form-urlecoded 형식으로 보내면서, HTTP body에 username=kim&age=20과 같이 문자로 전송한다. 하지만 파일 업로드를 하기 위해서는 문자가 아닌, 바이너리 데이터를 전송해야한다. 또한 파일을 전송할 때, 딱 파일만 전송하는 것이 아니라 다른 여러 데이터를 같이 보내야하는 경우가 많다.
이러한 문제를 해결하기 위해 HTTP는 multipart/form-data 형식의 전송 방식을 제공한다.
위처럼 각각의 전송 항목이 boundary로 구분되어 있고, 각 항목에서 별도의 헤더와 부가 정보가가 추가되어있다. multipart/form-data는 이렇게 각각의 항목을 구분해서, 한번에 전송하는 방식이다.

서블릿과 파일 업로드

서블릿에서는 이러한 mutlipart 형태의 HTTP를 받을 수 있는 기능을 지원한다.
@Slf4j @Controller @RequestMapping("/servlet/v1") public class ServletUploadControllerV1 { @GetMapping("/upload") public String newFile() { return "upload-form"; } @PostMapping("/upload") public String saveFileV1(HttpServletRequest request) throws ServletException, IOException { log.info("request={}", request); String itemName = request.getParameter("itemName"); log.info("itemName={}", itemName); Collection<Part> parts = request.getParts(); log.info("parts={}", parts); return "upload-form"; } }
Java
복사
이와 같이 request.getParts()를 통해 HTTP에 나뉘어진 각 part를 받아서 확인할 수 있다.
request=org.springframework.web.multipart.support.StandardMultipartHttpServletRequest itemName=Spring parts=[ApplicationPart1, ApplicationPart2]
Plain Text
복사
로그를 보면, 기존의 HttpServletRequest 객체 대신, StandardMultipartHttpServletRequest 객체가 사용된 것을 확인할 수 있다. 스프링에서는 멀티파트에 대한 설정이 켜있다면, DispatcherServlet에서 MultipartResolver를 실행해 멀티 파트인 경우 HttpServletRequest 대신 그 자식 인터페이스인 MultipartHttpServletRequest로 변환해 반환한다.
StandardMultipartHttpServletRequest는 MultipartHttpServletRequest 인터페이스를 구현한 클래스로, 스프링에서 기본으로 제공하는 기본 멀티파트 리졸버이다.
spring: servlet: multipart: enabled: false max-file-size: 1MB max-request-size: 10MB
YAML
복사
스프링에서는 이와 같이 멀티 파트의 파일에 대한 제한을 설정하거나, 멀티파트 HTTP를 받지 않도록 끌 수 있다.
서블릿의 request.getParts()를 통해 얻을 수 있는 Part는 여러 기능을 담고 있다.
@PostMapping("/upload") public String saveFileV1(HttpServletRequest request) throws ServletException, IOException { log.info("request={}", request); String itemName = request.getParameter("itemName"); log.info("itemName={}", itemName); Collection<Part> parts = request.getParts(); log.info("parts={}", parts); for (Part part : parts) { log.info("=== PART ===="); log.info("name={}", part.getName()); Collection<String> headerNames = part.getHeaderNames(); for (String headerName : headerNames) { log.info("header {}: {}", headerName, part.getHeader(headerName)); } //편의 메서드 //content-disposition; filename log.info("submittedFileName={}", part.getSubmittedFileName()); log.info("size={}", part.getSize()); //part body size //데이터 읽기 InputStream inputStream = part.getInputStream(); String body = StreamUtils.copyToString(inputStream, StandardCharsets.UTF_8); log.info("body={}", body); //파일에 저장하기 if (StringUtils.hasText(part.getSubmittedFileName())) { String fullPath = fileDir + part.getSubmittedFileName(); log.info("파일 저장 fullPath={}", fullPath); part.write(fullPath); } } return "upload-form"; }
Java
복사
getSumittedFileName : 클라이언트가 전달한 파일명
getInputStream : Part의 전송 데이터 읽기
write : Part를 통해 전송된 데이터를 특정 경로에 저장

스프링과 파일 업로드

스프링에서는 MultipartFile이라는 인터페이스를 통해 멀티파트 파일을 편리하게 다룰 수 있도록 지원한다.
@PostMapping("/upload") public String saveFile(@RequestParam String itemName, @RequestParam MultipartFile file, HttpServletRequest request) throws IOException { log.info("request={}", request); log.info("itemName={}", itemName); log.info("multipartFile={}", file); if (!file.isEmpty()) { String fullPath = fileDir + file.getOriginalFilename(); log.info("파일 저장 fullPath={}", fullPath); file.transferTo(new File(fullPath)); } return "upload-form"; }
Java
복사
이처럼 @RequestParam과 함께 MultipartFile을 받으면, 간단하게 멀티파트 정보를 담은 객체를 얻어와 사용할 수 있다. 추가로 @ModelAttribute에서도 받아올 수 있다.
getOriginalFilename : 업로드 파일명
transferTo : 특정 경로에 파일 저장