뭔가 잘못될 가능성은 늘 존재한다.
뭔가 잘못되면 바로 잡을 책임은 바로 우리 프로그래머에게 있다.
깨끗한 코드와 오류 처리는 연관성이 있다.
상당수 코드 기반은 전적으로 오류 처리 코드에 좌우되므로 오류 처리는 중요하다.
하지만 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.
이제부터 우리는 우아하고 고상하게 오류를 처리하는 기법을 배워보겠다.
public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// 디바이스 상태를 점검한댜.
if (handle != DeviceHandle.INVALID) {
// 레코드 필드에 디바이스 상태를 저장한다.
retrieveDeviceRecord(handle);
// 디바이스가 일시정지 상태가 아니라면 종료한다.
if (record.getStatus() != DEVICE_SUSPENDED) {
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
} else {
logger.log("Device suspended. Unable to shut down");
}
} else {
logger.log("Invalid handle for: " + DEV1.toString());
}
}
...
}오류 코드를 사용한다면 몇가지 문제점이 발생한다.
- 오류 확인을 잊어버리기 쉽다.
- 함수를 호출한 즉시 오류를 확인해야 하기 때문에 호출자 코드가 복잡해진다.
public class DeviceController {
...
public void sendShutDown() {
try {
tryToShutDown();
} catch (DeviceShutDownError e) {
logger.log(e);
}
}
private void tryToShutDown() throws DeviceShutDownError {
DeviceHandle handle = getHandle(DEV1);
DeviceRecord record = retrieveDeviceRecord(handle);
pauseDevice(handle);
clearDeviceWorkQueue(handle);
closeDevice(handle);
}
private DeviceHandle getHandle(DeviceID id) {
...
throw new DeviceShutDownError("Invalid handle for: " + id.toString());
...
}
...
}예외를 던지는 방법은 호출자 코드를 깔끔하게 만들어 논리가 뒤섞이지 않게 해준다.
즉, 조건문이 없어짐에 따라 메서드를 수행하는 코드들이 한 눈에 보일 수 있게 되었다.
- 가독성이 좋아졌다.
- 코드 품질이 나아졌다.
- 기능 알고리즘과 오류 처리 알고리즘을 분리했다.
- 각 개념을 독립적으로 살펴보고 이해할 수 있게 되었다.
프로그램 안에다 범위를 지정한다는 사실은 매우 흥미롭다.
try 블록은 어떤 면에서 트랜잭션과 비슷하다.
try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지하기 때문이다.
그러므로 코드를 짤때는 try-catch-finally 문 으로 시작하는 편이 낫다.
그러면 try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워진다.
즉, 예외 처리를 지정함으로써 프로그래머는 코드의 방향성을 생각하면서 코드를 짤 수 있다.
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}- 파일이 없으면 예외를 던지는지 알아본다.
public List<RecordedGrip> retrieveSection(String sectionName) {
// 실제로 구현할 때까지 비어 있는 더미를 반환한다.
return new ArrayList<RecordedGrip>();
}- 실제로 구현할 때까지 비어 있는 더미를 반환한다.
- 하지만 위 코드는 아직 예외를 던지지 않는 코드이다.
잘못된 파일 접근을 시도하게 구현을 변경해보았다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (Exception e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}- 파일을 읽어들이는 코드를 작성했고 예외가 발생하게끔 만들었다.
- 이 시점에서 해당 코드는 리팩토링이 가능하다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileInputException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}catch블록에서 예외 유형을 좁혀
FileInputStream생성자가 던지는FileInputException을 잡아낸다.
try-catch 구조로 범위를 정의했으므로 TDD를 사용해 필요한 나머지 논리를 추가한다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
... // 추가 로직
... // 추가 로직
... // 추가 로직
stream.close();
} catch (FileInputException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}- 나머지 논리는
FileInputStream생성 코드와stream.close()사이에 작성한다. - 오류나 예외가 전혀 발생하지 않는다고 가정한다.
이후, 강제로 예외를 일으키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하자
그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로
범위 내에서 트랜잭션 본질을 유지하기 쉬워진다.
쉽게 얘기하면 트랜잭션 처럼 한 기능을 온전히 수행하는 범위를 인위적으로 만들었다 볼 수 있다.
프로그래머들은 확인된(checked) 예외의 장단점을 놓고 논쟁을 벌여왔다.
하지만 안정적인 소프트웨어를 제작하는 요소로 반드시 필요하지 않다는 사실이 분명해졌다.
| Checked Exception | Unchecked Exception | |
|---|---|---|
| 처리 여부 | 반드시 예외를 처리해야 함 | 명시적인 처리를 강제하지 않음 |
| 확인 시점 | 컴파일 단계 | 실행 단계 |
| 예외 발생시 트랜잭션 처리 | Roll-back 하지 않음 | Roll-back 함 |
| 대표 예외 | Exception의 상속받는 하위 클래스 중 Runtime Exception을 제외한 모든 예외 * IOException * SQLException |
Runtime Exception 하위 예외 * NullPointerException * IllegalArgumentException * IndexOutOfBoundException * SystemException |
확인된(checked) 예외를 사용한 코드
public void catchFileInputException() {
try {
a();
} catch (FileInputException e) {
throw new StorageException("retrieval error", e);
}
}
public void a() throws FileInputException {
aa();
}
public void aa() throws FileInputException {
aaa();
}
public void aaa() throws FileInputException {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
}- 확인된(checked) 예외는 OCP를 위반한다.
- 메서드에서 예외를 던졌는데
catch문이 위에 있다면 모든 선언부에 예외를 정의해야한다. - 즉, 하위 단계에서 코드를 수정하면 모든 상위 단계 메서드 선언부를 고쳐야한다.
- 모듈과 관련된 코드가 바뀌지 않아도 선언부가 바뀌었으므로 모듈을 다시 빌드 후 배포 해야한다.
대규모의 시스템에 호출이 일어나는 방식을 살펴보자
최상위 함수가 아래 함수를 호출하고 아래 함수는 더 아래 함수를 호출한다.
만약 최하위 함수에서 기존과 다른 chekced 예외가 발생한다면?
아래와 같은 동작을 취해야 한다.
catch 블록을 가진 상위 메서드에 새로운 예외 처리를 추가한다.- 연관된 메서드의 선언부에 새로운 예외를 추가해줘야 한다.
결과적으로 최하단부터 최상단까지 연쇄 작용이 일어나고
throws 경로에 위치하는 모든 함수는 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.
때로는 확인된(checked) 예외도 유용하다.
아주 중요한 라이브러리를 작성한다면 모든 예외를 잡아야 한다.
하지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크므로 미확인 예외를 사용하는 것이 좋다.
public List<RecordedGrip> retrieveSection(String sectionName) {
try {
FileInputStream stream = new FileInputStream(sectionName);
stream.close();
} catch (FileInputException e) {
throw new StorageException("retrieval error", e);
}
return new ArrayList<RecordedGrip>();
}- 예외를 던질 때는 전후 상황을 충분히 덧붙인다.
- 그러면 오류가 발생하는 원인과 위치를 찾기가 쉬워진다.
- 오류 메시지에 정보를 담아 예외와 함께 던진다.
- 실패한 연산 이름과 실패 유형도 함께 언급한다.
- 로깅 기능을 사용한다면 catch 블록에서 오류를 기록하도록 충분한 정보를 남겨준다.
오류를 정의할 때 가장 중요한 관심사는 오류를 잡아내는 방법이다.
오류를 분류하는 방법은 수없이 많다.
오류가 발생한 위치로 분류하거나 오류 유형으로 분류가 가능하다.
다음은 오류를 형편없이 분류한 사례다
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("Device response exception");
} finally {
...
}- 외부 라이브러리를 호출하는 코드
- 외부 라이브러리가 던질 예외를 모두 잡아내고 있다.
- 하지만, 위 코드는 중복이 심하고 에러마다 기능을 추가해야 된다면 코드도 길어질 것이다.
- 즉, 시간이 지날수록 코드가 길고 복잡한 형태를 띄게 될 것이다.
호출 라이브러리 API를 감싸면서 예외 유형을 하나로 반환하게 만드는 기법
ACMEPort port = new ACMEPort(12);
try {
port.open();
} catch (DeviceResponseException e) {
reportPortError(e);
logger.log("Device response exception", e);
} catch (ATM1212UnlockedException e) {
reportPortError(e);
logger.log("Unlock exception", e);
} catch (GMXError e) {
reportPortError(e);
logger.log("Device response exception");
} finally {
...
}- 위 코드는 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일하다.
- 즉, 코드를 간결하게 고치기가 아주 쉬운 코드이다.
- Wrapper 클래스로 호출하는 라이브러리 API를 감싸면서 예외 유형을 하나로만 반환하면 된다.
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);
}
}
...
}- Wrapper 클래스는 단순히 인스턴스의 메서드를 호출한다.
- 이 과정에서 대응 방식이 동일한 예외를
PortDeviceFailure예외로 다시 던진다.
LocalPort port = new LocalPort(12);
try {
port.open();
} catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}- 외부에서는 Wrapper클래스의 메서드를 호출한다.
- 이너 클래스의 메서드를 수행하는 메서드
- 이전과 달리 Wrapper클래스의 예외 처리로 인해
PortDeviceFailure예외만 처리해주면 된다.
실제로 외부 API를 사용할 때는 감싸기 기법이 최선이다.
외부 API를 Wrapper클래스로 감싸면 아래와 같은 장점이 있다.
- 에러 처리가 간결해진다.
- 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어든다.
- 다른 라이브러리로 변경할 시 비용이 적다.
- 외부 API 호출 대신 테스트 코드를 넣는 방법을 사용해 프로그램 테스트가 쉬워진다.
- 무엇보다 특정업체의 외부 API 설계 방식에 의존하지 않아도 된다.
- 프로그램이 사용하기 편리한 API를 개발자가 직접 정의할 수 있다.
흔히 예외 클래스가 하나만 있어도 충분한 코드가 많다.
예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우가 그렇다.
한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용하자
예외 처리가 꼭 필요한 것이 아니라면 정의하지 않는 것이 좋다.
외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리
Wrapper 클래스를 이용하여 코드를 잘 분리하면 깨끗하고 간결한 알고리즘이 보인다.
하지만 예외 처리 또한, 가독성을 낮추는 작업이기에 예외를 발생시키지 않는 코드를 작성하는 것이 좋다.
즉, 예외 처리에 집중이되어 보다 좋은 코드를 작성할 수 있는 기회를 놓치지 않길 바란다.
try {
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal();
} catch(MealExpencesNotFound e) {
m_total += getMealPerDiem();
}- 위 코드는 총계를 계산하는 코드이다.
- 식비를 비용으로 청구했다면 직원이 청구한 식비를 총계에 더한다.
- 식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.
- 예외 처리를 이용하여 일일 기본 식비를 총계에 더하는 알고리즘이다.
- 하지만 예외가 논리를 따라가기 어렵게 만들고 있다.
getTotal CASE 1 and 2
public class PerDiemMealExpenses implements MealExpenses {
MealExpenses someThing
public int getTotal() {
try {
return someThing.getTotal();
} catch (NullPointerException e) {
throw new MealExpencesNotFound(e);
}
}
}- 예외를 던지는
getTotal()메서드이다. NullPointerException이 발생하면 Wrapper 클래스로MealExpencesNotFound를 던질 것이다.- 하지만 결과적으로 호출한 곳에서 예외 처리를 진행하므로 코드의 가독성을 낮추는 문제를 야기한다.
MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
m_total += expenses.getTotal(); public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// 기본값으로 일일 기본 식비를 반환한다.
}
}MealExpenses인터페이스를 구현하는PerDiemMealExpenses클래스를 새로 정의했다.PerDiemMealExpenses클래스는NullPointerException을 방지하기 위한 특수 사례 클래스이다.- 즉, 특수한 동작이 처리되지 않을 경우
PerDiemMealExpenses객체를 반환하게끔 만드는 것이다. - 예제로 빗대어 보면 식비를 비용으로 청구하지 않았다면
PerDiemMealExpenses객체를 반환한다. - 물론,
expenseReportDAO.getMeals()에서는 이에 대한 처리를 해주어야한다. - 이같이 '특수 사례 클래스'를 만들어
Null이나NullPointerException을 처리하는 방법을 '특수 사례 패턴'이라 한다. - 클래스나 객체가 예외적인 상황을 캡슐화해서 처리하므로 예외적인 상황을 처리할 필요가 없어진다.
- https://johngrib.github.io/wiki/null-object-pattern/
null을 반환하는 행위는 우리가 흔히 저지르는 오류 유발 행위이다.
public void registerItem(Item item) {
if (item != null) {
ItemRegistry registry = peristentStore.getItemRegistry();
if (registry != null) {
Item existing = registry.getItem(item.getID());
if (existing.getBillingPeriod().hasRetailOwner()) {
existing.register(item);
}
}
}
}- 대부분의 개발자는 이런 코드가 나쁘지 않다고 생각하는데 사실 나쁜 코드이다.
- 호출자에게 null을 체크할 의무를 준다.
NullPointerException이 발생할 위험이 있다.null 체크갯수에따라 분기가 나눠져 처리해야할 코드가 많아지므로 복잡성이 커진다.- 누구 하나라도
null 체크를 빼먹으면 통제 불가능해진다.
이 같은 문제를 해결하기 위해서 주로 2가지 방법을 사용한다.
- Wrapper 클래스를 이용해서 예외를 던진다.
- '특수 사례 패턴'의 '특수 사례 객체'를 이용해서 코드를 단일화 시킨다.
개선 전
List<Employee> employees = getEmployees();
if(employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}getEmployees()메서드는null을 반환하기에 이를 처리해줘야한다.
개선 후
public List<Employee> getEmployees() {
if (..직원이 없다면..)
return Collections.emptyList();
}List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}getEmployees()메서드에서 특정 조건을 검사해 '빈 리스트'를 반환하도록 코드를 변경했다.- 여기서
emptyList()는 '특수 사례 객체'를 리턴하는 메서드라 생각하면 된다. - 리턴된 값은 내부가 어떻든
List<Employee>형태가 동일하므로 코드가 단일화 된다. - 결과적으로 코드가 간결해지고
NullPointerException이 발생할 확률도 줄어들었다.
null을 반환하는 메서드보다 메서드로 null 을 전달하는 방식이 더 나쁘다.
정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피한다.
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x - p1.x) * 1.5; // 일부러 절차 지향적인 클래스로 만든것
}
...
}- 두 지점 사시의 거리를 계산하는 간단한 메서드다.
calculator.xProjection(null, new Point(12, 13));- 만약 인수로 null을 전달하면 어떤 일이 발생할까?
- 당연히
NullPointerException이 발생한다.
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
if(p1 == null || p2 == null) {
throw InvalidArgumentException(
"Invalid argument for MetricsCalculator.xProjection");
}
return (p2.x - p1.x) * 1.5; // 일부러 절차 지향적인 클래스로 만든것
}
...
}null일 경우InvalidArgumentException예외 클래스를 던지도록 했다.NullPointerException보다는 조금 나을지 모르겠다.- 하지만,
InvalidArgumentException을 잡아내는try-catch처리기가 필요하다. - 그렇다면
InvalidArgumentException예외를 어떻게 처리해야할까?
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
assert p1 != null : "p1 should not be null";
assert p2 != null : "p2 should not be null";
return (p2.x - p1.x) * 1.5;
}
}- assert 문을 사용한 방법은 코드를 읽기는 편하지만 문제를 해결하지는 않았다.
- 누군가 null을 전달하면 여전히 실행 오류가 발생하기 때문이다.
사실 대다수의 프로그래밍 언어는 호출자가 실수로 null을 넘기는 것을 처리하는 방법이 없다.
그렇기에 애초에 null을 넘기지 못하도록 금지하는 정책이 합리적인 방법이라 말할 수 있다.
즉, 인수로 null이 넘어오면 코드에 문제가 있다는 말이며 이를 주의하면서 코드를 작성하자
- 자바에서 assert 문을 사용하려면 몇 가지 준비사항이 있다.
- 방법은 해당 사이트에서 참조 : https://offbyone.tistory.com/294
깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려하면
튼튼하고 깨끗한 코드를 작성할 수 있다.
오류 처리를 프로그램 논리와 분리하면
독립적인 추론이 가능해지며 코드 유지보수성도 크게 높아진다.
예외 처리에 대해 추가적으로 공부를 하고 싶다면 cheese10yun 님의 블로그를 보는 것을 추천한다.
물론, 이 외에도 유용한 정보를 많이 알려주신다 : https://cheese10yun.github.io/checked-exception/
https://close852.tistory.com/47
https://cheese10yun.github.io/checked-exception/
http://amazingguni.github.io/blog/2016/05/Clean-Code-7-%EC%98%A4%EB%A5%98-%EC%B2%98%EB%A6%AC