예외에 대해서는 2가지 기본 규칙이 있다.
if). 예외를 처리하지 못하고 계속 던지면 어떻게 될까?
모든 예외는 잡아서 처리하거나, 던져야한다.
단, 쳬크 예외는 던질때 mehtod() throws예외를 만드시 지정해줘야한다.
Test
static class MyCheckedException extends Exception{
public MyCheckedException(String message){
super(message);
}
}
MyCheckedException은 Exception을 상속받고 있다. -> CheckedException이 된다.
Repository
static class Repository{
public void call() throws MyCheckedException {
throw new MyCheckedException("ex");
}
}
리포지토리에서 CheckedException을 발생시킨다.
Service
static class Service{
Repository repository = new Repository();
public void callCheck(){
try{
repository.call();
}catch (MyCheckedException e){
log.info("예외 처리, message = {}",e.getMessage(),e);
}
}
public void callThrow() throws MyCheckedException {
repository.call();
}
}
callCheck메서드는 오류를 잡아서 처리하는 메서드이다.
callThrow메서드는 오류를 처리하지 않고 그냥 던지는 예외이다.
checked예외는 오류를 던지기 위해서 반드시 던지는 예외를 명시해줘야한다.(안하면 빨간줄 컴파일 오류 발생)
-> throws MyCheckedException
@Test
void checked_catch(){
Service service = new Service();
service.callCheck();
}
callCheck메서드 호출 -> Repository.call호출 -> Exception발생 -> Repository에서는 던지므로 상위 Service의 callCheck로 온다. catch부분이 있으므로 오류 처리 -> 정상처리 로직이 됨
@Test
void checked_throw(){
Service service = new Service();
Assertions.assertThatThrownBy(()->service.callThrow()).isInstanceOf(MyCheckedException.class);
}
동일하게 호출 -> 그러나 Service의 callTrow에서 상위로 예외를 던지고 있음, 그래서 해당 checked_throw메서드로 MychekedException이 올라오는데 Assertions로 해당 오류가 맞는지 검증
정리
체크예외의 장단점
장점: 개발자가 실수로 예외를 누락하지 않도록 컴파일러를 통해 문제를 잡아준다.
단점: 개발자가 모든 체크예외를 던지거나 잡아야하는데 너무 번거롭다. 만약 DB가 연결이 안되면 -> 애초에 서비스단에서 예외를 처리할 수 있는게 없는데 그럼에도 불구하고 throws를 작성해야한다.
@Slf4j
public class UnCheckedTest {
static class MyUncheckedException extends RuntimeException{
public MyUncheckedException(String message){
super(message);
}
}
static class Repository{
public void call(){
throw new MyUncheckedException("ex");
}
}
static class Service{
Repository repository = new Repository();
public void callCatch(){
try{
repository.call();
}catch (MyUncheckedException e){
log.info("예외 처리, message = {}",e.getMessage(),e);
}
}
public void callThrow(){
repository.call();
}
}
@Test
void unchecked_catch(){
Service service = new Service();
service.callCatch();
}
@Test
void unchecked_throw(){
Service service = new Service();
assertThatThrownBy(() -> service.callThrow()).isInstanceOf(MyUncheckedException.class);
}
}
로직 자체는 체크예외와 비슷하다.
참고, throws로 명시해줘도 됨, 그냥 개발자한테 이런 예외를 던질거다라고 알려주는용도.
장점: 신경쓰지 않고 싶은 언체크 예오 ㅣ무시가능
단점: 개발자가 실수로 예외를 누락할 수 있음
고로, 예외는 처리하거나 던져야하는데
Repository에서 SQLException, NetWorkClient에서 ConnectException이 터진다고 가정해보자.
둘다 Checked예외이므로 Service, Controller입장에서 해당 에러를 해결할 수 없음에도 불구하고 throws를 선어하여 던저야만 한다.
웹 어플리케이션이라면 서블릿 오류페이지나 스프링 MVC가 제공하는 ControllerAdvice에서 해당 오류를 공통으로 처리한다.
CheckedAppTest
@Slf4j
public class CheckedAppTest {
static class Repository{
public void call() throws SQLException{
throw new SQLException("ex");
}
}
static class NetWorkClient{
public void call() throws ConnectException {
throw new ConnectException("연결실패");
}
}
static class Service{
Repository repository = new Repository();
NetWorkClient netWorkClient = new NetWorkClient();
public void logic() throws SQLException, ConnectException {
repository.call();
netWorkClient.call();
}
}
static class Controller{
Service service = new Service();
public void request() throws SQLException, ConnectException {
service.logic();
}
}
@Test
void checked(){
Controller controller = new Controller();
assertThatThrownBy(()-> controller.request()).isInstanceOf(Exception.class);
}
}
리포지토리와 네트워크 클라이언트 클래스의 call메서드는 checkedtype의 예외를 던진다.
그럼 서비스와 컨트롤러 부분을 보면, 해당 오류를 처리할 수 없음에도, 항상 throws로 해당 오류를 선언해줘야한다.
이러면 2가지의 치명적인 오류가 생긴다.
복구불가능한 예외
대부분의 예외는 복구가 불가능, SQLEXCEPTION의 경우 DB에 문제가 생겨서 발생하는 예외, sQL문법에 문제가 있거나, DB자체가 문제가 있거나, 서버가 중간에 다운되었거나, 어쨋든 이런 오류는 컨트롤러나 서비스에서 해결할수없다.
고로, 오류 로그를 남기고, 스프링의 ControllerAdvice를 통해 공통으로 해결하는것이 중요하다.
의존관계에 대한 문제,
복구 불가능한 예외임에도 불구하고, Serivce와 Controller에서 throws를 통해서 던지는 예외를 선언해야한다.
java.sql.SQLException에 의존하고 있는데, 향후 리포지토리를 JDBC기술이 아니라 JPA로 바꾸면, SQLExcpetion->JPAException으로 예외를 Service와 Controller에서 모든 코드를 뜯어 고쳐야한다.
만약 그럼 SQLException,ConectionException대신 그냥 최상위 예외인 Exception을 던지면 안될까?
void method() throws Exception{}
이건 안티 패턴이다. 왜? 최상위 타입인 Exception을 던지면, 중간에 중요한 체크예외가 발생되어도 다른 체크 예외를 체크할 수있는 기능이 무효화되고 그냥 Exception을 던지게 된다. 이러면 중요한 체크예외를 놓치게 된다.(컴파일 오류가 발생하지 않는다는 의미)
이제는 Repository와 NetworkClient에서 발생하는 예외를 언체크 type인 RuntimeException으로 던질것이다.
static class RuntimeConnectException extends RuntimeException {
public RuntimeConnectException(String message) {
super(message);
}
}
static class RuntimeSQLException extends RuntimeException {
public RuntimeSQLException(Throwable cause) {
super(cause);
}
}
우선 RuntimeException을 두가지 만든다.
static class NetworkClient {
public void call() {
throw new RuntimeConnectException("연결 실패");
}
}
NetworkClient에서는 그냥 원래대로 RuntimeConnectException을 던질것이다.
static class Repository {
public void call() {
try {
runSQL();
}
catch (SQLException e) {
throw new RuntimeSQLException(e);
}
}
private void runSQL() throws SQLException {
throw new SQLException("ex");
}
}
Repository에서는 CheckedException인 SQLException이 발생하는데, 이걸 catch부분에서 RuntimeSQLException으로 전환해줘야한다.
static class Controller {
Service service = new Service();
public void request() {
service.logic();
}
}
static class Service {
Repository repository = new Repository();
NetworkClient networkClient = new NetworkClient();
public void logic() {
repository.call();
networkClient.call();
}
}
검증
@Test
void unchecked() {
Controller controller = new Controller();
assertThatThrownBy(() -> controller.request())
.isInstanceOf(Exception.class);
}
@Test
void printEx() {
Controller controller = new Controller();
try {
controller.request();
}
catch (Exception e) {
//e.printStackTrace();
log.info("ex", e);
}
}
런타임 예외를 사용 -> 중간에 RuntimeSQLException -> RuntimeJPAExceptino으로 변경되더라도, 컨트롤러,서비스에서 코드 변경을 하지 않아도 된다.
다만 구현기술이 변경되는경우, 예외를 공통으로 처리하는 로직 ControllerAdvice등에서 RuntimeJPAException으로 변경해주기만 하면된다.
즉, 변경 범위가 최소화 된다.
참고로, RuntimeException도 마찬가지로 throws에 선언이 가능하다. 물론 생략해도 된다. 코드에 어떤 예외를 명시하면 개발자가 IDE를 통해 확인하기 편리하다.
예외를 전환할때는 꼭 기존 예외를 포함해야한다.
만약 기존 예외를 포함하지 않으면, 예외가 로그로 남지 않기 때문이다.
로그를 출력할때 마지막 파라미터에 예외를 넣어주면 로그에 스택 트레이스를 출력할 수 있다.
log.info("message={}","message",ex)
이러면 ex에대한 예외가 출력되게 된다.
public void call() {
try {
runSQL();
}
catch (SQLException e) {
throw new RuntimeSQLException(e); //기존 예외(e) 포함
}
}
기존예외를 항상 포함해야 변환한 RuntimeSQLException으로부터 예외를 확인 할 수 있다.
DB에 연동했다면 DB에 대한 잘못된 쿼리를 로그로 확인해야하는데,
RuntimeSQLException();이렇게 포함을 안하면, SQLException에서 생기는 오류를 확인 할 수 없다.