개발자로 후회없는 삶 살기
spring PART.자바 예외 이해 본문
서론
※ 이 포스트는 다음 강의의 학습이 목표임을 밝힙니다.
https://www.inflearn.com/roadmaps/373
본론
- 자바 예외 이해
데이터 접근 계층에서 SQL 예외를 throw해서 서비스 계층으로 예외가 전달되는 예외 누수를 해결해보겠습니다. 스프링이 제공하는 예외 추상화를 이해하려면 먼저 자바 기본 예외를 이해해야 합니다. 예외의 기본 내용을 간단히 복습하고 실무에 필요한 체크 예외와 언체크 예외의 차이와 활용 방안도 알아보겠습니다.
> 자바 기본 예외가 탄탄해야 서비스 계층을 순수하게 가져가는 것과 스프링의 예외에 대한 이해, 실무에서 체크+언체크 예외를 이해할 수 있습니다.
=> 예외 계층
-> 그림
모든 자바 부모는 obj이고 예외 계층의 최상위에는 throwable이 있고 exception과 error 두 갈래로 나뉩니다.
1. error
error 같은 경우 메모리 부족이나 심각한 시스템 오류와 같이 어플에서 복구 불가능한 시스템 예외입니다. 어플리케이션 개발자는 이 예외를 잡으려고 해서는 안되고 그냥 둘 수밖에 없습니다.
상위 예외를 catch로 잡으면 그 하위 예외까지 함께 잡습니다. Exception e를 catch에 넣어놓으면 하위 모든 에러가 다 잡힙니다. 그래서 어플리케이션 로직에서 Throwable을 잡으면 error 예외가 자식이라 함께 잡혀서 잡으면 안 되고 어플리케이션 로직에서는 Exception부터 필요한 예외로 생각하고 잡아야합니다.
2. Exception
여기가 체크 예외로 로직에서 사용할 수 있는 실질적인 최상위 예외로 Exception과 그 하위 예외는 모두 컴파일러가 체크하는 체크 예외입니다.
> 단 Runtime 예외와 그 하위는 언체크 예외로 컴파일러가 예외를 잡았는지 던졌는지 체크하지 않는 예외입니다. 런타임은 Exception의 모든 자식은 다 체크 예외로 컴파일러가 잡았나 안 잡았나 체크하는데 런타임과 그 하위만 체크 안 합니다.
- 예외의 기본 규칙
예외는 폭탄 돌리기 같은 것입니다. 그림에서 보면 레포에서 예외가 발생하면 나를 호출한 곳으로 던집니다. 레포에서 잡지 못하는 경우 던져야합니다. 서비스에서 잡을 수 있으면 catch로 잡고 정상정으로 리턴하면 이후 로직은 정상 흐름으로 동작합니다. 원래 try catch문이 어플리케이션이 죽지 않게 하는 것이었습니다. catch로 잡으면 정상흐름이 됩니다.
근데 서비스도 처리 못하면 무조건 던져야하고 예외를 처리하지 못 하면 호출한 곳으로 예외를 던져야합니다. 예외는 공식이 2개로 내가 잡아서 처리를 하거나 내가 처리하지 못하면 나를 호출한 쪽으로 던져야합니다.
※ 참고
예외를 처리하지 못하고 계속 던지면 자바의 경우 main 쓰레드의 경우 예외 로그를 출력하면서 시스템을 종료하고 웹 어플의 경우 요청이 하나가 아니고 사용자가 수백만이라서 예외 때문에 웹 서버가 죽으면 안 됩니다. was가 해당 예외를 받아서 처리하는데 서버를 죽이지 않고 주로 오류 페이지를 보여주는 것으로 동작합니다. /error를 호출하는 것입니다.
- 체크 예외 기본 이해
Exception과 그 하위는 모두 체크 예외입니다. 체크 예외는 잡아서 처리하거나, 밖으로 던지도록 선언해야하고 그렇지 않으면 컴파일 오류가 발생합니다. 언체크와의 차이는 이 컴파일 오류가 발생하냐 아니야입니다.
- 코드로 이해
@Slf4j
public class CheckedTest {
static class MyCheckedException extends Exception {
public MyCheckedException(String message) {
super(message);
}
}
체크 예외를 사용자 지정 예외로 만듭니다. Exception을 상속받으면 그 하위도 체크 예외니 체크 예외가 됩니다. 컴파일러가 체크하는 체크 예외가 도대체 뭔지 알아보겠습니다.
> 생성자를 메세지를 받게 만들겠습니다. 그러면 슈퍼로 부모의 메세지가 초기화 됩니다. 예외의 이유를 메세지로 확인할 것입니다.
서비스와 레포를 만듭니다. 레포를 호출하면 위에 만든 예외를 터트립니다. 예외는 잡거나 던져야한다고 했는데 지금 던지려고 하니 얘가 체크 예외라서 잡지 않으니 던져야한다고 선언을 하라고 컴파일러가 체크해줍니다.
> 예외라는 것은 무조건 잡거나 던지거나 해야하는데 체크 예외는 잡지 않으면 밖으로 던지는 것을 무조건 선언해 줘야합니다. 컴파일러가 잡지 않았으니 던져야한다고 컴파일 오류를 보여줍니다.
static class Service {
Repository repository = new Repository();
public void callCatch() {
try {
repository.call();
} catch (MyCheckedException e) {
log.info("예외 처리, message={}", e.getMessage(), e);
}
}
}
서비스가 레포를 사용합니다. 예외를 잡아서 처리하는 코드를 만듭니다. 레포의 call을 할 건데 레포가 예외를 던졌으니 레포를 호출한 서비스도 예외를 잡거나 던져야 합니다. 이것을 컴파일러가 체크를 해줘서 체크 예외입니다. checked 예외는 서비스에서 예외를 잡아서 처리하거나, 던지거나 둘 중 하나를 필수로 선택해야 합니다.
-> 테스트
1. catch 코드
@Test
void callCatch() {
Service service = new Service();
service.callCatch();
}
서비스를 만들고 callCatch를 호출하면 try의 레포의 call을 호출하고 레포에서 예외를 터트립니다. 이게 레포를 호출한 서비스까지 날라오고 catch에서 잡아서 로그를 남깁니다.
밑에 예외 정보가 e입니다. 여기서는 예외를 잡아서 catch 밑으로 정상 로직이 되고 서비스의 callCatch가 void로 정상 리턴이 되고 테스트가 정상 흐름으로 리턴이 되어서 성공입니다.
2. throw 코드
public void callThrow() throws MyCheckedException {
repository.call();
}
서비스에서 예외를 던지는 코드를 짭니다.
@Test
void callThrow() throws MyCheckedException {
Service service = new Service();
service.callThrow();
}
테스트에서 서비스에서 callThrow를 호출하면 컴파일 오류가 터집니다. 서비스가 잡아서 정상처리 하지 않고 던져서 서비스를 호출한 테스트까지 예외가 올라옵니다.
이걸 잡거나 던져야하는데 던지면 테스트 실패입니다. 테스트 입장에서는 예외가 터지면 테스트 실패입니다. 그래서 thrownBy를 사용합니다.
※ 참고
런타임 예외를 상속받으면 언체크 예외가 됩니다. MyChecked 예외는 체크 예외인 Exception을 상속받아서 체크 예외입니다.
- 체크 예외의 장단점
장점 : 개발자가 실수로 예외 처리를 누락하지 않도록 해줘서 컴파일 오류로 문제를 잡아주는 훌륭한 안전장치입니다. 개발자가 예외를 잡거나 던지는 것을 놓치지 않게 해줍니다. 가장 좋은 예외는 컴파일 오류라고 했습니다.
단점 : 하지만 이걸 다 잡아야하는 것이 번거로운 일이 됩니다. 크게 신경쓰고 싶지 않은 예외까지 모두 챙겨야합니다. 수많은 라이브러리를 사용하는데 수많은 예외가 발생하여 그러다가 모든 에러 다 Exception으로 던지게 되는 일이 발생합니다. 추가로 의존 관계에 따른 단점도 있습니다. 서비스 입장에서 DB 에러는 어차피 못 잡는 에러인데 꼭 throw로 처리를 해야하고 이것이 예외 누수입니다. 이렇게 과해지는 것이 체크 에러의 단점입니다.
- 언체크 이해
런타임 하위는 언체크로 컴파일러가 예외를 체크하지 않습니다. 체크 예외와 동일하게 모든 예외는 잡거나 던지거나인데 차이가 있다면 예외를 던지는 부분은 throws 선언을 하지 않아도 되고 이 경우 자동으로 예외를 던집니다.
-> 차이
1) 체크 예외는 잡아서 처리 하지 않으면 항상 throws에 예외를 던지는 선언을 해야하여 명시적으로 예외를 날렸다는 것을 코드에 남겨야합니다.
2) 언체크 : 언체크는 예외를 잡지 않아도 선언을 하지 않아도 되고 그러면 자동으로 던집니다.
-> 잡기 예제
static class MyUncheckedException extends RuntimeException {
public MyUncheckedException(String message) {
super(message);
}
}
런타임을 상속받는 사용자 예외를 만듭니다. 런타임의 하위는 다 언체크 예외입니다.
레포에서 call 메서드를 만드는데 잡지않고 던져도 throws를 선언을 하지 않아도 언체크 예외라서 컴파일 오류가 터지지 않습니다.
서비스를 만듭니다. 레포를 선언하고 예외를 던지는 call 메서드를 부르는데 레포에서 예외를 던졌는데 서비스에서 잡지 않아도 컴파일 오류가 안납니다. callCatch에서는 잡아보겠습니다.
@Test
void unchecked_catch() {
Service service = new Service();
service.callCatch();
}
서비스를 만들고 callCatch하면 서비스에서 예외를 잡아서 테스트를 성공합니다.
-> 던지기 예제
void callThrow() {
repository.call();
}
이번에는 서비스에 언체크 예외를 던지는 callThrow를 만듭니다. 예외를 던지는 것을 선언하지 않아도 자동으로 던져집니다. 깔끔하게 예외를 안 잡을 거면 throws를 안해도 됩니다.
@Test
void unchecked_throw() {
Service service = new Service();
service.callThrow();
}
테스트 해보면 예외가 날라와서 예외가 발생하고 실패합니다.
- 언체크 장단점
1) 장점 : 신경쓰지 않은 언체크 예외를 다 무시할 수 있습니다. 서비스의 callThrow를 보면 레포가 어떤 예외를 던지든지 신경쓰지 않고 비즈니스 로직만 있습니다. Throws를 선언하지 않아도 자동으로 던지기 때문입니다. 신경쓰고 싶지 않은 예외의 의존 관계를 참조하지 않아도 됩니다. 서비스에서는 예외에 대해 신경쓰지 않아도 됩니다.
2) 단점 : 개발자가 실수로 잡아야하는 예외를 누락할 수 있습니다.
- 체크 예외 활용
언제 체크를 사용하고 언제 언체크를 사용할까요?
-> 기본 원칙 2가지
이런 대원칙을 잡고 진행합니다.
1) 기본적으로 런타임 예외를 사용하는 것이 좋습니다.
2) 체크 예외는 잘 안 씁니다. 근데 비즈니스 로직상 너무 중요해서 의도적으로 던질 때만 사용합니다.
-> 예시
해당 예외를 잡아서 반드시 처리해야 하는 문제일 때만 체크를 사용하고 나머지는 다 언체크를 사용합니다.
1. 계좌 이체 실패 예외 :
계좌 이체 실패는 너무 중요하고 크리티컬해서 개발자가 놓치면 안되고 비즈니스적으로 너무 중요합니다. 이때는 실패했다는 것을 명시적으로 던져야 해서 이럴 때 체크 예외를 사용할 수 있습니다. 던지면 받는 곳에서 계좌 이체 실패시 어떤 해결이 되어야 한다는 후처리 로직이 있습니다.
2. 결제시 포인트 부족 예외 :
이것도 비즈니스 적으로 문제가 있는 것이라서 포인트가 부족하면 어떻게 해야한다는 로직이 후처리가 되어야합니다. 이럴 때 예외를 잡기를 기대해서 예외를 던져야하는 것입니다.
3. 로그인 불일치 :
이때도 안 맞으면 나를 호출한 쪽에서 해결해주기를 원해서 예외를 던지는 것입니다.
- 정리
이렇게 비즈니스적으로 의도적으로 문제가 있을 때 체크를 사용합니다. 물론 이 경우도 100% 체크를 하는 것은 아니고 다만 이체 실패처럼 매우 심각한 문제는 개발자가 실수로 예외를 놓치면 안된다고 판단할 수 있습니다. 이 경우 체크 예외로 하면 컴파일러를 통해 놓친 예외를 인지할 수 있습니다.
+ 결론은 런타임을 많이 쓰는 추세이고 로직상 예외를 직접 정의할 때 매우 중요한 문제면 체크 예외를 씁니다. 로직 상으로 문제가 적으면 런타임 예외로 처리해도 됩니다.
- 체크 예외의 문제점
언 체크를 기본으로 사용하는 이유는 문제점 때문입니다. 체크는 컴파일러로 예외를 실수로 놓치는 것을 해결해 줍니다. 따라서 잡거나 잡지 못하면 명시적으로 throws를 선언해 던져야만 하도록합니다.
-> 그림
레포는 sql 예외를, 네트워크는 connection 예외가 발생한다고 치겠습니다. 서비스 입장에서는 레포와 네트워크를 다 호출하는데 둘 다 체크 예외를 던지는 상황입니다. 서비스는 두 곳의 예외를 다 처리해야합니다. 근데 서비스는 sql에서 터지는 db문제나 sql 문법 오류를 해결할 수 없습니다. 해결할 수 있는 방법을 모릅니다. 이런 문제는 대부분 어플리케이션 로직에서 처리할 방법이 없습니다.
> 그래서 서비스는 이 두 예외를 다 밖으로 던져야합니다. 비즈니스 로직만 해결하는 애이니 던져야합니다. 근데 체크예외라서 throws를 선언해야합니다. 컨트롤러로 던지면 얘도 해결할 수 없어서 throws를 선언하고 밖으로 던집니다. 그러면 was는 controllerAdvice에서 예외를 공통으로 처리합니다. 이런 문제는 보통 사용자에게 어떤 문제가 발생했는지 자세하게 설명하기 어렵습니다.
+ "DB가 끊겼습니다"라는 메세지를 사용자에게 보여줘봤자 사용자는 어쩌라는 것인지 모릅니다. 그래서 사용자에게는 "고객님 죄송합니다. 서비스 장애입니다."라는 일반적인 메세지를 보여줘야합니다. API 라면 500 응답을 내려줍니다. 이렇게 해결이 불가능한 공통 예외는 별도의 오류 로그를 남기고 개발자가 오류를 빨리 인지할 수 있도록 메일, 알림을 통해서 전달 받아야합니다. 예를 들어서 DB 예외가 터지면 빨리 sql 개발자가 인지하여 빨리 sql을 수정해야 합니다.
-> 정리
여기서 문제는 서비스 입장에서 예외가 터졌다는 것을 알고 싶지도 않습니다. 그래도 체크여서 선언해서 던져야하는것이 문제입니다.
- 구현
static class NetworkClient {
void call() throws ConnectException {
throw new ConnectException("fail");
}
}
static class Repository {
void call() throws SQLException {
throw new SQLException("ex");
}
}
서비스와 네트워크, 레포를 만듭니다. 레포에서 sql 예외를 터뜨리고 체크 예외라서 던지는 것을 선언해야합니다. 네트워크는 Connect 예외를 터뜨리고 체크라서 던지는 것을 선언합니다.
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
void logic() throws ConnectException, SQLException {
repository.call();
networkClient.call();
}
}
서비스에서는 이 둘을 호출합니다. 그러면 서비스도 체크라서 두 개를 다 던지는 것을 선언해야합니다. 이게 쌓이면 나중에는 귀찮아서 throws Exception으로 모든 예외를 다 던져서 잡아야하는데 하위 예외까지 던지는 문제가 터집니다.
static class Controller {
Service service = new Service();
void request() throws SQLException, ConnectException {
service.logic();
}
}
컨트롤러를 만듭니다. 얘는 서비스를 호출하고 여기서도 서비스가 던지는 예외를 던져야합니다. 그러면 이 throws 선언이 계속 나와야하는 것입니다.
-> 테스트
@Test
void checked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request()).isInstanceOf(Exception.class);
}
컨트롤러를 선언하고 얘는 예외가 터지면 성공입니다.
- 2가지 문제
이것으로 보면 2가지 문제를 인지할 수 있습니다.
1. 복구 불가능한 예외
대부분의 예외는 거의 복구가 불가능합니다. sql 예외는 sql 문법에 문제가 있을 수 있고 db 자체에 기본키가 안 맞을 수도 있습니다. 이런 문제는 복구가 불가능한 문제입니다. 특히 서비스나 컨트에서 이 문제를 해결할 수 없습니다. 따라서 이런 문제들을 일관성 있게 공통으로 처리해야하고 오류 로그를 남겨서 개발자가 빨리 해결할 수 있게 해야합니다.
> 이런 것들을 서블릿 필터, 인터셉터, controlleradvice를 사용해서 깔끔하게 공통으로 해결해야 합니다. 비즈니스 로직적으로 해결할 수 없는 문제는 공통으로 해결해야 합니다. 고객한테는 지금 문제가 있다는 표시만 하고 로그를 남겨 개발자가 빨리 해결할 수 있는 흐름을 만들어야합니다.
2. 의존 관계에 대한 문제
체크의 심각한 또 다른 문제는 의존 관계 문제입니다. 대부분의 예외는 복구 불가능하다고 했는데 체크 예외라서 컨트와 서비스가 본인이 처리할 수 없어도 어쩔 수 없이 throws를 선언해야합니다. 이런게 왜 문제가 될까요?
> 바로 서비스, 컨트에서 SQL Exception에 의존하기 때문입니다. 서비스에 SQL Exception 코드가 들어왔다는 것은 서비스가 SQL Exception에 의존하고 있는 것이고 SQL Exception는 jdbc 기술입니다. 로직에서 특정 기술에 의존하게 되어 향후에 jdbc가 아닌 jpa로 변경하면 JpaException에 의존하도록 고쳐야합니다.
+ 서비스와 컨트는 어차피 본인이 처리할 수도 없는데 의존해야하는 문제가 생기는 것입니다.
-> 그림
레포에서 jdbc를 jpa로 바꾸면 서비스, 컨트의 sql ex가 체크 예외라면 다 컴파일 오류가 나서 jpa ex로 바꿔야합니다. 예외도 기술을 바꾸면 함께 변경해야하고 해당 예외를 던지는 부분도 함께 다 바꿔야합니다.
- 정리
처리할 수 있는 체크 예외라면 서비스에서 처리하겠지만 지금처럼 복구 불가능한 sql 에러같은 시스템 레벨에서 올라온 예외들은 서비스에서 처리 못합니다. 그리고 실무에서 발생하는 대부분의 예외들은 이런 시스템 예외입니다. 이런 경우 체크를 사용하면 복구 불가능한 예외를 서비스에서 모두 알고 있어야하고 불필요한 의존관계 문제가 발생합니다.
-> throws Exception
그러면 던지는 선언을 다 최상위 부모인 Exception으로 하면 어떻게 될까요? sql 예외의 부모인 Exception으로 하면 의존이 해결되는 것입니다. Exception로 추상화하는 것입니다. 근데 이렇게 하면 Exception은 던질 때 본인뿐만 아니라 자식 예외도 던진다고 했고 이러면 체크 예외의 의도가 사라지게 됩니다.
체크 예외는 로직상 중요한 문제를 의도적으로 정의하여 의도적으로 체크를 하게 하여 개발자가 놓치지 않게 하려고 한 것이라고 했는데 Exception을 해버려서 다 던져서 컴파일러는 문법에 맞다고 판단해서 컴파일 오류가 발생하지 않습니다. Exception를 하는 것은 절대로 하면 안 됩니다. 그래서 런타임 예외를 사용하는 것이 대안입니다.
- 언체크 예외 활용
런타임 예외를 사용하여 문제를 해결해 보겠습니다. SQL ex을 런타임 예외인 RuntimeSQL ex로 바꿔서 던질 것입니다. 런타임 예외라서 서비스는 해결할 수 없으면 별도의 선언없이 그냥 두면 알아서 던집니다. 그러면 컨트도 던지고 was까지 가서 예외를 공통으로 처리하는 곳에서 해결할 것입니다.
- 구현
static class RuntimeConnectionException extends RuntimeException {
public RuntimeConnectionException(String message) {
super(message);
}
}
이전에 한 코드를 런타임 예외로 바꿀 것입니다. 런타임 예외를 상속받는 RuntimeConnectEx를 만들고
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException() {
}
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
런타임 예외를 상속받는 RuntimeSQLEx를 만듭니다.
// 이전
static class Repository {
void call() throws SQLException {
throw new SQLException("ex");
}
}
// 이후
static class Repository {
void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
레포에서 sql을 쓴다고 가정하는 runSQL 메서드를 만들고 SQL ex를 터뜨립니다. jdbc 기술을 쓰면 SQL ex는 무조건 터지는 것입니다. 체크니 무조건 던집니다. 그리고 runSQL을 내부에서 호출하고 여기서 이 예외를 잡을 것이고 SQL ex가 터지면 RuntimeSQLEx로 바꿔서 던질 것입니다. 체크예외가 런타임 예외로 바뀌는 것입니다. 예외를 던질 때 항상 기존 예외를 넣어야합니다. 생성자중에 cause를 재정의하는 것이 있는데 그러면 지금 터진 예외가 왜 발생했는지 이전 예외를 넣을 수 있습니다.
그러면 현재 예외가 이전 예외를 포함하고 스택 트레이스에서 출력할 때 현재 에러와 기존 예외를 둘 다 출력할 수 있습니다. > call을 하게 되면 일단 sql을 실행합니다. 근데 체크 예외가 터져서 던지고 호출한 곳에서 잡는데 던질 때 런타임 예외로 바꿔서 던집니다.
// 이전
static class NetworkClient {
void call() throws ConnectException {
throw new ConnectException("fail");
}
}
// 이후
static class NetworkClient {
void call() {
throw new RuntimeConnectionException("fail");
}
}
네트워크 에러는 그냥 체크 에러를 언체크 에러로 바꿔 던지게 합니다.
이렇게 하면 서비스에서 throws 명시가 다 사라집니다. 호출한 레포와 네트워크 메서드가 둘 다 런타임 자식 예외를 가지고 있어서 언체크 예외가 던져지고 던져지긴 하지만 언체크라서 throws를 명시하지 않아도 자동으로 던져집니다.
static class Controller {
Service service = new Service();
void request() {
service.logic();
}
}
컨트에서도 명시하지 않아도 됩니다.
@Test
void checked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request()).isInstanceOf(RuntimeSQLException.class);
}
테스트해보면 request에서 서비스의 logic을 호출하는데 logic은 call을 먼저 호출하고 예외가 터지면 그 아래는 진행이 안되어서 RuntimeSQL 예외가 터졌다고 테스트해도 통과합니다. 드디어 서비스, 컨트에서 throws 지저분한 해결 못하는 코드가 사라지고 의존관계도 사라집니다.
-> 설명
1) 예외 전환
레포에서 체크 예외인 SQL 예외가 발생하면 런타임 예외인 RuntimeSQL ex로 전환해서 던졌습니다. 기존 예외를 포함해서 던졌고 스택 트레이스에서 기존 예외도 함께 확인할 수 있습니다. 네트워크는 그냥 단순히 발생하는 예외자체를 바꿨습니다.
2) 복구 불가능한 예외 처리
시스템에서 발생하는 예외는 서비스나 컨트에서 어차피 복구 불가능해서 신경쓰지 않아도 됩니다. 이후 was에서 공통 처리해야합니다.
3) 의존관계
런타임은 그냥 명시하지 않아도 되어서 처리할 수 없으면 예외를 무시해도 됩니다. 따라서 throws 의존을 하지 않아도 됩니다. 컨트와 서비스에 처리 부분을 생략합니다.
> 기술을 jpa로 바꾸면 서비스와 컨트에서 해결할 수 없어서 던져야 하는데 더 이상 throws 코드를 바꾸지 않아도 됩니다. 공통 처리하는 곳에만 바꾸면 됩니다.
-> 정리
처음 자바를 설계할 당시에는 체크가 당연히 더 좋다고 생각했습니다. 근데 시간이 흐르며 처리해야하는 예외가 늘어았습니다. 그래서 체크 예외는 throws를 덕지덕지 붙여야 했습니다. 그래서 throws Excetpion을 사용하는 극단적인 방법도 일어났습니다.
그래서 최근에 라이브러리들은 런타임 예외를 제공합니다. jpa 예외도 런타임 예외입니다. 처리할 수 없으면 서비스에서 자연스럽게 던지게 됩니다. 런타임 예외지만 서비스에서 잡아야 겠다는 것은 잡으면 되고 잡을 수 없으면 신경 안쓰고 throws 없이 던지게 됩니다.
따라서 런타임 예외는 문서화를 잘해야합니다. jpa의 경우 EntityManager라는 라이브러리를 쓸 때 내부에서 이러한 런타임 예외가 발생할 수 있으니 신경 안쓰면 알아서 던지게 된다고, 잡고 싶으면 잡으라고 문서화했습니다.
또한 생략해도 되지만 명시도 해놔서 개발자가 이런 런타임 예외가 있다는 것을 인지하게도 합니다. 개발자는 필요하면 잡고 아니면 명시를 지워서 무시하면 됩니다.
- 예외 포함과 스택 트레이스
예외를 전환할 때는 꼭 기존 예외를 포함해야 합니다. 그렇지 않으면 스택 트레이스를 확인할 때 심각한 문제가 발생합니다.
- 구현
@Test
void exPrint() {
Controller controller = new Controller();
try {
controller.request();
} catch (Exception e) {
log.info("ex", e);
}
}
컨트롤러에서 request를 호출하여 log를 남겨보면 예외 로그를 확인할 수 있는데 이 로그가 예외가 터졌을 때 발생하는 스택 트레이스입니다. request를 하면 레포의 call을 호출하는데 그러면 SQL 예외가 터지고 이것을 RuntimeSQLex로 전환해서 던진 상황입니다.
로그를 출력할 때 log.info("message={}", "message", ex) 하면 ex에 스택 트레이스를 출력 할 수 있습니다. 실무에서는 e.printStackTrace를 쓰지 않고 log를 사용합니다.
-> 기존 예외를 포함하는 경우
static class Repository {
void call() {
try {
runSQL();
} catch (SQLException e) {
throw new RuntimeSQLException(e);
// throw new IllegalStateException(e);
}
}
void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
지금 SQL 예외가 터진 것을 RuntimeSQLException(e); 으로 전환하려는 상황인데 기존 예외가 들어가 있습니다. RuntimeSQLException에 생성자에 cause를 넣었습니다. 이렇게 하면 예외가 내부에 기존 예외를 가지고 있습니다. RuntimeSQLException의 부모가 SQL ex라서 되는 것이 아니라 그냥 기존 예외를 전환을 하는 상황이라서 ILLegal 같은 관련 없는 예외에 기존 예외를 넣어도 됩니다.
-> 실행
실행해보면 런타임 sql 예외가 남고 밑에 보면 caused by sql 예외가 남습니다. 실무에서 기존 예외를 빠뜨리는 실수를 합니다. 그러면 뭐 때문에 지금 전환한 예외가 발생했는지 알 수가 없습니다.
> 지금 SQL 예외를 변환해서 RuntimeSQLEx를 터뜨렸는데 SQL 예외가 나오지 않은 상황입니다. SQL 예외가 실무에서는 뭐가 들어있냐면 DB와 연동했다면 DB에 쿼리가 잘못됐는지, 기본키 문제인지, 뭐 때매 잘못됐는지 정보가 다 들어있는데 지금 DB 문제 정보를 알 수 없어서 장애가 났을 때 어떤 이유로 장애가 났는지 알 수가 없습니다.
'[백엔드] > [spring | 학습기록]' 카테고리의 다른 글
spring PART.중간점검 3 (0) | 2023.05.09 |
---|---|
spring PART.스프링 데이터 접근 예외 처리 (0) | 2023.05.07 |
spring PART.스프링 트랜잭션 적용 (0) | 2023.05.05 |
spring PART.트랜잭션 이해 (0) | 2023.05.03 |
[문법] 커넥션과 데이터 소스 (0) | 2023.05.02 |