Clean Code 3판을 읽고 정리한 글입니다
Ⅶ. 오류 처리
클린 코드와 오류처리의 상관관계
- 오류의 가능성은 늘 언제나 존재
- 오류 정정의 책임은 프로그래머가 가지고 있음
- 여기저기 흩어진 오류 처리 코드 → 실제 코드 로직 파악 어렵게 만듬
오류 코드 보다 예외 사용
이전 프로그래밍 언어 → Exception 제공 x
- 개발자들이 에러 상태나 flag를 set해야 함
- 에러코드를 리턴, 호출하는 측에서 예외 처리해줘야 함
안좋은 예 오류 플래그, 호출자에게 오류 코드 반환 등 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22public class DeviceController {
...
public void sendShutDown() {
DeviceHandle handle = getHandle(DEV1);
// Check the state of the device
if (handle != DeviceHandle.INVALID) {
// Save the device status to the record field
retrieveDeviceRecord(handle);
// If not suspended, shut down
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());
}
}
...
} - 위 코드 단점
- 호출자 코드 복잡 : 호출 리턴 받은 즉시 오류 확인 필요
- 불행히 이 단계를 잊어버리기 쉬움
- Exception 사용하면?
- 깔끔해짐
- 겉보기만 아름다워지는 것이 아님
- 실제 로직과 예외처리 부분이 나누어짐
- 필요 부분에 집중
오류 발견시 예외를 던지는 코드 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25public 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-catch-Finally부터 작성하라
- try블록은 트랜잭션과 비슷
- try가 어떻든 catch에서는 프로그램 상태를 일관성 있게 유지
- 코드의 scope 정의 가능
예시TEST 코드 1
2
3
4
5//예외를 기대하는 단위 테스트 코드
public void retrieveSectionShouldThrowOnInvalidFileName() {
sectionStore.retrieveSection("invalid - file");
}
1 | public List<RecordedGrip> retrieveSection(String sectionName) { |
1 | public List<RecordedGrip> retrieveSection(String sectionName) { |
- 이제부터 리팩토링 가능
1 | // Exception의 범위를 FileNotFoundException으로 줄여 잡아낸다. |
- try catch구조로 범위 정의 → TDD를 사용해 나머지 논리 추가
- 나머지 논리는
FileInputStream
생성과 close()사이에 넣는다 - 먼저 강제로 예외를 일으키는 테스트 케이스를 작성 후 테스트를 통과하게 코드를 작성하는 방법 권장
- 자연스럽게 try블록의 트랜잭션 부터 구현하게됨 → 범위 내에서 트랜잭션 본일 유지가 쉬워짐
unchecked 예외를 사용하라
논쟁은 끝났다 : unchecked 사용하라!
checked Execption vs Unchecked Exception
- 가장 명확한 기준 : 꼭 처리를 해야 하는 부분
- 확인된 예외 : 반드시 try/catch로 감싸거나 throw로 처리해야 한다
이전엔는 확인된 예외를 좋은 아이디어로 생각
- 확인된 예외 : checked exception
- 북구될 가능성이 있는 문제상황으로 봄
- 컴파일단에서 확인이 가능하고 복구를 시도해 보는 것이 일차적 관심상
- 메서드 선언시 메서드가 반환할 예외 모두 열거
- 메서드 반환 예외는 메서드 유형의 일부
- 코드가 메서드를 사용하는 방식이 메서드 선언과 일치하지 않으면 아예 컴파일도 불가
현재는 확인된 예외가 반드시 필요하지 않는다
- c#, c++, 파이썬, 루비 : 확인된 예외가 없지만 안정적인 sw만드는데 문리가 없음
확인된 예외(checked exception)의 비용
- 위에서 말했듯이 모든 중간 단계 메소드에 해당 exception을 throw 해야 함
- OCP 위반
- 메서드에서 확인된 예외를 던졌는데 catch 블록이 3단계 위라면
- 그 사이 메서드 모두 선언부에 해당 예외를 정의해야함 (throw 절)
- 하위 단계 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야함(새로운 예외 추가or throw절 추가등)
- (코드가 전혀 변경이 없어도) 선언부가 바뀌므로 모듈 다시 빌드하고 배포해야함
- throws 경로의 모든 함수가 최하위 함수의 예외(하위 세부)를 알아야 함 → 캡슐화가 꺠진다
- 오류를 원거리에서 처리하기 위해 예외를 사용했는데 예외가 캡슐화를 깨버리는 아이러니
때로는 유용
- 아주 중요한 라이브러리라면 모든 예외를 잡아야함
- 일반적 어플리케이션? 의존성 비용>>>>>> 예외로 얻는 이득
예외에 메시지를 담아라 - 예외에 의미있는 정보를 담기
예외를 던질때 전주 상황을 충분히 덧붙여라
- 오류가 발생한 원인/위치 찾기가 쉬어짐
- 자바는 모든 예외에 호출 스택을 제공 → 실패 코드 의도 파악을 위해선 해당 정보로 부족
- 오류 메시지에 정보를 담아 예외와 함께 던져야함
- 실패한 연산 이름, 실패 유형도 언급
- 로깅 사용시 catch 블럭에서 오류룰 기록하도록 충분한 정보를 넘겨준다
호출자를 고려해서 예외 클래스 정의
- 오류 분류방법은 수없이 많음 : 오류 발생한 위치, 유형등
- 프로그래머 가장 큰 관심사 : 오류를 잡아내는 방법일반적인 상황에서 우리가 오류를 처리하는 방식
형편 없는 오류 분류 예시 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 외부 라이브러리가 던질 예외를 모두 잡음
// 중복도 심함
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를 감싸면서 예외 유형 하나를 반환하도록
1 | // 호출부 - Exception 처리가 들어나지 않도록 수행 가능 |
wrapping의 유용성
- 실제로 외부 api 사용시 wrapping 기법이 최선
- 외부 api wrapping → 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어든다
- 나중에 다른 라이브러리로 갈아타도 비용이 적음
- wrapping 클래스에서 외부 api 호출 대신 테스트 코드 넣어주는 방법으로 테스트도 쉬어짐
- wrapping 사용하면 특정 업체가 api를 설계한 방식에 발목 잡히지 않음
- 위의 예제에선 port 디바이스 실패를 표현하는 예외 유형 하나 정의 → 프로그램이 깨끗해짐
예외 클래스 수
- 예외 클래스가 하나만 있어도 충분한 코드 → 예외 클래스에 포함된 정보로 오류를 구분해도 괜찮은 경우
- 한 예외 잡아내고 다른 예외 무시해도 괜찮다면? 여러 예외 클래스 사용
정상 흐름을 정의하라
- 앞절의 지침 충실히 → 비지니스와 오류처리가 잘 분리된 코드
- 오류 감지가 프로그램 언저리로 밀려나게 됨
- 외부 API를 감싸 독자적 예외 던짐
- 코드위에 처리기를 정의해 중단된 계산을 처리
- 대부분은 좋은 처리 방식이지만 이런 중단이 적합하지 않을 때도 있음
1 | try { |
1 | MealExpenses expenses = expenseReportDAO.getMeals(employee.getID()); |
정답 : 가능함
- ExpenseReportDAO가 언제나 MealExpense 객체 반환하도록 고침
- 청구 식비가 없으면 일일 기본 식비를 반환하는 MealExpense 객체 반환
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21public class PerDiemMealExpenses implements MealExpenses {
public int getTotal() {
// return the per diem default
}
}
// ex??
public class ExpenseReportDAO {
...
public MealExpenses getMeals(int employeeId) {
MealExpenses expenses;
try {
expenses = expenseReportDAO.getMeals(employee.getID());
} catch(MealExpensesNotFound e) {
expenses = new PerDiemMealExpenses();
}
return expenses;
}
...
} - 특수 사례 패턴
- 클래스를 만들거나 객체를 조작해서 특수 사례를 처리하는 방식
- 객체나 클래스가 예외적인 상황을 캡슐화해서 처리
- 클라이언트 코드가 예외적인 상황을 처리할 필요가 없어진다
null을 반환하지 마라
null확인이 가득한 코드 → 나쁜 코드
- null 반환 코드는 일거리를 늘리며 호출자에게 문제를 넘김
- 한명이라도 null 체크 빼먹으면 위험
1
2
3
4
5
6
7
8
9
10
11
12
13public 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);
}
}
}
}
// - peristentStore가 null인 경우 예외처리 안됨
// - 안된 것도 찾기가 힘든
메서드에서 null을 반환하고 픈 유혹이 든다면? → 예외를 던지거나 특수 사례 객체를 반환하라
사용하려는 외부 api가 null 반환? → 감싸기 기법 사용 → 예외 던지거나 특수 사례 객체 반환하는 방식 고려
많은 경우 특수 사례 객체가 손쉬운 해결책
예시 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19// 1. null 반환
List<Employee> employees = getEmployees();
if (employees != null) {
for(Employee e : employees) {
totalPay += e.getPay();
}
}
// 2. null 체크 없애고 getEmployees에서 빈 리스트 반환
List<Employee> employees = getEmployees();
for(Employee e : employees) {
totalPay += e.getPay();
}
public List<Employee> getEmployees() {
if( .. there are no employees .. )
return Collections.emptyList();
}
}
// 코드도 깔끔해지고 nullPointerException 발생도 줄어든다 - 안정화
null 전달하지마라
- 메서드 null 반환보다 메서드 null 전달이 더 나쁨
- 정상적인 인수로 null을 기대하는 API가 아니라면 메서드로 null 전달하는 코드는 최대한 피한다
- 일반적으로 대다수의 프로그래밍 언어들은 파라미터로 들어온 null에 대해 적절한 방법을 제공하지 못한다.
- 가장 이성적인 해법은 null을 파라미터로 받지 못하게 하는 것이다.
1 | public class MetricsCalculator { |
어떻게 고치는가?
1 | // 1. 새로운 예외 유형을 만들어 던지기 |
1 | // 2. assert문 사용 |
null 인수의 자체가 문제
- 대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없음
- 애초에 null을 넘기지 못하도록 금지하는 정책이 함리적
- 인수로 null이 넘어오면 코드에 문제가 있는 것
결론
- 클린코드는 읽기도 좋지만 안정성도 좋야야 하며 상충하는 목표가 아님
- 오류처리를 프로그램 로직과 분리해서 독자적 사안으로 고려 → 튼튼한 클린코드 작성 가능
- 독립적 추론 가능 , 코드 유지보수성 올라감
실무 예외 처리 패턴
제로베이스에서 한달 한권의 내용
1. getOrElse
- 예외 대신 기본 값 리턴
- null이 아닌 기본값 : 위의 employee 예제
- 도메인에 맞는 기본 값 : 아래 설명
1 | UserLevel userLevel = null; |
1 | // 호출부 - 단순해짐 |
- 예외처리를 데이터 제공 쪽 처리 → 호출부가 심플, 단순해짐
- 코드 리딩시 논리적 흐름이 끊기지 않음
- 도메인에 맞는 default 값을 도메인 서비스에서 관리
getOrElseThrow
기본값이 없으면 null대신 예외를 던진다
1 | User user = userRepository.findByUseId(userId); |
- user를 사용하는 쪽에서 매번 체크를 해야한다.
- 가독성 뿐만 아니라 안정성도 떨어진다.
- null 체크가 빠진 부분이 발생할 수 있다.
1 | // 호출부 |
- 기본 값이 없는 경우임
- 기본 값이 없으면 null 체크 → 데이터가 없으면 예외 던짐
- 호출부에서 매번 null 체크가 필요 없음
- 호출부의 가독성 올라감
실무에선 보통 자신의 예외를 정의
1 | public class MyProjectException extends RuntimeException { |
장점
- 에러 로그에서 stacktrace할 때 자신의 예외라는 것을 바로 인지 가능
- 다른 라이브러리 에러와 섞이지 않음 : 다른 IllegalArgumentException과 분리
- 우리 도메인에서 발생한 에러 종류를 나열 가능
Related POST
- [Clean Code] Ⅰ. 깨끗한 코드
- [Clean Code] Ⅱ.의미 있는 이름
- [Clean Code] Ⅲ. 함수
- [Clean Code] Ⅳ. 주석
- [Clean Code] Ⅴ. 형식 맞추기
- [Clean Code] Ⅵ. 객체와 자료구조
- [Clean Code] Ⅶ. 오류 처리
- [Clean Code] Ⅷ. 경계
- [Clean Code] Ⅸ. 단위 테스트
- [Clean Code] Ⅹ. 클래스
- [Clean Code] Ⅺ. 시스템
- [Clean Code] Ⅻ. 창발성(創發性)
- [Clean Code] XIII. 동시성
- [Clean Code] XIV. 점진적 개선(SUCCESSIVE REFINEMENT)
- [Clean Code] XV. JUnit 들여다보기
- [Clean Code] XVI. SerialDate 리팩터링
- [Clean Code] XVII. 냄새와 휴리스틱
- [Clean Code] 다 읽었다~