Java/Spring

Controller 유효성 검증 리팩터링

권멋져 2025. 6. 9. 16:57

리팩터링 계기

과제 전형을 진행하면서 나름 재밌었기 때문에 이 프로젝트에서 아쉬웠던 부분을 발전, 보완하고 싶다는 생각이 들었다. (결과는 탈락)

 

과제 전형 제출 후 여러 일정이 겹쳐서 바로 코드를 들여다 보지는 못했지만, 평소 생각하던 부분을 바꿔보고 싶다고 생각했고, 그 중 하나는 오늘 이야기 하려는 Controller에서 요청에 대한 Parameter 유효성 검증이다.

 

검증 로직 개선

우선 가장 최근의 Controller의 유효성 검증 로직을 살펴보자

 

@PostMapping()
public ResponseEntity<AlarmResultResponseDto> saveAlarm(@RequestBody AlarmRequestDto alarmRequestDto) {

    AlarmResultResponseDto alarmResultResponseDto = new AlarmResultResponseDto();
    alarmResultResponseDto.setCode("FAIL");

    if (alarmRequestDto.getBody() == null) {
        alarmResultResponseDto.setMessage("전송 메세지가 없습니다.");
        return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
    } else if (alarmRequestDto.getCategory() == null || alarmRequestDto.getCategory().isEmpty()) {
        alarmResultResponseDto.setMessage("전송 대상이 없습니다.");
        return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
    } else if (alarmRequestDto.getUserName() == null || alarmRequestDto.getUserName().isEmpty()) {
        alarmResultResponseDto.setMessage("고객명이 없습니다.");
        return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
    }

    try {
        if (alarmRequestDto.getReserveDate() != null && !alarmRequestDto.getReserveDate().isEmpty()){
            String reserveDate = alarmRequestDto.getReserveDate();
            try {
                LocalDateTime localDateTime = LocalDateTime.parse(reserveDate); // Format Check

                if(localDateTime.getSecond() != 0){
                    alarmResultResponseDto.setMessage("0초만 입력 가능합니다.");
                    return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
                }
                if(LocalDateTime.now().isAfter(localDateTime)){
                    alarmResultResponseDto.setMessage("현재보다 이전 시간에 예약할 수 없습니다.");
                    return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
                }

                alarmReserveService.saveAlarmReserve(alarmRequestDto);
            }catch (DateTimeParseException e){
                alarmResultResponseDto.setMessage("날짜 형식이 잘못됐습니다. (yyyy-mm-ddTHH:MM:00)");
                return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
            }
            alarmResultResponseDto.setCode("SUCCESS");
            alarmResultResponseDto.setMessage("알람 요청이 예약됐습니다.");
            return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.OK);
        }else{
            Map<String, String> resultMap = producerService.insert(alarmRequestDto);
            alarmResultResponseDto.setCode(resultMap.get("code"));
            alarmResultResponseDto.setMessage(resultMap.get("message"));

            return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.valueOf(Integer.parseInt(resultMap.get("state"))));

        }
    } catch (RuntimeException e) {
        alarmResultResponseDto.setCode("ERROR");
        alarmResultResponseDto.setMessage("알람 요청 실패했습니다. : " + e.getMessage());
        return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.INTERNAL_SERVER_ERROR);
    }

}

 

내가 생각하는 위 코드의 문제점은 다음과 같았다.

 

  1. Setter의 상용으로 반복되는 코드가 너무 많음
  2. 기본적인 요청 Parameter의 유효성 검증 로직이 불필요함

 

반복되는 코드 제거

우선 반복되는 코드를 먼저 제거하고 싶었다.

 

내가 찾은 반복되는 부분은 다음과 같다.

alarmResultResponseDto.setCode("ERROR");
alarmResultResponseDto.setMessage("알람 요청 실패했습니다. : " + e.getMessage());
return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.INTERNAL_SERVER_ERROR)

 

그래서 Contrloller에서 사용하기 위한 ResponseEntity를 반환하는 static 메소드를 하나 만들었다.

 

public class ControllerUtil {

    public static ResponseEntity<AlarmResultResponseDto> getAlarmResultResponseEntity(String code, String message, HttpStatus status) {

        AlarmResultResponseDto alarmResultResponseDto = AlarmResultResponseDto.builder()
                .code(code)
                .message(message)
                .build();

        return new ResponseEntity<>(alarmResultResponseDto,status);
    }
    
}

 

이 static 메소드는 code, message, HTTP Status만 넣으면 ResponseEntity를 반환한다.

 

코드가 3줄에서 약 10줄 이상으로 늘어났지만 가독성은 더 좋아질 것이라 예상된다.

위 메소드를 활용해서 다음과 같이 코드를 수정했다.

 

@PostMapping()
public ResponseEntity<AlarmResultResponseDto> saveAlarm(@RequestBody AlarmRequestDto alarmRequestDto) {
    try {
        if (alarmRequestDto.getBody() == null) {
            alarmResultResponseDto.setMessage("전송 메세지가 없습니다.");
            return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
        } else if (alarmRequestDto.getCategory() == null || alarmRequestDto.getCategory().isEmpty()) {
            alarmResultResponseDto.setMessage("전송 대상이 없습니다.");
            return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
        } else if (alarmRequestDto.getUserName() == null || alarmRequestDto.getUserName().isEmpty()) {
            alarmResultResponseDto.setMessage("고객명이 없습니다.");
            return new ResponseEntity<>(alarmResultResponseDto,HttpStatus.BAD_REQUEST);
        }

        if (alarmRequestDto.getReserveDate() != null && !alarmRequestDto.getReserveDate().isEmpty()) {
            String reserveDate = alarmRequestDto.getReserveDate();
            try {
                LocalDateTime localDateTime = LocalDateTime.parse(reserveDate); // Format Check

                if(localDateTime.getSecond() != 0){
                    throw new IllegalArgumentException("0초만 입력 가능합니다.");
                }
                if(LocalDateTime.now().isAfter(localDateTime)){
                    throw new IllegalArgumentException("현재보다 이전 시간에 예약할 수 없습니다.");
                }
                alarmReserveService.saveAlarmReserve(alarmRequestDto);
            } catch (DateTimeParseException e){
                throw new IllegalArgumentException("날짜 형식이 잘못됐습니다. (yyyy-mm-ddTHH:MM:00)");
            }
            return ControllerUtil.getAlarmResultResponseEntity("SUCCESS","알람을 성공적으로 예약했습니다.",HttpStatus.OK);
        } else {
            Map<String, String> resultMap = producerService.insert(alarmRequestDto);
            return ControllerUtil.getAlarmResultResponseEntity(
                    resultMap.get("code")
                    ,resultMap.get("message")
                    ,HttpStatus.valueOf(Integer.parseInt(resultMap.get("state")))
            );
        }
    } catch (IllegalArgumentException e) {
        return ControllerUtil.getAlarmResultResponseEntity("FAIL",e.getMessage(),HttpStatus.BAD_REQUEST);
    } catch (RuntimeException e) {
        return ControllerUtil.getAlarmResultResponseEntity("ERROR",e.getMessage(),HttpStatus.INTERNAL_SERVER_ERROR);
    }

}

 

기존에 Setter로 필드에 값을 넣어주던 부분들은 모두 IllegalArgumentException으로 사용자 message와 함께 throw를 시켰고, 이를 catch에서 받아 내가 만든 static method에 인자로 사용했다.

 

Spring에서 제공하는 유효성 검증 기능 활용

두번째는 Parameter로 받는 RequestBody 객체 내부 필드에 null을 체크하고 싶었다.

 

이는 Jakarta 패키지에서 제공하는 @Valid 어노테이션을 활용했다. (

 

우선 의존성을 추가해야한다.

dependencies {
	implementation 'org.springframework.boot:spring-boot-starter-validation'
}

 

하지만 @Valid 어노테이션을 달아도 정상적으로 예외를 던지지 않았다.

그 이유는 이미 내부 로직에서 try-catch를 사용하고 있어서 그렇기 때문이다.

 

따라서 Parmeter에서 BindingResult 객체를 받아 처리했다.

 

@PostMapping()
public ResponseEntity<AlarmResultResponseDto> saveAlarm(@Valid @RequestBody AlarmRequestDto alarmRequestDto, BindingResult bindingResult) {
    try {
        if (bindingResult.hasErrors()) {
            throw new IllegalArgumentException(bindingResult.getAllErrors().get(0).getDefaultMessage());
        }

        if (alarmRequestDto.getReserveDate() != null && !alarmRequestDto.getReserveDate().isEmpty()) {
            String reserveDate = alarmRequestDto.getReserveDate();
            try {
                LocalDateTime localDateTime = LocalDateTime.parse(reserveDate); // Format Check

                if(localDateTime.getSecond() != 0){
                    throw new IllegalArgumentException("0초만 입력 가능합니다.");
                }
                if(LocalDateTime.now().isAfter(localDateTime)){
                    throw new IllegalArgumentException("현재보다 이전 시간에 예약할 수 없습니다.");
                }
                alarmReserveService.saveAlarmReserve(alarmRequestDto);
            } catch (DateTimeParseException e){
                throw new IllegalArgumentException("날짜 형식이 잘못됐습니다. (yyyy-mm-ddTHH:MM:00)");
            }
            return ControllerUtil.getAlarmResultResponseEntity("SUCCESS","알람을 성공적으로 예약했습니다.",HttpStatus.OK);
        } else {
            Map<String, String> resultMap = producerService.insert(alarmRequestDto);
            return ControllerUtil.getAlarmResultResponseEntity(
                    resultMap.get("code")
                    ,resultMap.get("message")
                    ,HttpStatus.valueOf(Integer.parseInt(resultMap.get("state")))
            );
        }
    } catch (IllegalArgumentException e) {
        return ControllerUtil.getAlarmResultResponseEntity("FAIL",e.getMessage(),HttpStatus.BAD_REQUEST);
    } catch (RuntimeException e) {
        return ControllerUtil.getAlarmResultResponseEntity("ERROR",e.getMessage(),HttpStatus.INTERNAL_SERVER_ERROR);
    }

}

 

앞에서 중복되는 코드를 정리하고 나니 BindingResult로 받아 처리할 때도 한 줄로 처리가 가능했다.

 

그리고 어떤 필드에 어떤 유효성을 검증할 것인지는 RequestBody로 받는 객체 내부에 어노테이션으로 처리했다.

public class AlarmRequestDto {

    @NotNull(message = "userName을 입력해주세요.")
    private String userName;
    @NotNull(message = "category을 입력해주세요.")
    private String category;
    private String reserveDate;
    @NotNull(message = "body을 입력해주세요.")
    private Map<String, String> body;
}

 

message를 명시하면 bindingResult로 받을때 출력할 메세지도 로직에서 구현할 필요가 없어진다.

 

테스트 코드

더보기

열받게 테스트 하는데 'Cannot resolve reference to bean 'jpaMappingContext' while setting constructor argument' 이런 오류가 계속 뜬다.

 

해당 테스트 클래스에 다음과 같은 어노테이션 달아주자

 

@MockBean(JpaMetamodelMappingContext.class)

 

그럼 MockBean이 만들어지면서 테스트가 동작한다.

 

* 그런데 이게 또 deprecated 된다고 한다.

 

void saveAlarm() throws Exception {

        Map<String, String> body = new HashMap<String, String>();
        body.put("test", "test");

//        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
//                        .requestAttr("username","test")
//                .requestAttr("category","test")
//                .requestAttr("reservedate","2025-06-01T00:00:00")
//                .requestAttr("body",body))
//                .andExpect(status().isOk());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2025-06-01T00:00:00")
                        )
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("reservedate","2025-06-01T00:00:00")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                .requestAttr("category","test")
                .requestAttr("reservedate","2025-06-01T00:00:00")
                .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                .requestAttr("username","test")
                .requestAttr("category","test")
                .requestAttr("reservedate","2025-06-01T00:00:10")
                .requestAttr("body",body))
                .andExpect(status().isBadRequest())
                ;

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2024-06-01T00:00:10")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2025/06/01T00:00:10")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

    }
void saveAlarm() throws Exception {

        Map<String, String> body = new HashMap<String, String>();
        body.put("test", "test");

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2025-06-01T00:00:00")
                        )
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("reservedate","2025-06-01T00:00:00")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                .requestAttr("category","test")
                .requestAttr("reservedate","2025-06-01T00:00:00")
                .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                .requestAttr("username","test")
                .requestAttr("category","test")
                .requestAttr("reservedate","2025-06-01T00:00:10")
                .requestAttr("body",body))
                .andExpect(status().isBadRequest())
                ;

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2024-06-01T00:00:10")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

        mockMvc.perform(MockMvcRequestBuilders.post("/alarms")
                        .requestAttr("username","test")
                        .requestAttr("category","test")
                        .requestAttr("reservedate","2025/06/01T00:00:10")
                        .requestAttr("body",body))
                .andExpect(status().isBadRequest());

    }

 

@Valid 와 @Validated

찾다보니 Validated 라는 SpringBoot에서 제공하는 기능이 있더라..

 

결과적으로 @Valid와 @Validated는 같은 역할을 하는데 차이점은 그룹 검증이 가능하다.

 

public class AlarmRequestDto {

    @NotNull(groups = Create.class)
    private String userName;

    @Size(min=2, max=10, groups = {Create.class, Update.class})
    private String category;

    public interface Create {}
    public interface Update {}
}

 

위와 같이 interface를 선언하고 이를 유효성 검증을 위한 어노테이션에 groups 옵션으로 주게되면 해당 옵션이 있는 경우에만 유효성을 검증하게 된다.

 

해당 옵션을 사용하고 싶으면 아래 처럼 @Validated에 옵션을 주면 된다.

 

@PostMapping
public ResponseEntity<?> create(@Validated(AlarmRequestDto.Create.class) @RequestBody AlarmRequestDto dto) {}