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

[우아한 테크코스 3기] LEVEL 2 회고 - 지하철 경로 조회 3단계 미션 2차 피드백을 받아 보다 (107일차)

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

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

 

오늘은 석가탄신일이지만, 인상 깊게 배운 내용과 피드백이 있어서 기록해 두려고 합니다.

 

 

에러 핸들링 중 발견한 의문점

이번 지하철 경로 조회 미션은 많은 코드가 주어져 있고, 필요한 부분만 제가 새롭게 구현하는 방식이었습니다. 그리고 주어진 코드를 보면, 기능적인 코드는 대부분 잘 구현되어있었지만, 에러 핸들링은 SQLException밖에 없었습니다.

 

 

    @ExceptionHandler(SQLException.class)
    public ResponseEntity<Void> handleSQLException(SQLException exception) {
        return ResponseEntity.badRequest().build();
    }

 

 

또한, 테스트 코드도 상당수 이미 구현되어있었는데, 중복된 역 이름을 입력하면 400번 에러가 발생하도록 설계되어 있었습니다. 그리고 테스트 자체도 잘 통과가 됩니다. 그래서 저는 중복된 키로 인해 발생하는 DuplicatedKeyException이 SQLException의 상속을 받을 것이라고 추측하였습니다.

 

이후에 다른 것도 같이 에러 핸들링을 하기 위하여 ControllerAdvice를 정의하였고, 아래와 같이 코드를 작성했습니다.

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Void> handleBusinessException(BusinessException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<Void> handleAuthorizationException(AuthorizationException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    @ExceptionHandler(SQLException.class)
    public ResponseEntity<Void> handleSQLException(SQLException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Void> handleSeverException(RuntimeException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

 

 

익셉션의 특성에 맞게 나누었고, 커스텀 익셉션 이외의 익셉션에 대해서는 모두 handleSeverException()에서 처리하도록 만들었습니다. 그런데, 이 상태에서 중복된 역 관련 테스트를 돌리면 실패합니다. 그 이유를 보니, 기댓값은 400번 에러 코드인데 실제 값은 500번 에러 코드였기 때문입니다.

 

handleSeverException()에 브레이킹 포인트를 찍어놓고 디버깅을 해 보았더니, 인자에 DuplicatedKeyException이 잡히는 것을 확인하였습니다. 실제로 DuplicatedKeyException을 조사해 보니, SQLException에 상속을 받지 않고 RuntimeException에 상속을 받으므로 handleSeverException()에서 잡히는 것이었습니다.

 

그래서 이번에는 handleSeverException()을 주석 처리하고 다시 테스트를 돌렸더니 성공하였습니다. handleSQLException()에 브레이킹 포인트를 찍어놓고 디버깅을 해 보았더니, 인자에 JdbcSQLIntegrityConstraintViolationException이 잡히는 것을 확인하였습니다.

 

이번에는 handleSeverException()와 handleSQLException() 모두에 브레이킹 포인트를 찍어놓고 디버깅을 해 보았더니, handleSeverException()에서 DuplicatedException이 먼저 잡히는 것을 확인하였습니다. 이를 통해, DuplicatedException 이후에 JdbcSQLIntegrityConstraintViolationException이 잡히는 것을 알 수 있습니다.

 

여기서 제가 들었던 의문은 "handleSeverException()을 주석 처리하면, DuplicatedKeyException을 catch할 대상이 없으므로 테스트가 터져야 하는 것 아닌가?"였습니다. 하지만, 실제로 handleSeverException()을 주석 처리하더라도 정상적으로 테스트는 성공합니다.

 

이 의문을 코니에게 질문하였습니다. 코니는 @ExceptionHandler가 최상위 예외 또는 Wrapper 예외 내의 중첩된 예외도 잡아내기 때문에 테스트가 정상적으로 작동하는 것이라고 말씀해 주셨습니다. 저는 Wrapper 예외가 정확히 무엇인지 모르겠어서 구글링을 통해 이해할 수 있었습니다. 자세한 링크는 이곳을 참고해 주시길 바랍니다.

 

Wrapper 예외는 쉽게 말하면 아래 코드와 같습니다.

 

 

catch(SQLException e) {
    throw DuplicatedKeyException(e);
}

 

 

여기서 Wrapper 예외는 SQLException이 되는 것이고, 그것의 중첩 예외가 DuplicatedKeyException()이 되는 것입니다. @ExceptionHandler는 내부적으로 상위 예외의 중첩 예외를 잡을 대상이 없는지 먼저 찾아보고, 없다면 Wrapper 예외 내의 중첩된 예외를 잡을 대상이 있는지 확인합니다.

 

 

@RestControllerAdvice
public class GlobalExceptionHandler {

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

    @ExceptionHandler(BusinessException.class)
    public ResponseEntity<Void> handleBusinessException(BusinessException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(AuthorizationException.class)
    public ResponseEntity<Void> handleAuthorizationException(AuthorizationException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
    }

    @ExceptionHandler(SQLException.class)
    public ResponseEntity<Void> handleSQLException(SQLException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

    @ExceptionHandler(RuntimeException.class)
    public ResponseEntity<Void> handleSeverException(RuntimeException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
}

 

 

ControllerAdvice가 위와 같이 정의되어 있고, DuplicatedKeyException이 발생하였다면, 그 예외의 상위 예외들(자기 자신도 포함)을 찾아봅니다. DuplicatedKeyException을 타고 들어가보면, 'DataIntegrityViolationException -> NonTransientDataAccessException -> DataAccessException -> NestedRuntimeException -> RuntimeException'와 같은 계층 구조를 이루는 것을 알 수 있습니다. 그런데, 위 예외들 중에 RuntimeException이 핸들러로 존재하므로 handleSeverException()이 동작하는 것입니다.

 

만약, handleSeverException()을 주석 처리한다면 어떻게 될까요? DuplicatedKeyException이 Wrapper 예외인지 확인하고, 맞다면 그것의 중첩 예외를 확인합니다. 자세하게 코드까지는 까 보지 못했지만 아래와 같은 구조로 되어있는 것으로 추측됩니다.

 

 

catch(DuplicatedKeyException e) {
    throw JdbcSQLIntegrityConstraintViolationException(e);
}

 

 

따라서 중첩 예외인 JdbcSQLIntegrityConstraintViolationException의 상위 예외가 SQLException이므로 handleSQLException()이 동작하게 되는 것입니다. 만약, handleSQLException()까지 주석 처리가 된다면 테스트는 당연히 터질 것입니다.

 

추가로, ExceptionHandler 설명 전문을 첨부합니다.

 

 

Annotation for handling exceptions in specific handler classes and/or handler methods.
Handler methods which are annotated with this annotation are allowed to have very flexible signatures. They may have parameters of the following types, in arbitrary order:
An exception argument: declared as a general Exception or as a more specific exception. This also serves as a mapping hint if the annotation itself does not narrow the exception types through its value(). You may refer to a top-level exception being propagated or to a nested cause within a wrapper exception. As of 5.3, any cause level is being exposed, whereas previously only an immediate cause was considered.

 

 

(2021-05-21 수정 사항)
위에서 제가 DuplicatedKeyException이 Wrapper라고 소개했는데, 반대로 이야기한 것이라 정정합니다. 기본적으로 @ExceptionHandler는 발생한 예외 그 자체를 핸들링하는 메소드가 있는지 확인하고, 타고 타고 상위 계층으로 올라가면서 핸들링하는 메소드가 있는지 확인합니다. 만약, DuplicatedKeyException이 발생했다면 해당 예외를 핸들링하는 메소드가 있는지 체크하고, 그것의 상위 예외, 상위 예외, ..., RuntimeException까지 타고 타고 올라가서 체크합니다. 만약, 최상위 계층까지도 핸들링하는 메소드가 없다면, Wrapper 예외를 찾아봅니다.

 

 

else if (Arrays.binarySearch(sqlErrorCodes.getDuplicateKeyCodes(), errorCode) >= 0) {
	logTranslation(task, sql, sqlEx, false);
	return new DuplicateKeyException(buildMessage(task, sql, sqlEx), sqlEx);
}

 

 

위와 같이 Wrapper가 Jdbc~Exception이고, 그것의 중첩 예외가 DuplicatedKeyException임을 알 수 있습니다. 따라서, DuplicatedKeyException을 핸들링하는 메소드가 없다면, Jdbc~Exception을 핸들링하는 메소드가 있는지 찾고, 없다면 그것의 상위 예외를 찾는 방식으로 동작합니다. 만약, Jdbc~Exception도 마찬가지로 최상위 계층까지 못 찾으면 해당 예외의 Wrapper 예외가 있는지 확인합니다. 이러한 과정에서 어떠한 핸들링 메소드도 못 찾는다면 프로그램이 중간에 종료될 것입니다.

 

 

위와 같은 상황에서 에러 핸들링은 어떻게 할까?

그렇다면, 여기서 한 가지 궁금한 점이 있을 수 있습니다. 위와 같은 상황에서 DuplicatedKeyException을 어떻게 처리할 지에 대한 것이죠. 해당 예외를 처리하지 않으면, handleSeverException()에서 잡으므로 500번 에러가 발생하고, 이것을 SQLException에 같이 엮자니 어색하다는 생각이 듭니다. 여러 가지 방법이 있겠지만, 저는 DuplicatedKeyException의 상위 예외인 DataAccessException을 핸들링하도록 구현하였습니다.

 

 

    @ExceptionHandler(DataAccessException.class)
    public ResponseEntity<Void> handleDataAccessException(DataAccessException exception) {
        logger.info(exception.getMessage());
        return ResponseEntity.badRequest().build();
    }

 

 

이렇게 분리함으로써 테스트 코드도 정상적으로 작동하고, 에러 핸들링도 자연스럽게 정의되었습니다.

 

 

다만, 400번은 클라이언트 관련 에러인데 서버 내부적으로 발생한 에러도 400번으로 치부되는 것은 부적절해 보입니다. 이러한 이유로 DB와 관련된 몇몇 에러는 애플리케이션 단에서 커스텀 에러로 처리하라는 것 같습니다. 물론, 애플리케이션 단에서 에러 핸들링하는 것은 동시성 이슈가 발생할 수 있다는 문제도 있긴 합니다. 이 부분은 계속해서 고민해 봐야할 문제인 듯합니다.

 

 

CORS 설정

저는 원래 스프링 단에서 CORS 설정을 해 주었지만, 인터셉터를 도입하고 나서 CORS 문제가 발생하였습니다. 결국, 스프링 단에서 CORS 설정하는 것을 포기하고 vue.config.js에서 프록시를 도입하는 것으로 일단락되었습니다. 그런데, 오늘 웨지가 인터셉터를 도입하고 나서 CORS 문제가 발생한 크루들을 구제하기 위해 좋은 글을 써 주었습니다.

 

 

 

 

쉽게 이야기하자면, 스프링 CORS 설정은 필터 단계에서 동작하는데, 현재 인터셉터에서 throw를 통해 401 또는 500 에러를 반환했고, 브라우저는 이 때문에 CORS 위반이라고 인식했다고 합니다. 왜 필터 단계에서 CORS를 잘 설정했는데, 인터셉터에서 throw가 발생했다고 CORS 문제가 발생하는 것일까요? 그것은 브라우저가 preflight(OPTIONS 요청)의 상태 코드가 200를 전제로 CORS를 인식하기 때문입니다. 따라서, preHandle() 상단에 OPTIONS 메소드의 경우 true를 반환하게 만들어야 합니다.

 

 

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (HttpMethod.OPTIONS.matches(request.getMethod())) {
            return true;
        }
        
        final String accessToken = AuthorizationExtractor.extract(request);
        if (!jwtTokenProvider.validateToken(accessToken)) {
            throw new AuthorizationException("잘못된 토큰입니다.");
        }
        return true;
    }

 

 

이렇게 하면 vue 단에서 프록시 설정을 안 해도 CORS를 회피할 수 있습니다.

 

 

지하철 경로 조회 3단계 2차 피드백

 

 

첫 번째 수정 사항은 테스트의 상수 필드를 public으로 열지 말라는 것입니다. 위 사진에서 볼 수 있듯이, 해당 클래스에서 상수를 변경하는 순간 다른 테스트에 모든 영향을 끼치게 됩니다. 따라서, 중복되더라도 상수 필드는 private으로 설정했습니다.

 

 

 

 

두 번째 수정 사항은 DAO에 불필요한 검증 로직을 넣지 말라는 것입니다. 해당 코드 전문을 보겠습니다.

 

 

    private RowMapper<Section> rowMapper(List<Long> stationIds) {
        return (rs, rowNum) -> {
            final long id = rs.getLong("id");
            final long upStationId = rs.getLong("up_station_id");
            final long downStationId = rs.getLong("down_station_id");
            final int distance = rs.getInt("distance");

            if (!stationIds.contains(upStationId) || !stationIds.contains(downStationId)) {
                throw new NotFoundStationException("해당하는 Id의 지하철역이 없습니다.");
            }
            final Station upStation = stationDao.findById(upStationId);
            final Station downStation = stationDao.findById(downStationId);
            return new Section(id, upStation, downStation, distance);
        };
    }

 

 

파라미터인 Station ID 리스트를 가져와서 해당 리스트 안에 upStationId와 downStationId가 존재하는지 확인한 뒤, stationDao에게 findById() 메소드를 실행하라고 요청합니다. 그런데, 굳이 유효성 검증을 할 필요가 있을까요? 어차피 stationDao의 findById()는 지하철역 ID가 없다면 예외를 반환할 것입니다. 따라서 위 코드는 아래처럼 깔끔하게 작성할 수 있습니다.

 

 

    private RowMapper<Section> rowMapper() {
        return (rs, rowNum) -> {
            final long id = rs.getLong("id");
            final long upStationId = rs.getLong("up_station_id");
            final long downStationId = rs.getLong("down_station_id");
            final int distance = rs.getInt("distance");

            final Station upStation = stationDao.findById(upStationId);
            final Station downStation = stationDao.findById(downStationId);
            return new Section(id, upStation, downStation, distance);
        };
    }

 

 

애초에 Station ID 리스트를 받아올 필요 조차 없어지는 것이죠!

 

 

이 피드백을 끝으로 해당 미션은 merge가 되었습니다. 코니 덕분에 이번 미션에서 많은 지식을 얻어갈 수 있었다고 생각합니다. 

 

 

정리

에러 핸들링은 보면 볼수록 정답이 없는 것 같습니다. 계속 개발해 보면서 저만의 정답을 찾아가야겠다는 생각이 들었습니다. 그 외에 CORS는 웨지 덕분에 해결하는 방법을 다양하게 배워서 감사했습니다. 추후 시간을 내서 CORS 자체에 대해 공부해 보면 좋을 듯합니다.

댓글

추천 글