6 minute read

우아하게 예외 처리 하기

목차

  1. 예외 처리 방식
  2. Uncheked Exception
  3. Exception 잘 쓰기
  4. 실무 예외 처리 패턴
  5. 오픈소스 속 Exception 살펴보기

예외 처리 방식

오류 코드를 리턴하지 말고 예외를 던져라

  • 옛날에는 오류를 나타낼 떄 에러 코드를 던졌다.
  • 하지만 예외를 던지는 것이 훨신 명확하고, 처리 흐름이 깔끔해진다.
// 잘못된 방법 : 오류 플래그를 설정하거나 호출자에게 오류 코드를 변환하는 방법

public class DeviceController {

	...

	DeviceHandle handle = getHandle(DEV1);
	if (handle != DeviceHandle.INVALID) {
		retrieveDeviceRecord(handle);
		if (record.getStatus() != DEVICE_SUSPENDED) {
			closeDevice(handle);
		} else {
			logger.log("Device suspended. Unable to shut down");
		}
	} else {
		logger.log("Invalid handle");
	}

	...
}

위 코드의 문제: 호출 코드가 복잡해 진다. 함수를 호출한 즉시 오류를 확인해야 하기 떄문

// 현재의 예외처리 방식 코드 
public class DeviceController {
    ...

    //3
    public void sendShutDown() {
        try {
            tryToShutDown();

        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }
    
    //2
    private void tryToShutDown() throws DeviceShutDownError() {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        pauseDevice(handle);
        clearDeviceWordQueue(handle);
        closeDevice(handle);
    }

    // 1
    private DeviceHandle getHandle(DeviceID id) {
        ...
        throw new DeviceShutDownError("blah blah");
        ...
    }
}
  1. 오류가 발생한 부분에서 예외를 던진다. (별도의 처리가 필요한 예외라면 checked exception으로 던진다.)
  2. checked exception 에 대한 예외 처리를 하지 않는 다면 메서드 선언부에서 throws를 명시해야한다.
  3. 예외를 처리할 수 있는 곳에서 catch하여 처리한다.

Exception 가계도

익셉션

Checked vs Unchecked Exception

  • Checked Exception
    • Exception__을 상속하면 __Checked Exception 명시적인 예외처리가 필요함.
      • 예 IOException, SQLException
    • 확인시점 : 컴파일 시점
    • 처리 여부 : 반드시 처리
    • 트랜잭션 처리 : roll-back 하지 않음
  • RuntimeException__을 상속하면 __UncheckedException 명시적인 예외처리가 필요하지 않는다.
    • NullPointerException, IlleegalArgumentException, IndexOutOfBoundException
    • 확인시점 : 런타임 시점
    • 처리 여부 : 명시적으로 처리하지 않아도 됨
    • roll-back 함

Effective Java, Exception에 대한 규약

자바 언어 명세가 요구하는 것은 아니지만, 업계에 널리 펴진 규약으로 
__Error__ 클래스를 상속해 하위 클래스를 만드는 일을 자재하자.

즉, 사용자가 직접 구현하는 unchecked throwable은 모두 RuntieException의 하위 클래스여야 한다.

Exception, RuntimeException, Erro를 상속하지 않는 throwable 을 만들수도 있지만, 이러한 throwable은 정상적인 사항보다 나을게 없으며, api 사용자를 헷갈리게 할 뿐이다.

Checked Excpetiond이 나쁜이유

이전 코드를 보자

public class DeviceController {
    ...

    //3
    public void sendShutDown() {
        try {
            tryToShutDown();

        } catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }
    
    // 2
    // 여기서는 하나지만 2, 3, +++ depth 가 된다면 throws DeviceShutDownError 을 계속 던져줘야함 
    private void tryToShutDown() throws DeviceShutDownError {
        DeviceHandle handle = getHandle(DEV1);
        DeviceRecord record = retrieveDeviceRecord(handle);
        pauseDevice(handle);
        clearDeviceWordQueue(handle);
        closeDevice(handle);
    }

    // 1
    private DeviceHandle getHandle(DeviceID id) {
        ...
        throw new DeviceShutDownError("blah blah");
        ...
    }
}
  1. 특정 메소드에서 checked Exception을 throw 하고 상위 메소드에서 그 exception을 catch한다면 모든 중간단계 메소드에 excpetion을 trows 해야 한다.
  2. OCP (패쇄 개방 원칙) 위배 상위 레벨 메서드에서 하위 레벨 메서드의 디테일에 대해 알아야 하기 떄문에 OCP 원칙을 위배한다.
  3. 필요한 경우 checked exception을 사용해야 되지만 일반적인 경우 득보다 실이 많다.

Unchecked Exception 를 사용하라,

Checked Exception은 선언부의 수정을 필요로 하기 떄문에 모듈의 캡슐화를 꺠버린다. 

안정적인 소프트웨어를 제작하는 요소로 확인된 예외가 반드시 필요하지 않다는 사실이 분병해졌다.

Exception 잘 쓰기

  • 예외에 메세지 담기
    • 오류가 발생한 위치를 찾기 쉽도록, 예외를 던질 떄는 전후 상황을 충분히 덧붙인다.
    • 실패한 연산과 이름과 유형 등 정보를 담아 예외를 던진다.

예외를 감싸는 클래스를 만들자.

오류를 정의해 분류하는 방법은 프로그래머에게 오류를 잡아내는 방법이 되어야한다.

bad

ACMEPort port = new ACMEPort(12);

try{
	  port.open();
} catch (DeviceResponseException e) {
		reportPortError(e);
} catch (ATM1212UnlockedException e) {
		reportPortError(e);
} catch (GMXError e) {
		reportPortError(e);
} finally {
	...
}

good 호출하는 라이브러리으 API를 감싸면서 예외 유형을 하나 반환한다.

LocalPort port = new LocalPort(12);
try {
  port.open();
} catch (PortDeviceFailure e) {
  reportError(e);
  logger.log(e.getMessage(), e);
} finally {
  ...
}
public class LocalPort {
  private ACMEPort innerPort;
  
  public LocalPort(int portNumber) {
    innerPort = new ACMEPort(portNumber);
  }
  
  public void open() {
    try{
      innerPort.open();
    } catch (DeviceResponseException e) {
      throw new PortDeviceFailure(e);
    } catch (ATM1212UnlockedException e) {
      throw new PortDeviceFailure(e);
    } catch (GMXError e) {
      throw new PortDeviceFailure(e);
    }
  }
  
  ...
}

실무 예외 처리 패턴

getOrElse vs getOrElseThrows

  • getOrElse
    • 예외 대신 기본 값을 리턴한다.
    • null이 아닌 기본 값.
    • 도메인에 맞는 기본값
  • getOrElseThrow
    • null 대신 예외를 던진다. (기본 값이 없다면)

getOrElse - 예외 대신 기본값을 리턴

bad

// 잘못된 예시
List<Employee> employees = getEmployees();
if (employees != null) {
    for (Employee e : employees) {
        totalPay += e.getPay();
    }
}

// getEmployees를 설계할 때, 데이터가 없는 경우를 null 로 표현했는데, 다른 방법은 없을까?. 
// null 을 리턴한다면 이후 코드에서 모두 null 체크가 되어야 한다.

good

// null이 아닌 기본값을 리턴한다.

List<Employee> employees = getEmployees();
for (Employee e : employees) {
    totalPay += e.getPay();
}


public List<Employee> getEmployees() {
    if (..no employees ..) {
        return Collections.emptyList();
    }
}

// 복수형 데이터를 가져올 떄는 데이터의 없음을 의미하는 컬렉션을 리턴하면 된다.
// null 보다 size가 0인 컬렉션이 훨씬 안전하다.

빈 컬렉션과 빈 문자열을 적용할 수 없는 경우에는 어떻게 할까?

도메인에 맞는 기본값을 가져온다

bad

UserLevel userLevel = null;
try {
    User user = userRepository.findByUserId(userId);
    userLevel = user.getUserLevel();
} catch (UserNotFoundException e) {
    userLevel = UserLevel.BASIC;
}

// !!호출부!!에서 예외 처리르 통해 userLevel 값을 처리한다.
// 이러면 코드를 계속 읽어나가는 과정에서 논리적인 흐름이 끊기게 된다.

good

UserLevel userLevel = userService.getUserLevelOrDefault(userId);
public class UserService {
    private static final UserLevel USER_BASIC_LEVEL = UserLevel.BASIC;

    ...

    public UserLevel getUserLevelOrDefault(Long userId) {
        try {
            User user = userRepository.findByUserId(userId);
            return user.getUserLevel();
        } catch (UserNotFoundException e) {
            return USER_BASIC_LEVEL;
        }
    }
}

예외 처리를 __데이터를 제공하는 쪽__에서 처리해 호출부 코드가 심플해진다. 코드를 읽어 가며 놀리적인 흐름이 끊기지 않는다. 도메인에 맞는 기본값을 도메인 서비스에서 관리하게된다.

도메인에 맞는 기본이 없다면!!?

getOrElseThrow - null 대신 예외를 던진다.

null 체크 지옥 벗어나기

bad

/**
*   아래 코드의 문제점
*   null 체크가 빠진 부분이 발생가능
*   persistentStore에 대한 null 체크가 빠져있지만 알아챌 수 없다.
*   코드 가독성이 굉장히 떨어진다.
*/
public void registerItem(Item item) {
    if (item != null) {
        ItemRegistry registry = persistentStore.getItemRegistry();
        if (registry != null) {
            Item existing = registry.getItem(item.getId());
            if (existing.getBillingPeriod().hasRetailOwner()) {
                existing.register(item);
            }
        }
    }
}

이전 코드 bad

/**
*  user를 사용하는 쪽에서 매번 null 체크를 해야한다.
*  가독성이 떨어짐
*/
User user = userRepository.findByUserId(userId);
if (user != null) {
    // User처리
}

good

User user = userService.getUserOrElseThrow(userId);
public class UserService {
    
    private static final UserLevel USER_BASIC_LEVEL = UserLevel.BASIC;

    ...

    /**
    *   데이터를 제공하는 쪽에서 null 체크를 하여, 데이터가 없는 경우엔 예외를 던진다.
    *   호출부에서 매번 null 체크를 할 필요 없이 안전하게 데이터를 사용할 수 있다.
    *   호출부의 가독성이 올라간다.
    */
    public UserLevel getUserOrElseThrow(Long userId) {
        User user = userRepository.findByUserId(userId);

        if (user == null) {
            throw new IllegalArgumentException("User is not found. userId = " + userId);
        }
        return user;
    }
}

파라미터 null을 점검하자

bad

public class MetricsCalculator {
    public double xProjection(Point p1, Point p2) {
        return (p2.x - p1.x) * 1.5;
    }
}
  • null을 리턴하는것도 나쁘지만 null 을 메서드로 넘기는것이 더 나쁘다
  • null을 메서드의 파라미터로 넣어야 하는 API를 사용하는 경우가 아니라면 null을 메서드로 넘기지 말자

null 을 파라미터로 받지 못하게 하자 good

public class MetricsCaculator {
    public double xProjection(Point p1, Point p2) {
        if (p1 == null || p2 == null) {
            throw InvalidArgumentException("Invalid argument for ~~")
        }
        return (p2.x - p1.x) * 1.5;
    }
}

차라리 null이 들어오면 unchecked exception을 발생시키자.

good

public class MetricsCaculator {
    public double xProjection(Point p1, Point p2) {
        assert p1 != null : "p1 should not be null";
        assert p2 != null : "p2 should bit be null";

        return (p2.x - p1.x) * 1.5;
    }
}

나만의 예외 처리를 만들기

단순히 IllegalException, RuntimeException을 사용하지만 말고, 내 예외를 만들어 사용해보자. 실무에서는 보통 자신의 예외를 정의하여 사용한다.

  • 장점
    • 에러 로그에서 stacktrace 해봤을 때 우리가 발생시킨 예외라는것을 바로 인지 가능하다.
    • 다른 라이브러리에서 발생한 에러와 섞이지 않는다. 우리도 IllegalArgumentException을 던지는 것보다 우리 예외로 던지는게 어느 부분에서 에러가 났는지 파악하기가 용이하다.
    • 우리 시스템에서 발생한 에러의 종류를 나열할 수 있다.

Categories:

Updated: