Programming-[Backend]/SpringBoot

[탐험]Spring MVC, String 전체 앞 뒤 공백 제거하기! - MappingJackson2HttpMessageConverter, @JsonFormat, StringTrimmerEditor

컴퓨터 탐험가 찰리 2022. 3. 2. 12:06
728x90
반응형

1. 목표

 

SpringMVC를 이용하는 프로젝트에서 클라이언트의 입력값으로 들어오는 String 문자열 값의 앞 뒤 공백을 제거한다.

"이름" : "    컴퓨터 탐험가   " -> "이름" : "컴퓨터 탐험가"

 

위와 같이 String의 앞 뒤 공백을 제거하는 것을 Java, Spring 쪽에서는 trim이라고 한다(자바스크립트에서도). 이 글의 목표는 프로젝트 전체에서 String값을 trimming하는 방법에 대해 정리하는 것이다. 개별 필드에 대한 trim에 대해서도 조금은 다룬다.

 

 


 

2. 방법과 적용 범위

 

집중적으로 다룰려고 하는 내용은 프로젝트 전체에서 trim을 적용하는 방법이다. 다만 이럴 경우 사용자가 의도적으로 문자열 앞뒤로 공백을 넣는 경우는 작동하지 않게될 수 있다. 예를 들어 비밀번호 입력 시 " password1234    "라고 입력해도 비밀번호가 trim되어 "password1234"로 입력될 수 있다. 따라서 별로 권장할만한 방식은 아니다. 그러나 이 글을 통해서 문자열을 다루는 방법과 MVC 패턴에서 요청의 종류별로 파라미터들을 처리해주는 구조와 주변 지식에 대해서 배울 수 있다.

 

 

 

1. 각 문자열 trim

문자열 뒤에 .trim() 메서드를 사용해주면 된다. 또는 StringUtils.trim() 메서드를 사용하면 된다.

 

String trimmed1 = "   컴퓨터 탐험가  ".trim();   //컴퓨터 탐험가
String trimmed2 = StringUtils.trim("   컴퓨터 탐험가  ");   //컴퓨터 탐험가

 

 

 

2. 특정 필드나 클래스 trim

 

2-1. 어노테이션과 Reflection 적용하기

 

상기 각 문자열 trim 방식을 커스텀 어노테이션을 만들어서 적용하면 된다.  커스텀 어노테이션Reflection을 이용한 trim 방법에 대해서는 아래 참조하면 된다.

 

ref1) 강남언니 공식 블로그 - Spring Annotation 과 Reflection 을 활용해서 Entity의 여러 필드 한번에 수정하기

https://blog.gangnamunni.com/post/Annotation-Reflection-Entity-update/

 

Ref1)의 코드 일부

@Merge 어노테이션을 만들고, Reflection을 이용해서 클래스의 각 필드에 접근하는 방법

@AllArgsConstructor
@Data
class Obj {
	private String a;
	@Merge
	private long b;
	@Merge
	private Boolean c;
	@Merge
	private String d;
}

@Test
public void mergeTest() {
	Obj obj1 = new Obj("abc", 11, false, "123");

	System.out.println(obj1);

	for(Field field : obj1.getClass().getDeclaredFields()) {
		annotation = field.getAnnotation(Merge.class);
	}
}

 

 

2-2. Mapper에서 적용하기

 

만약 프로젝트 전체에서 파라미터 오브젝트(PO) -> 데이터 전달 오브젝트(DTO)를 mapper를 통해 변환하는 로직을 적용했다면, 필요한 api 마다 mapper에서 적용해도 된다.

 

엄청 대충 테스트한 내용 ---

@SpringBootTest
class JavatestApplicationTests {

    @Data
    class MemberPo {
        private String name;
        private Integer age;

        public MemberPo(String name, Integer age) {
            this.name = name;
            this.age = age;
        }
    }

    @Data
    class MemberDto {
        private String name;
        private Integer age;

        public MemberDto() {
        }
    }

    @Test
    void contextLoads() {

        /* given */
        MemberPo memberPo = new MemberPo("  컴퓨터 탐험가 ", 12);
        MemberDto memberDto = new MemberDto();

        /* when */
        BeanUtils.copyProperties(memberPo, memberDto);

        //reflection 적용
        Arrays.stream(memberDto.getClass().getDeclaredFields())
                .forEach(f -> {
            try {
                if(f.get(memberDto).getClass().equals(String.class)) {
                    f.set(memberDto, f.get(memberDto).toString().trim());
                }

            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
        });

        /* then */
        System.out.println(memberDto);
    }

}

 

즉 api 마다 po -> dto로 변환하는 구조체를 mapper 클래스로 만들어놓았다면(mapstruct 라이브러리 등) mapper 클래스에서 "//reflection 적용" 부분을 적용하면 String 값들을 trim 할 수 있다.

 

-//reflection 적용 부분 제거했을 때 결과

 

-//reflection 적용 부분 적용했을 때 결과

 

 

 

 

3. 전체 프로젝트에 적용

 

SpringMVC의 ArgumentResolver - HttpMessageConverter 부분에서 JSON -> Object로 변환하는 converter를 따로 설정해주면 된다. 이전 학습했던 글 - https://whitepro.tistory.com/360?category=985210

 

즉 springMVC 구조에서는 HTTP 요청을 DispatcherServlet에서 받아오고, HTTP 요청의 content-type(text/html, application/json 등)에 따라 우리가 원하는 객체 타입으로 변환시켜주는 ArgumentResolver가 있다. 여기서 Converter가 동작하는데 대표적/기본적인 converter 적용 순서는 다음과 같다.(상세 내용은 위 참고 글)

 

  1. ByteArrayHttpMessageConverter
  2. StringHttpMessageConverter
  3. MappingJackson2HttpMessageConverter

 

즉 여기서 MappingJackson2HttpMessageConverter라는, JSON을 우리가 원하는 객체로 변경해주는 converter를 커스터마이징할 것이다.

 


3. 해결책

 

결론을 내리기 전에, 문제별로 해결책이 구분되어야 한다.

 

1. 쿼리 스트링으로 오는 정보(@GetMapping, @ModelAttribute)

 

참조1)에 따르면 쿼리 스트링으로 오는 정보는 @InitBinder를 통해 StringTrimmerEditor를 적용하면 된다. 여기서는 각 컨트롤러에 적용하도록 되어있는데, @ControllerAdvice를 하나 만들어서 해당 로직을 추가하면 프로젝트 전체에서 String 값들을 trim할 수 있을 것이다.

 

다만 이 내용은 Spring Validator에서 적용해주는 것이기 때문에, @Valid 어노테이션을 사용해야하고, @ModelAttribute를 사용하는, 즉 기본 타입에(String, Integer 등) 생략해도 알아서 파싱해주는 부분에서만 적용된다.

 

--참조 1의 코드

@Controller
@RequestMapping("/customer")
public class CustomerController {
	
	// input 스트링으로 들어오는 String 데이터들의 white space를 trim해주는 역할을 한다.
	// 모든 요청이 들어올때마다 해당 method를 거침 (node의 middleware 같은 것 )
	@InitBinder
	public void InitBinder(WebDataBinder dataBinder) {
		StringTrimmerEditor stringTrimmerEditor = new StringTrimmerEditor(true);
		dataBinder.registerCustomEditor(String.class, stringTrimmerEditor);
	}
	
	
	@RequestMapping("/showForm")
	public String showForm(Model theModel) {
		
		theModel.addAttribute("customer", new Customer());
		
		return "customer-form";
	}
	
	@RequestMapping("/processForm")
	public String processForm(@Valid @ModelAttribute("customer") Customer customer, BindingResult theBindingReuslt) {
		
		
		System.out.println("customer lastname | " + customer.getLastName() + " |");
		System.out.println("BindingResult | " + theBindingReuslt + " | ");
		System.out.println("\n\n");
		if( theBindingReuslt.hasErrors()) {
			return "customer-form";
		}
		return "customer-confirmation";
	}
}

 

 

 

2. Body로 오는 정보((@PostMapping or @PutMapping),@RequestBody)

 

참조2)에 따르면 @RequestBody가 적용된 부분에서는 trim이 불가하다며 해답을 제시한다. 따라서 이 코드를 통해 converter를 Override 해주어야 하는데, 내가 적용한 방법은 아래와 같다.

 


@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    List<MappingJackson2HttpMessageConverter> mappingJackson2HttpMessageConverters =
            converters.stream()
                    .filter(cvt -> cvt instanceof MappingJackson2HttpMessageConverter)
                    .map(jacksonCvt -> (MappingJackson2HttpMessageConverter) jacksonCvt)
                    .collect(Collectors.toList());
    mappingJackson2HttpMessageConverters.forEach(cvt -> {
        ObjectMapper mapper = cvt.getObjectMapper();
        SimpleModule moduleForStringTrimmer = new SimpleModule();
        moduleForStringTrimmer.addDeserializer(String.class, new CustomStringTrimmer(String.class));
        mapper.registerModule(moduleForStringTrimmer);
        cvt.setObjectMapper(mapper);
    });
}

 

 

 

 

WebConfig 설정파일에서 extendMessageConverters 메서드를 Override 해준다.

 

Spring 프로젝트라면 아래와 같이 설정 클래스가 있을 것이다.

 

 

 

입력값

swagger 입력값

 

 

{
  "itemStockReleaseTypeId": 1,
  "orderedAt": "2021-12-25",
  "remark": "     공백이 제거 될까요    ",

 

.... 중략

}

 

 

 

출력값

클라이언트 요청 시, po값 debugging

 


사이트 이펙트 부분 해결함. 기존 converter 안에 deserializer를 추가하고 JavaTimeModule을 추가함

 

사이드 이펙트 1. converter 우선순위 하드코딩

기본적으로 설정해주는 converters 리스트에서 커스터마이징된 MappingJackson2HttpMessageConverter를 index(6)을 지정하여 강제로 원래 converter(아래 콘솔 사진에서 index 8번 converter) 앞쪽에 위치하도록해서, json 처리기로 설정함. index를 하드코딩한 문제가 있고, 사이드 이펙트 2가 발생하는 원인이 된 것 같음 

 

기존 사이트 이펙트 났던 코드

@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
    converters.add(6, mappingJackson2HttpMessageConvertor());
}

@Bean
public HttpMessageConverter<?> mappingJackson2HttpMessageConvertor() {
    MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
    ObjectMapper mapper = new ObjectMapper();
    mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

    SimpleModule module = new SimpleModule();
    module.addDeserializer(String.class, new CustomStringTrimmer(String.class));
    mapper.registerModule(module);

    converter.setObjectMapper(mapper);

    return converter;
}

 


 

사이드 이펙트2. @JsonFormat과 충돌

 

컨트롤러에서 클라이언트의 입력값을 받아오는 파라미터 오브젝트(po)의 날짜값들을 LocalDate, LocalDateTime 등으로 지정하고 String으로 받기 위해서 강제로 @JsonFormat을 이용했는데, 커스터마이징된 converter에서 String으로 변환된 날짜 타입을 deserialize 할 수 없다는 에러가 발생함.

 

@Schema(description = "출고요청일", example = "2021-12-25", required = true)
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd", timezone = "Asia/Seoul")
@NotNull(message = "{...}")
private LocalDate orderedAt;

 

대표 에러 내용

org.springframework.http.converter.HttpMessageConversionException: Type definition error: [simple type, class java.time.LocalDate]; nested exception is com.fasterxml.jackson.databind.exc.InvalidDefinitionException: Cannot construct instance of `java.time.LocalDate` (no Creators, like default constructor, exist): no String-argument constructor/factory method to deserialize from String value ('2021-12-25')

 

-> 해당 에러는 converter가 JavaTimeModule을 갖고 있지 않아서라는 것을 알아내서, mappingJackson2HttpMessageConvertor 설정파일에 아래 내용을 추가하였다.

 

mapper.registerModule(new JavaTimeModule());

참고 링크 : 

https://stackoverflow.com/questions/45863678/json-parse-error-can-not-construct-instance-of-java-time-localdate-no-string-a

 

 

 

 

기본 converter가 @JsonFormat을 사용하는 부분을 어떻게 처리하는지 알아내고, 기본으로 주어지는 converter의 메서드를 override 한다거나, 커스터마이징된 converter에 해당 내용을 추가하는 방식 등을 고려해봐야할 것 같음.

 

 

 


참조

 

1. StringTrimmerEditor 적용

https://mia-dahae.tistory.com/23

 

 

2. @RequestBody에 적용불가

https://stackoverflow.com/questions/42362490/how-to-auto-trim-strings-of-bean-object-in-spring-with-restful-api

 

 

 

 


 

Studies & History

 

1. 어노테이션으로 진행하려 했으나, 어노테이션에서 각 Controller에서 다루는 파라미터 오브젝트를 class 타입으로 받

을 수가 없어서 방법 변경

 

2. @ControllerAdvice - @StringTrimmerEditor - @InitBinder를 적용하는 방식을 검토하였으나, @RequestBody로 오는 요청값은 해당 방식이 적용 불가하다는 자료를  찾음

 

-StringTrimmerEditor도 PropertyEditor의 한 종류이며, 아래 참조 글에 따르면 PropertyEditor로 디폴트 프로퍼티, 즉 String, Integer 등과 같은 타입이 아니라 다른 타입을 등록해서 사용할 수 있음

 

 

 

 

WebMvcConfigurer 대신 WebMvcConfigurationSupport 해보기

:extends라 그런지 서버 기동 시 초기화 때 converters가 디버깅이 안됨

https://yoojh9.github.io/%EC%8A%A4%ED%94%84%EB%A7%81%EB%B6%80%ED%8A%B8-HttpMessageConverter/

 

 

그냥 String을 deserialize 하는 기본 메서드 자체를 Override 하라는 것 같은 글

https://stackoverflow.com/questions/6852213/can-jackson-be-configured-to-trim-leading-trailing-whitespace-from-all-string-pr?noredirect=1&lq=1

 

 

Serialize, Deserialize 개념 및 @JsonComponent 학습 필요

https://ncucu.me/68

 

https://www.logicbig.com/tutorials/misc/jackson/json-format.html

 

Employee{name='Amy', dateOfBirth=Thu Jul 28 15:51:51 CDT 1988, startDate=Thu Jul 28 15:51:51 CDT 2016, dept=Sales}
-- after serialization --
{"name":"Amy","dateOfBirth":586126311022,"startDate":1469739111026,"dept":"Sales"}
-- after deserialization --
Employee{name='Amy', dateOfBirth=Thu Jul 28 15:51:51 CDT 1988, startDate=Thu Jul 28 15:51:51 CDT 2016, dept=Sales}

 

 

@JsonFormat, Jackson2ObjectMapperBuilderCustomizer

https://addio3305.tistory.com/101

 

날짜 타입 serialization, JSON 변환 원리 기초 : 조졸두님 블로그

https://jojoldu.tistory.com/361

 

 

 

 

 

 

728x90
반응형