각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 2 회고 (114일차)

제이온 (Jayon) 2021. 5. 27.

안녕하세요? 제이온입니다.

 

어제 과음하느라 114일차 회고를 부득이하게 오늘 작성하려고 합니다.

 

 

협업 미션 에러 핸들링

오늘은 우테코를 온라인으로 진행했고, 에러 핸들링을 하는 데 모든 시간을 사용했습니다. 브라운이 제공해 주신 뼈대 코드에는 에러 핸들링이 대부분 되어 있지 않아서 역, 노선, 구간, 경로, 멤버, 인증 부분을 모두 손수.. 에러 핸들링을 해 주어야 했습니다. 그리고 테스트까지도 새롭게 작성해야했죠.

 

모든 예외 처리는 크게 입력값 검증과 비즈니스 로직 검증으로 나누었습니다. 비즈니스 로직 검증은 서비스 단과 DAO 단중에 어디서 할 지 고민해 보았는데, 이번에는 서비스에서 처리하기로 결정했습니다. 서비스 단에서 예외 처리를 하면 동시성 이슈가 발생할 수 있지만, DAO 단에서 예외 처리를 하려면 DAO로 인해 어떠한 에러가 발생하는지 구체적으로 알고 있어야 한다는 점이 마음에 들지 않았습니다. 그래서 우선은 서비스에서 처리하되, 동시성 이슈의 해결법은 천천히 고민해 보려고 합니다.

 

 

지하철역, 노선, 구간, 경로 예외 처리

입력값 검증은 스프링의 @Valid를 이용하였습니다. 입력으로 들어온 Dto에 대해서 아래와 같이 조건을 제시해 주었습니다.

 

 

public class StationRequest {

    @NotBlank(message = "이름에 공백만 있을 수 없습니다.")
    @Length(min = 2, max = 10, message = "역 이름은 2글자 이상 10글자 이하여야합니다.")
    @Pattern(regexp = "^[가-힣|0-9]*$", message = "역 이름은 한글 또는 숫자여야 합니다.")
    private String name;

    public StationRequest() {
    }

    public StationRequest(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }

    public Station toStation() {
        return new Station(name);
    }
}

 

 

@NotBlank는 null, 빈 문자열, 공백이 아닌 1글자이상이 들어가야 한다는 제약 조건이며, Length는 길이 제약 조건, Pattern은 정규 표현식에 따른 제약 조건입니다. 그리고 이 제약 조건에 걸리면 "MethodArgumentNotValidException" 예외가 발생합니다. 이것을 핸들러에서 잡아서 에러 dto를 던져주면 됩니다.

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

    private final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ResponseEntity<ErrorResponse> unValidBindingHandler(MethodArgumentNotValidException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.badRequest().body(new ErrorResponse(bindingResultMessage(exception)));
    }

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<ErrorResponse> authorizeExceptionHandler(final AuthorizationException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(new ErrorResponse(exception.getMessage()));
    }

    @ExceptionHandler(InvalidInputException.class)
    public ResponseEntity<ErrorResponse> InvalidExceptionHandler(InvalidInputException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.badRequest().body(new ErrorResponse(exception.getMessage()));
    }

    @ExceptionHandler(NotFoundException.class)
    public ResponseEntity<ErrorResponse> NotFoundException(NotFoundException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse(exception.getMessage()));
    }

    @ExceptionHandler(DuplicatedException.class)
    public ResponseEntity<ErrorResponse> duplicatedExceptionHandler(DuplicatedException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.CONFLICT).body(new ErrorResponse(exception.getMessage()));
    }

    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
    public ResponseEntity<ErrorResponse> unsupportedMediaTypeSupportedExceptionHandler(
        final HttpMediaTypeNotSupportedException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNSUPPORTED_MEDIA_TYPE).body(new ErrorResponse(exception.getMessage()));
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<ErrorResponse> serverExceptionHandler(RuntimeException exception) {
        logger.error(exception.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(new ErrorResponse(exception.getMessage()));
    }

    private String bindingResultMessage(MethodArgumentNotValidException exception) {
        return exception.getBindingResult().getAllErrors().get(0).getDefaultMessage();
    }
}

 

 

바인딩 로직을 설명하는 김에 다른 에러 핸들러도 모두 작성해 두었습니다.

 

 

그리고 해당 입력값 검증할 객체 앞에 @Valid 어노테이션을 붙이면 됩니다.

 

 

    @PostMapping("/stations")
    public ResponseEntity<StationResponse> createStation(@Valid @RequestBody StationRequest stationRequest) {
        StationResponse station = stationService.saveStation(stationRequest);
        return ResponseEntity.created(URI.create("/stations/" + station.getId())).body(station);
    }

 

 

그렇다면, 입력값 검증 단위 테스트는 어떻게 할까요? 결론부터 말하자면, Validator라는 클래스를 사용합니다.

 

 

class StationRequestTest {

    private Validator validator;

    @BeforeEach
    void setUp() {
        final ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
        validator = factory.getValidator();
    }

    @DisplayName("이름 입력값이 빈 값, null, 특수 문자, 영어, 글자수는 2자 미만이거나 10자 초과면 안 된다.")
    @ParameterizedTest
    @NullAndEmptySource
    @ValueSource(strings = {"썬", "가나다라마바사아자차카타파하, %, )))"})
    void create(String name) {
        final StationRequest stationRequest = new StationRequest(name);
        final Set<ConstraintViolation<StationRequest>> violations = validator.validate(stationRequest);
        assertThat(violations).isNotEmpty();
    }
}

 

 

ValidatorFactory를 통해 Validator를 주입받고, 입력값 검증할 객체를 검증합니다. 그리고 검증 결과를 Set에 담는데, 이 Set에는 에러가 들어갑니다. 에러가 발생하지 않는다면 해당 Set은 비어있게 됩니다. 다만, 제가 아직 이 Validator에 대해서는 개념은 잘 알지 못하는 상태라 추후 학습 예정입니다.

 

 

비즈니스 로직 검증은 적당히 잘하면 되는데, 예외 처리 도중 SQL의 exist라는 키워드를 알게 되었습니다.

 

 

    public boolean existsByName(String name) {
        String sql = "select exists(select * from STATION where name = ?)";
        return jdbcTemplate.queryForObject(sql, Boolean.class, name);
    }

 

 

위와 같이 원하는 데이터가 존재하는지 여부만 판단할 수 있습니다. 기존의 findXXX()를 호출하게 되면 쓸데 없이 객체가 생기는데, exist를 통해 이 문제를 해결할 수 있었습니다.

 

이와 같은 방식으로 나머지 노선, 구간, 경로에도 예외 처리를 적용했습니다.

 

 

정리

예외 처리를 하고 테스트를 하는 과정이 꽤나 고된 일이었습니다. 하지만, 기능 구현만큼 예외 처리가 정말 중요한 일이므로 앞으로도 세밀하게 예외 처리와 테스트를 잘 구현해야겠습니다.

댓글

추천 글