클린코드 (5) 오류처리
우아하게 예외 처리 하기
목차
- 예외 처리 방식
- Uncheked Exception
- Exception 잘 쓰기
- 실무 예외 처리 패턴
- 오픈소스 속 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");
...
}
}
- 오류가 발생한 부분에서 예외를 던진다. (별도의 처리가 필요한 예외라면 checked exception으로 던진다.)
- checked exception 에 대한 예외 처리를 하지 않는 다면 메서드 선언부에서 throws를 명시해야한다.
- 예외를 처리할 수 있는 곳에서 catch하여 처리한다.
Exception 가계도
Checked vs Unchecked Exception
- Checked Exception
- Exception__을 상속하면 __Checked Exception 명시적인 예외처리가 필요함.
- 예 IOException, SQLException
- 확인시점 : 컴파일 시점
- 처리 여부 : 반드시 처리
- 트랜잭션 처리 : roll-back 하지 않음
- Exception__을 상속하면 __Checked Exception 명시적인 예외처리가 필요함.
- 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");
...
}
}
- 특정 메소드에서 checked Exception을 throw 하고 상위 메소드에서 그 exception을 catch한다면 모든 중간단계 메소드에 excpetion을 trows 해야 한다.
- OCP (패쇄 개방 원칙) 위배 상위 레벨 메서드에서 하위 레벨 메서드의 디테일에 대해 알아야 하기 떄문에 OCP 원칙을 위배한다.
- 필요한 경우 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을 던지는 것보다 우리 예외로 던지는게 어느 부분에서 에러가 났는지 파악하기가 용이하다.
- 우리 시스템에서 발생한 에러의 종류를 나열할 수 있다.