//
Search
Duplicate
📖

[Clean Code] 7장 오류처리

상태
클린코드
담당자
시작일
2022/04/29 18:15
최종편집일
2023/12/10 11:43
책 내용 - 오류 처리
깨끗한 코드와 오류 처리는 확실히 연관성이 있다.
상당수 코드 기반은 전적으로 오류 처리 코드에 좌우된다.
여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 파악하기가 거의 불가능하다는 의미다.
깨끗하고 튼튼한 코드에 한걸음 더 다가가는 단계로 우아하고 고상하게 오류를 처리하는 기법과 고려 사항 몇 가지를 소개한다.
오류 코드보다 예외를 사용하라
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("Deivce suspended. Unalbe to shut down"); } } else { logger.log("Invlid handle for:" + DEV1.toString()) } } }
Java
복사
위와 같은 방법을 사용하면 호출자 코드가 복잡해진다.
함수를 호출한 즉시 오류를 확인해야 하기 때문이다.
오류가 발생하면 예외를 던지는 편이 낫다.
public class DeviceController { ... public void sendShutDown() { try { tryToShutDown(); } catch (DeviceShutDownError e) { logger.log(e); } } private void tryToShutDonw() 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()); ... } ... }
Java
복사
코드가 확실히 깨끗해지지 않았는가! 단순히 보기만 좋아지지 않았다.
코드 품질도 나아졌다.
디바이스를 종료하는 알고리즘과 오류를 처리하는 알고리즘을 분리했기 때문이다
Try-Catch_finaly 문부터 작성하라
어떤 면에서 try 블록은 프트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다.
다음은 파일이 없으면 예외를 던지는지 알아보는 단위 테스트이다.
@Test(expected = StorageException.class) public void retrieveSetctionShouldThrowOnInvalidFileName() { sectionStore.retrieveSection("invlid - file"); } // 단위 테스트에 맞춰 다음 코드를 구현했다. public List<RecordedGrip> retrieveSection(String sectionName) { // 실제로 구현할 때까지 비어 있는 더미를 반환한다; return new ArrayList<RecprdedGrip>(); } // 그런데 코드가 예외를 던지지 않으므로 단위 테스트 실패// 변경해보자 public List<RecordedGrip> retrieveSection (String sectionName) { try { fileInputStream stream = new FileInputStream(setctionName); } catch (Exception e) { throw new StorageException("retrieval error", e); } return new ArrayList<RecordedGrip>(); } // 코드가 예외를 던지므로 이제는 테스트가 성공한다. 이 시점에서 리팩터링이 가능하다. catch 블록에서// 예외 유형을 좁혀 실제로 FileInputStream 생성자가 던지는지 FileNotFoundException을 잡아낸다. public List<RecordedGrip> retrieveSection (String sectionName) { try { fileInputStream stream = new FileInputStream(setctionName); stream.close(); } catch (Exception e) { throw new StorageException("retrieval error", e); } return new ArrayList<RecordedGrip>(); }
Java
복사
먼저 강제로 예외를 일이 키는 테스트 케이스를 작성한 후 테스트를 통과하게 코드를 작성하는 방법을 권장한다.
그러면 자연스럽게 try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉽다.
미확인 unchecked 예외를 사용하라
미확인 예외?? 정확히 와닿지 않았다.
checked Exception과 unchecked Exception의 차이점은 RuntimeException을 상속을 하는지의 안 하는지의 차이가 있다.
그리고 컴피일 시 예외 처리를 확인하는 차이점이 있다.
Show All
Search
checked Exception
unchecked Exception
반드시 예외 처리가 있어야된다.
런타임중 예외가 확인된다.
기본적으로 roll-back하지 않는다.
기본적으로 roll-back을 한다.
IoExceptionsqlException
NullPointExceptionIndexOutOfBoundException
확인된 예외는 OCP를 위반한다.
메서드에서 확인된 예외를 던졌는데 catch 블록이 세 단계 위에 있다면 그사이 메서드 모두가 선언부에 해당 예외를 정의해야 한다. 즉, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 말이다.
아래 코드는 출력하는 메서드이다.
public void printA(bool flag) { if (flag) System.out.println("called"); } public void func(bool flag) { printA(flag); }
Java
복사
프린트 출력을 안 할 때 NotPrintException을 던지기로 구현을 변경했을 때,
public void printA(bool flag) throws NotPrintException { if (flag){ System.out.println("called"); } else { throw new NotPrintException(); } } public void func(bool flag) throws NotPrintException { printA(flag); }
Java
복사
대규모 시스템에서 호출이 일어나는 방식을 상상해보라.
최상위 함수가 아래 함수를 호출한다.
아래 함수는 그 아래 함수를 호출한다.
단계를 내려갈수록 호출하는 함수 수는 늘어난다.
이 처럼 throws 경로에 위치하는 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.
예외에 의미를 제공하라
1. 오류 메시지에 정보를 담아 예외와 함께 던진다.
2. 실패한 연산 이름과 실패 유형도 언급한다.
호출자를 고려해 외예 클래스를 정의하라
애플리케이션에서 오류를 정의할 때 프로그래머에게 가중 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
다음은 오류를 형편없이 분류한 사례다.
ACMEPort port = new ACMEPort(12); try { port.open(); } catch (DeviceResponseException e) { reportPortError(e); logger.log("Device response exception", e); } catch (ATM1212UnlocakedException e) { reportPortError(e); logger.log("Unlocaked exception", e); } catch (GMXError e) { reportPortError(e); logger.log("Device response exception", e); } finally { .... }
Java
복사
위 코드는 중복이 심하지만 그리 놀랍지 않다. 대다수 상황에서 우리가 오류를 처리하는 방식은 비교적 일정하다.
1) 오류를 기록한다.
2) 프로그램을 계속 수행해도 좋은지 확인한다.
위 경우는 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일하다. 그 래서 코드를 간결하게 고치기가 아주 쉽다. 호출하는 라이브 API를 감싸면서 예외 유형 하나를 반환하면 된다.
LocalPort port = new LocalPort (12); try { port.open(); } catch (PortDeviceFailure e) { reportPortError(e); logger.log("Device response exception", e); } finally { ... }
Java
복사
public class LocalPort { private ACMEPort ineerPort; public LocalPort(int number) { innerPort = new ACMEPort(number); } public void open() { try { innerPort. open(); } catch (DeviceResponseException e) { throw new PortDeviceFailure(e); } catch (ATM1212UnlocakedException e) { throw new PortDeviceFailure(e); } catch (GMXError e) { throw new PortDeviceFailure(e); } } }
Java
복사
LocalPort 클래스처럼 ACMEPort를 감싸는 클래스는 미우 유용하다.
실제로 외부 API를 사용할 때는 감싸기 기법이 최선이다.
1) 외부 API를 감싸면 외부 라이브러리와 프로그램 사이의 의존선이 크게 줄어든다.
2) 나중에 다른 라이브러리로 갈아타도 비용이 적다.
3) 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법으로 테스트하기도 쉽다.
4) 특정 업체가 API를 설계한 방식에 발목 잡히지 않는다.
5) 프로그램이 훨씬 깨끗해진다.
정상 흐름을 정의하라
다음 예제를 살펴보자. 다음은 비용 청구 애플리케이션에서 총계를 계산하는 허술한 코드다.
try { MealExpensee expenses = expenseReportDAO.getMeals(meployee.getId()); m_total += expense.getTotal(); } catch (MealExpensesNotFound e) { m_total += getMealPPerDiem(); }
Java
복사
위에서 식비를 비용으로 청구했다면 직원이 청구한 시비를 총계에 더한다.
식비를 비용으로 청구하지 않았다면 일일 기본 식비를 총계에 더한다.
그런데 예외가 논리를 따라기기 어렵게 만든다.
특수 상황을 처리할 필요가 없다면 더 좋지 않을까?
MealExpensee expenses = expenseReportDAO.getMeals(meployee.getId()); m_total += expense.getTotal();
Java
복사
ExpenseReportDAO를 고쳐 언제나 MealExpense객체를 반환한다.
청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환한다.
public class PerDiemMealExpenses implements MealExpense { public int getTotal() { return 3000;// 기본값 } }
Java
복사
여기서 좀 이해가 안 되는 부분이 있었다. 그래서 expenseRepotDAO를 어떻게 설계하라고?
public class ExpenseReportDAO { ... MealExpense getTotal(String employeeId) { if (isEmployeeIdNull(employeeId)) { return new PerDieMealExpenses(); } else { return new MealExpenses(); } } }
Java
복사
이와 같이 설계하면 되지 않을까 생각했다.
그러면 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다.
클래스나 객체가 예외적인 상황을 캡슐화해서 처리하므로.
null을 반환하지 마라
한 줄 건너 하나씩 null을 확인하는 코드로 가득한 애플리케이션을 지금까지 수도 없이 봤다.
다음이 한 예이다.
public void registerItem(Item item) { if (item != null) { ItemRegistry registry = peristentStore.getItemRegisrty(); if (registry != null) { Item existing = registry.getItem(item.getId()); if (existing.getBillingPeriod().hasRetailOwner()) { existing.register(item); } } } }
Java
복사
이런 코드 기반에서 코드를 짜 왔다면 나쁘다고 느끼지 않을지도 모르겠다. 하지만 위 코드는 나쁘 코드다!
1) null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘긴다.
2) 누구 하나라도 null 확인을 빼먹는다면 애플리케이션 통제 불능에 빠질지도 모른다.
위 코드에 둘째 행에 null 확인이 빠졌다는 사실을 눈치챘는가?
차라리 예외를 던지거나 특수 사례 객체를 반환하는 것이 좋다.
List<Employee> employees = getEmployees(); if(employees != null) { for(Employee e : employees) { totalPay += e.getPay(); } }
Java
복사
위에서 getEmployees는 null도 반환한다.
하지만 반드시 null을 반환할 필요가 있을까?
getEmployees를 변경해 빈 리스트를 반환한다면 코드가 훨씬 깔끔해진다.
List<Employee> employees = getEmployees(); for(Employee e : employees) { totalPay += e.getPay(); } public List<Employee> getEmployees() { if (..직원이 없다면..) return Collections.emptyList(); }
Java
복사
이렇게 코드를 변경하면 코드도 깔끔해질뿐더러 NullPointerException이 발생할 가능성도 줄어든다.
결론
깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 한다.
이 둘은 상충하는 목표가 아니다.
이번 챕터는 이해하는데 시간이 좀 걸렸다.
많은 개념들을 알고 있어야지 좀 더 명확하게 알 것 같으며, 특히 Null의 위험성에 강조한다는 생각이 든다. Null을 주의 깊게 그리고 사용하지 않게 생각하면서 코딩하자.