CleanCode 7장 오류 처리 에 대해 정리한 포스트입니다.

Overview

상당수 코드 기반은 전적으로 오류 처리 코드에 좌우되기 때문에 깨끗한 코드와 연관성이 있습니다.

  • 여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 파악하기가 어려워 질 수 있음
  • 오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵다.

이 장은 오류를 처리하는 기법과 고려 사항 몇가지로 구성되어 있습니다.

  1. 오류 코드 보다 예외 를 사용하라
  2. Try-Catch-Finally 문부터 작성하라
  3. 미확인 ^unchecked 예외를 사용하라
  4. 예외에 의미 를 제공하라
  5. 호출자를 고려 해 예외 클래스를 정의하라
  6. 정상 흐름 을 정의하라
  7. null을 반환 하지 마라
  8. null을 전달 하지 마라

오류 코드보다 예외를 사용하라

오류 코드 를 사용하는 기존의 방식은 아래와 같습니다.

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());
		}
	}
	...
}

위와 같은 방법을 사용하면 몇가지 문제점이 생깁니다.

  1. 호출한 즉시 오류를 확인해야 하기 때문에 호출자 코드가 복잡 해진다.
  2. 오류 확인을 잊어버리기 쉽다.

그래서 오류 발생시 예외를 던지는 편이 더 좋습니다.

  • 논리가 오류 처리 코드와 뒤섞이지 않아 호출자 코드가 더 깔끔해짐

여기서 예외란 프로그램 실행 중에 정상적인 프로그램의 흐름에 어긋나는 이벤트 를 뜻합니다.

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-Catch-Finally문부터 작성하라

try-catch-finally 문에서 try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어갈 수 있습니다.

  • try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 함
  • try-catch-finally 문을 시작으로 코드를 짜면 호출자가 기대하는 상태를 정의하기 쉬워짐

TDD 방식으로 메소드 구현

  1. 단위 테스트 를 만든다.

     @Test(expected = StorageException.class)
     public void retrieveSectionShouldThrowOnInvalidFileName() {
         sectionStore.retrieveSection("invalid - file");
     }
    
  2. 단위 테스트에 맞춰 코드를 구현 한다.

     public List<RecordedGrip> retrieveSection(String sectionName) {
         // 실제로 구현할 때까지 비어 있는 더미를 반환한다.
         return new ArrayList<RecordedGrip>();
     }
    

    예외가 발생하지 않기 때문에 단위 테스트에서 실패 한다.

  3. 파일 접근을 시도하도록 구현한다.

     public List<RecordedGrip> retrieveSection(String sectionName) {
         try {
             FileInputStream stream = new FileInputStream(sectionName);
         } catch (Exception e) {
             throw new StorageException("retrieval error", e);
         }
         return new ArrayList<RecordedGrip>();
     }
    

    테스트가 성공할 것이다.

  4. 리펙터링이 가능해졌다.

     public List<RecordedGrip> retrieveSection(String sectionName) {
         try {
             FileInputStream stream = new FileInputStream(sectionName);
             stream.close();
         } catch (FileNotFoundException e) {
             throw new StorageException("retrieval error", e);
         }
         return new ArrayList<RecordedGrip>();
     }
    

미확인^unchecked 예외를 사용하라

미확인 예외란?

checked 예외 는 컴파일 단계에서 확인되며 반드시 처리해야 하는 예외입니다.

  • IOException
  • SQLException

Unchecked 예외 는 실행 단계에서 확인되며 명시적인 처리를 강제하지는 않는 예외입니다.

  • NullPointerException
  • IllegalArgumentException
  • IndexOutOfBoundException
  • SystemException

미확인 예외의 단점

  • 메서드를 선언할 때 메서드가 반환할 예외를 모두 열거해야 하기 때문에 메서드 유형의 일부 가 됨
  • OCP^Open ^Closed ^Principle 을 위반
    • 확인된 예외는 예상되는 모든 예외를 사전에 처리할 수 있다는 장점이 있지만, 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 더 크다.

    소프트웨어 개발 작업에 이용된 많은 모듈 중에 하나에 수정을 가할 때 그 모듈을 이용하는 다른 모듈을 줄줄이 고쳐야 한다면, 이와 같은 프로그램은 수정하기가 어렵다. 개방-폐쇄 원칙은 시스템의 구조를 올바르게 재조직(리팩토링)하여 나중에 이와 같은 유형의 변경이 더 이상의 수정을 유발하지 않도록 하는 것이다. 개방-폐쇄 원칙이 잘 적용되면, 기능을 추가하거나 변경해야 할 때 이미 제대로 동작하고 있던 원래 코드를 변경하지 않아도, 기존의 코드에 새로운 코드를 추가함으로써 기능의 추가나 변경이 가능하다.

  1. 아래 코드는 단순한 출력을 하는 메소드이다.

     public void printA(bool flag) {
         if(flag)
             System.out.println("called");
     }
    
     public void func(bool flag) {
         printA(flag);
     }
    
  2. 문득 아 프린트를 안할 때 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);
     }
    

해당 함수 뿐만이 아니라 호출하는 함수도 수정을 해줘야 하기 때문에 OCP 를 위반하게 된다.

예외에 의미를 제공하라

  • 오류가 발생한 원인과 위치를 찾기 쉽도록 호출 스택만으로는 부족한 정보를 충분히 덧붙여야 함.
    • 오류 메시지에 정보를 담음
    • 실패한 연산 이름, 실패 유형 언급

호출자를 고려해 예외 클래스를 정의하라

오류를 잡아내는 방법 은 오류를 정의할 때 고려해야 할 중요한 사항입니다.

  1. 아래 코드는 외부 라이브러리를 호출하고 모든 예외를 호출자가 잡아내고 있습니다.

     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 {
         ...
     }
    
  2. 호출 라이브러리 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);
             }
         }
         ...
     }
    

외부 API를 감싸면 아래와 같은 장점이 있다.

  • 에러 처리가 간결해짐
  • 외부 라이브러리와 프로그램 사이의 의존성이 크게 줄어듦
  • 프로그램 테스트가 쉬워짐
  • 외부 API 설계 방식에 의존하지 않아도 됨

정상 흐름을 정의하라

클래스나 객체가 예외적인 상황을 캡슐화해 처리하여 클라이언트 코드가 예외적인 상황을 처리할 필요가 없도록 할 수 있습니다.

아래는 특수 사례 객체를 반환하는 특수 사례 패턴의 예시입니다.

  1. 총계를 계산하는 코드입니다.

     try {
         MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
         m_total += expenses.getTotal();
     } catch(MealExpencesNotFound e) {
         m_total += getMealPerDiem();
     }
    
  2. getTotal 메소드에 예외 시 처리를 넣어 클라이언트 코드를 간결하게 처리합니다.

     public class PerDiemMealExpenses implements MealExpenses {
         public int getTotal() {
             // 기본값으로 일일 기본 식비를 반환한다.
             // (예외가 아닌)
         }
     }
    
     MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
     m_total += expenses.getTotal();
    

null을 반환하지 마라

null을 반환하는 습관은 좋지 않습니다.

  • 호출자에게 null을 체크할 의무를 줌
  • NullPointerException 의 발생 위험이 있음
  • 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);
			}
		}
	}
}

차라리 예외를 던지거나 특수 사례 객체를 반환하는 것이 좋습니다.

// bad
List<Employee> employees = getEmployees();
if(employees != null) {
	for(Employee e : employees) {
		totalPay += e.getPay();
	}
}

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

public List<Employee> getEmployees() {
	if (..직원이 없다면..)
		return Collections.emptyList();
}

null을 전달하지 마라

결론

reference

Clean Code 클린코드 : 애자일 소프트웨어 장인 정신 자바의 예외 처리 java 예외 (Exception) 처리에 대한 작은 생각 | 넥스트리 | NEXTREE