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;
}
}
서버에서 요청을 처리하는 흐름은 다음과 같다
- GET /basic을 처리할 수 있는 HandlerMapping 조회
- (1)에서 조회한 Handler에 적합한 HandlerAdapter 조회
- ArgumentResolver를 통해서 요청을 처리하는 Handler의 Parameter들을 정제
- (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));
}
}