[Spring] Converter

HttpServletRequest

서버에 요청을 보낼때 QueryString에 요청 정보들을 보내면 서버에서는 HttpServletRequest의 getParameter를 통해서 값을 얻을 수 있다

  • HttpServletRequest getParameter의 리턴 타입은 String

@RestController
public class BasicRequestController {
    @GetMapping("/basic")
    public String basicRequest(HttpServletRequest request) {
        String tech = request.getParameter("tech");
        int level = Integer.parseInt(request.getParameter("level"));
        return tech + " -> " + level;
    }
}

 

서버에서 요청을 처리하는 흐름은 다음과 같다

  1. GET /basic을 처리할 수 있는 HandlerMapping 조회
  2. (1)에서 조회한 Handler에 적합한 HandlerAdapter 조회
  3. ArgumentResolver를 통해서 요청을 처리하는 Handler의 Parameter들을 정제
  4. (4)에서 정제된 데이터와 함께 HandlerAdapter가 Handler를 invoke

 

@RequestParam

@RestController
public class BasicRequestController {
    @GetMapping("/basic")
    public String basicRequest(@RequestParam String tech, @RequestParam Integer level) {
        return tech + " -> " + level;
    }
}

앞서 본 코드에서는 HttpServletRequest의 getParameter를 통해서 queryString 정보를 받았다

새로운 코드에서는 @RequestParam을 이용해서 queryString 정보들을 바인딩하고있다

  • 추가적으로 getParameter의 return type은 String이지만 현재 @RequestParam Integer level을 보면 Integer type으로 받고 있음을 확인할 수 있다
Integer level이 정상적으로 바인딩될 수 있는 이유는 바로 Spring의 내부적인 메커니즘으로 인해 중간에서 타입을 변환해주었기 때문이다

 

 

Converter<S, T>

Spring에서는 Converter라는 확장 가능한 인터페이스 형태의 컨버터를 제공해주고 있다

@RestController
public class BasicRequestController {
    @GetMapping("/basic")
    public String basicRequest(@RequestParam String tech, @RequestParam Integer level) {
        return tech + " -> " + level;
    }
}

앞선 설명에서 본 코드에서 @RequestParam Integer level은 여러 Converter 구현체 중에서 StringToNumberConverterFactory 내부의 StringToNumber에 의해서 Converting된다

 

  • StringToNumber의 targetType을 보면 Integer Class로 디버깅됨을 확인할 수 있다
  • 이를 통해서 ArgumentResolver가 level이라는 파라미터의 type을 Integer로 판단해서 ConversionService의 convert를 호출할 때 이와 관련된 정보도 넘겨줌을 예측할 수 있다

  • StringToNumber에서 반환되는 NumberUtils.parseNumber(source, this.targetType)에 step into를 한 모습이다
  • 결과적으로 level의 targetClass가 Integer.class임에 따라 변환이 수행됨을 확인할 수 있다

 

Custom Converter

Converting의 과정을 위에서 대략적으로 파악해보았다

이제 Converter 인터페이스를 통해서 커스텀한 Converter를 구현해보자

// Convert String -> Student
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class Student {
    private Long studentId;
    private String name;
}

public class StringToStudentConverter implements Converter<String, Student> {
    @Override
    public Student convert(String source) {
        if (!StringUtils.hasText(source)) {
            throw new IllegalArgumentException("Can't convert... [Request Error : String is empty]");
        }

        String[] divideIdAndName = divideIdAndName(source);
        return new Student(Long.valueOf(divideIdAndName[0]), divideIdAndName[1]);
    }

    private String[] divideIdAndName(String source) {
        int nameIndex = getNameIndex(source);

        if (nameIndex == -1) {
            throw new IllegalArgumentException("Can't convert... [Request Error : String has no Korean Name]");
        }

        return new String[]{source.substring(0, nameIndex), source.substring(nameIndex)};
    }

    private int getNameIndex(String source) {
        char[] charArray = source.toCharArray();

        for (int i = 0; i < charArray.length; i++) {
            if (Character.getType(charArray[i]) == Character.OTHER_LETTER) {
                return i;
            }
        }

        return -1;
    }
}
StringToStudentConverter는 들어온 String Input에 대해서 Student 타입의 Object로 변환해주는 Converter이다

 

 

StringToStudentConverter 테스트 코드

커스텀하게 구현한 Converter가 정상적으로 동작하는지 테스트코드를 작성해보자

class StringToStudentConverterTest {
    private final StringToStudentConverter converter = new StringToStudentConverter();

    @Test
    @DisplayName("입력으로 들어온 String이 비어있다")
    void 입력으로_들어온_String이_비어있다() {
        // given
        final String inputStringA = "";
        final String inputStringB = null;

        // when
        assertThatThrownBy(() -> converter.convert(inputStringA))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Can't convert... [Request Error : String is empty]");
        assertThatThrownBy(() -> converter.convert(inputStringB))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Can't convert... [Request Error : String is empty]");
    }

    @Test
    @DisplayName("입력으로 들어온 String에 한글 이름이 없다")
    void 입력으로_들어온_String에_한글_이름이_없다() {
        // given
        final String inputStringA = "201811251";
        final String inputStringB = "201811251Seo";

        // when
        assertThatThrownBy(() -> converter.convert(inputStringA))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Can't convert... [Request Error : String has no Korean Name]");
        assertThatThrownBy(() -> converter.convert(inputStringB))
                .isInstanceOf(IllegalArgumentException.class)
                .hasMessage("Can't convert... [Request Error : String has no Korean Name]");
    }

    @Test
    @DisplayName("입력으로 들어온 String에 대한 정상적인 Converting 성공")
    void 입력으로_들어온_String에_대한_정상적인_Converting_성공() {
        // given
        final String inputStringA = "201610000홍길동";
        final String inputStringB = "201811251서지원";
        final String inputStringC = "202010820신";

        // when
        Student convertStringToStudentA = converter.convert(inputStringA);
        Student convertStringToStudentB = converter.convert(inputStringB);
        Student convertStringToStudentC = converter.convert(inputStringC);

        // then
        assertThat(convertStringToStudentA).isNotNull();
        assertThat(convertStringToStudentA.getStudentId()).isEqualTo(201610000);
        assertThat(convertStringToStudentA.getName()).isEqualTo("홍길동");

        assertThat(convertStringToStudentB).isNotNull();
        assertThat(convertStringToStudentB.getStudentId()).isEqualTo(201811251);
        assertThat(convertStringToStudentB.getName()).isEqualTo("서지원");

        assertThat(convertStringToStudentC).isNotNull();
        assertThat(convertStringToStudentC.getStudentId()).isEqualTo(202010820);
        assertThat(convertStringToStudentC.getName()).isEqualTo("신");
    }
}

 

 

ConversionService

위에서는 실제 구현한 Converter를 가져오고 직접 호출함으로써 변환 과정을 수행하였다

이 흐름을 Client - Server 구조로 나눠보자
Converter를 호출하는 Client는 구체적인 Converter 구현체의 converter를 호출함으로써 변환을 하였다
→ 이러한 구조는 Client가 Server의 구현체에 의존하는 형태라고 볼 수 있다

직접적인 구현체에 의존하게 된다면 확장성이 떨어지고 테스트 코드 작성에도 어려움을 느끼게 된다

따라서 이렇게 직접적으로 구현체에 의존하는 방식을 해결하기 위해서 Spring에서는 ConversionService라는 기능을 제공한다

 

@Test
@DisplayName("ConversionService를 통한 Converter 등록과 호출의 분리")
void ConversionService를_통한_Converter_등록과_호출의_분리() {
    // given
    DefaultConversionService conversionService = new DefaultConversionService();
    conversionService.addConverter(new StringToStudentConverter()); // [컨버터 등록]

    final String inputStringA = "201610000홍길동";
    final String inputStringB = "201811251서지원";

    // when
    Student convertStringToStudentA = conversionService.convert(inputStringA, Student.class); // [컨버터 호출]
    Student convertStringToStudentB = conversionService.convert(inputStringB, Student.class); // [컨버터 호출]

    // then
    assertThat(convertStringToStudentA).isNotNull();
    assertThat(convertStringToStudentA.getStudentId()).isEqualTo(201610000);
    assertThat(convertStringToStudentA.getName()).isEqualTo("홍길동");

    assertThat(convertStringToStudentB).isNotNull();
    assertThat(convertStringToStudentB.getStudentId()).isEqualTo(201811251);
    assertThat(convertStringToStudentB.getName()).isEqualTo("서지원");
}

  • ConversionService를 통해서 Converter의 등록/사용을 확실하게 구분할 수 있다
  • 그에 따라서 Client는 구현체가 무엇이든 상관없이 ConversionService의 convert를 호출함으로써 변환을 할 수 있게 된다

 

Custom Converter 등록

웹 애플리케이션에서 커스텀하게 구현한 Converter를 적용하기 위해서는 아래와 같은 설정을 해줘야 한다

// Configuration
@Configuration
public class CustomConverterConfig implements WebMvcConfigurer {
    @Override
    public void addFormatters(FormatterRegistry registry) {
        registry.addConverter(new StringToStudentConverter());
    }
}

// RestController
@RestController
public class RequestConverterController {
    @GetMapping("/convert-param")
    public Student convertStringToStudentWithRequestParam(@RequestParam Student student) {
        return student;
    }

    // ExceptionHandling
    @Getter
    @AllArgsConstructor
    static class ErrorResponse {
        private int status;
        private String code;
        private String message;
    }

    @ResponseStatus(HttpStatus.BAD_REQUEST)
    @ExceptionHandler(IllegalArgumentException.class)
    public ErrorResponse catchConvertException(IllegalArgumentException e) {
        HttpStatus badRequest = HttpStatus.BAD_REQUEST;
        return new ErrorResponse(badRequest.value(), badRequest.getReasonPhrase(), e.getMessage());
    }
}

 

 

Controller 테스트 코드

@WebMvcTest
class RequestConverterControllerMockingTest {
    @Autowired
    private MockMvc mockMvc;

    private static String EMPTY_STRING_CONVERT_FAIL_JSON;
    private static String NO_KOREAN_NAME_CONVERT_FAIL_JSON;
    private static String STUDENT_CONVERT_SUCCESS_JSON;

    @BeforeEach
    void before() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper(); // Object <-> JSON Translator Library
        HttpStatus badRequest = HttpStatus.BAD_REQUEST; // 400
        Student successConvertStringToStudent = new Student(201811251L, "서지원");

        EMPTY_STRING_CONVERT_FAIL_JSON = objectMapper.writeValueAsString( // Object -> JSON
                new RequestConverterController.ErrorResponse(
                        badRequest.value(),
                        badRequest.getReasonPhrase(),
                        "Can't convert... [Request Error : String is empty]"
                )
        );
        NO_KOREAN_NAME_CONVERT_FAIL_JSON = objectMapper.writeValueAsString( // Object -> JSON
                new RequestConverterController.ErrorResponse(
                        badRequest.value(),
                        badRequest.getReasonPhrase(),
                        "Can't convert... [Request Error : String has no Korean Name]"
                )
        );
        STUDENT_CONVERT_SUCCESS_JSON = objectMapper.writeValueAsString(successConvertStringToStudent); // Object -> JSON
    }

    @Test
    @DisplayName("Converting 대상인 student 파라미터 값이 비어있다")
    public void Converting_대상인_student_파라미터_값이_비어있다() throws Exception {
        // given
        final String inputString = "";

        // when-then
        RequestBuilder requestBuilder = MockMvcRequestBuilders
                .get("/convert-param")
                .param("student", inputString);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isBadRequest())
                .andExpect(MockMvcResultMatchers.content().json(EMPTY_STRING_CONVERT_FAIL_JSON));
    }

    @Test
    @DisplayName("Converting 대상인 student 파라미터 값에 한글 이름이 없다")
    void Converting_대상인_student_파라미터_값에_한글_이름이_없다() throws Exception {
        // given
        final String inputStringA = "201811251";
        final String inputStringB = "201811251Seo";

        // when-then
        RequestBuilder requestBuilderA = MockMvcRequestBuilders
                .get("/convert-param")
                .param("student", inputStringA);
        RequestBuilder requestBuilderB = MockMvcRequestBuilders
                .get("/convert-param")
                .param("student", inputStringB);

        mockMvc.perform(requestBuilderA)
                .andExpect(MockMvcResultMatchers.status().isBadRequest())
                .andExpect(MockMvcResultMatchers.content().json(NO_KOREAN_NAME_CONVERT_FAIL_JSON));
        mockMvc.perform(requestBuilderB)
                .andExpect(MockMvcResultMatchers.status().isBadRequest())
                .andExpect(MockMvcResultMatchers.content().json(NO_KOREAN_NAME_CONVERT_FAIL_JSON));
    }

    @Test
    @DisplayName("Converting 대상인 student 파라미터에 대한 Converting 성공")
    void Converting_대상인_student_파라미터에_대한_Converting_성공() throws Exception {
        // given
        final String inputString = "201811251서지원";

        // when-then
        RequestBuilder requestBuilder = MockMvcRequestBuilders
                .get("/convert-param")
                .param("student", inputString);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().json(STUDENT_CONVERT_SUCCESS_JSON));
    }
}

 

 

@ModelAttribute 내부 DTO Converting

지금까지의 구현 및 테스트는 단일 value에 대한 Converting이였다

DTO 내부에 중첩된 DTO에 대해서도 Converting이 정상적으로 수행되는지 확인해보자

// DTO
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class StudentInformation {
    private String school;
    private Student student; // Converting 대상
}

// Handler
@GetMapping("/convert-model")
public StudentInformation converStringToStudentInformationWithModelAttribute(@ModelAttribute StudentInformation studentInformation) {
    return studentInformation;
}
@WebMvcTest
class RequestConverterControllerMockingTest {
    @Autowired
    private MockMvc mockMvc;

    ...

    private static String MODEL_ATTRIBUTE_INNER_STUDENT_CONVERT_SUCCESS_JSON;

    @BeforeEach
    void before() throws JsonProcessingException {
        ObjectMapper objectMapper = new ObjectMapper();
        HttpStatus badRequest = HttpStatus.BAD_REQUEST;
        Student student = new Student(201811251L, "서지원");
        StudentInformation studentInformation = new StudentInformation("경기대학교", student);

        ...
        
        MODEL_ATTRIBUTE_INNER_STUDENT_CONVERT_SUCCESS_JSON = objectMapper.writeValueAsString(studentInformation);
    }
    
    ...

    @Test
    @DisplayName("ModelAttribute Annotation 내부 Converting 테스트")
    void ModelAttribute_Annotation_내부_Converting_테스트() throws Exception {
        // given
        final String school = "경기대학교";
        final String student = "201811251서지원";

        // when-then
        RequestBuilder requestBuilder = MockMvcRequestBuilders
                .get("/convert-model")
                .param("school", school)
                .param("student", student);

        mockMvc.perform(requestBuilder)
                .andExpect(MockMvcResultMatchers.status().isOk())
                .andExpect(MockMvcResultMatchers.content().json(MODEL_ATTRIBUTE_INNER_STUDENT_CONVERT_SUCCESS_JSON));
    }
}