Clean Code

Chapter7 오류 처리

takman 2022. 2. 9. 14:45

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

문제점: 
1. 여기저기 흩어진 오류 처리 코드로 인해 실제 코드가 하는 일을  파악하기가 거의 불가능할 수  있다.
            2. 오류 처리 코드로 인해 프로그램의 논리를 이해하기 어렵다면 깨끗한 코드라 할 수 없다.

이 장에서는 오류를 처리하는 기법과 고려사항을 몇 가지 소개하고자 한다.

해당 포스팅 내용은 책 'Clean Code - Robert C. Martin' 출처로 합니다.

 

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

 

오류 플래그를 설정하거나 호출자에게 오류코드를 반환하는 방법

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);
        DeviceHandle record = retrieveDeviceRecord(handle);
        
        pauseDevice(handle);
        clearDeviceWorkQueue(handle);
        closeDevice(handle);
    }
    
    private DeviceHandle getHandle(DeviceID id){
    ...
    
    	throw new DeviceShutDownError("Invalid handle for:" + id.toString());
    	...
    }
    
    ...
}

장점: 디바이스를 처리하는 알고리즘과 오류를 처리하는 알고리즘을 분리함으로써 호출자 코드가 간단해진다.

 

 

2. Try-Catch-Finally 문부터 작성하라

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

1. try 블록은 트랜잭션과 비슷해서 try 블록에서 무슨 일이 생기든지 catch 블록에서 프로그램 상태를 일관성 있게 유지 가능
(cf. 트랜잭션: 쪼갤 수 없는 업무의 최소 단위)
2. 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬움

 

다음은 파일이 없으면 예외를 던지는지 알아보는 단위 테스트다.

1. 단위 테스트 만들기
@Test(expected = StorageException.class)
public void retrieveSectionShouldThrowOnInvalidFileName() {
	sectionStore.retriveSection("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>();
}

 (catch 블록에서 예외 유형을 좁혀 FileNotFoundException을 잡아냄)

 

위와 같은 방식은 try-catch 구조로 범위를 정의하고, TDD를 사용해 필요한 나머지 논리를 추가하는 방식이다.

(강제로 예외를 일으키는 테스트 케이스를 작성한 후, 테스트를 통과하게 코드를 작성하는 방법)

 

->try 블록의 트랜잭션 범위부터 구현하게 되므로 범위 내에서 트랜잭션 본질을 유지하기 쉬워짐 

 

(cf. TDD(테스트 주도 개발) : 반복 테스트를 이용한 소프트웨어 방법론으로, 작은 단위 테스트 케이스를 작성하고 이를 통과하는 코드를 추가하는 단계를 반복하여 구현하는 방법)

 

 

3. 미확인 예외(Unchecked Exception)를 사용하라

지금은 안정적인 소프트웨어를 제작하는 요소로 확인된 예외(Checked Exception)가 반드시 필요하지 않다.

먼저 확인된 예외와 미확인 예외의 차이는 아래 표로 정리하였다.

 

  Checked Exception Unchecked Exception
확인시점 컴파일 런타임
처리 여부 반드시 처리 명시적인 처리를 강제하지 X
트랜잭션 처리  roll-back 하지 X  roll-back 
예시 IOException, ClassNotFoundException NullPointerException, ArithmeticException

(cf. rollback: 업데이트에서 오류가 발생했을 때, 이전 상태로 되돌리는 것(오류 발생 전으로 되돌리는것))

 

대규모 시스템에서 최하위 함수를 변경해 새로운 오류를 던진다고 가정하자

확인된 오류를 던진다면 함수는 함수는 선언부에 throws 절을 추가해야 한다.

이로 인해 변경한 함수를 호출하는 모든 함수가 1) catch 블록에서 새로운 예외를 처리하거나 2) 선언부에 throw 절을 추가해야 한다.

-> 최하위 단계에서 최상위 단계까지 연쇄적인 수정이 일어난다.

 

확인된 예외의 단점:

1. 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다. 

2. throws 경로에 위치하는 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨진다.

 

확인된 예외의 장점:

아주 중요한 라이브러리를 작성할 때 모든 예외를 잡아야 한다.

그래서 우리는 확인된 오류를 치르는 비용에 상응하는 이익을 제공하는지 따져봐야 한다.
확인된 예외는 OCP 원칙을 위반하기 때문이다.
하지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크다.

 

 

4. 예외에 의미를 제공하라

예외를 던질 때 전후 상황을 충분히 덧붙여야 한다. 그러면 오류가 발생한 원인과 위치를 찾기 쉬워짐

 

자바는 모든 예외에 호출 스택을 제공하지만 실패한 코드의 의도를 파악하기엔 부족

해결: 오류 메시지에 정보(실패한 연산 이름, 실패 유형 등)를 담아 예외와 함께 던진다.

 

 

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

오류를 분류하는 방법은 많다.

(오류가 발생한 위치, 오류가 발생한 컴포넌트, 오류가 발생한 유형)

하지만 프로그래머에거 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.

 

잘못된 경우) 형편없이 오류를 분류한 케이스(외부 라이브러리가 던질 예외를 호출자가 모두 잡아냄)

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를 감싸서 예외 유형 하나를 반환

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

위의 코드는 LocalPort 클래스가 ACMEPort 클래스가 던지는 예외를 잡아 변환하는 Wrapper클래스이다.

 

Wrapper클래스 기법의 장점)

 

1. 외부 API를 감싸기 때문에 외부 라이브러리와 프로그램 사이에서 의존성이 크게 줄어듬

2. 다른 라이브러리로 갈아타도 비용이 적음

3. 외부 API를 호출하는 대신 테스트 코드를 넣어주는 방법으로 프로그램을 테스트하기 쉬워짐

4. 외부 API 설계 방식에 의존하지 않아도 됨

 

6. 정상 흐름을 정의해라

중단이 적합하지 않은 때도 있다. 이때, "특수 사례 패턴"으로 클래스를 만들거나 객체를 조작해 특수사례를 처리한다.

->  클라이언트 코드가 예외적인 상황을 처리할 필요X

 

총계를 계산하는 코드

try {
     MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
     m_total += expenses.getTotal();
 } catch(MealExpencesNotFound e) {
     m_total += getMealPerDiem();
 }

 클래스나 객체가 예외적인 상황을 캡슐화 해서 처리한다.

MealExpenses expenses = expenseReportDAO.getMeals(employee.getID());
	m_total += expenses.getTotal();

ExpenseReportDAO를 고쳐 언제나 MealExpense 객체를 반환하게 한다.
청구한 식비가 없다면 일일 기본 식비를 반환하는 MealExpense 객체를 반환한다.

(getTotal() 메소드에 예외 시 처리를 넣어 클라이언트 코드를 간결하게 처리)

public class PerDiemMealExpenses implements MealExpenses {
	public int getTotal() {
		// 기본값으로 일일 기본 식비를 반환한다.
	}
}

 

7. null을 반환하지 마라

null을 반환하고 이를 if(object != null)로 확인하는 습관은 나쁘다.

 

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을 전달했을 때 문제)

  • 호출자에게 null을 체크할 의무를 떠넘김
  • NullPointerException 의 발생 위험이 있음
  • null확인이 너무 많아짐

 

해결)

  • null 대신 예외를 던지거나 특수 사례 객체를 반환하라
  • 사용하려는 외부 API가 null을 반환한다면 Wrapper 를 구현해 예외를 던지거나 특수 사례 객체를 반환하라

 

문제) null을 전달하는 코드

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

 

해결 1) getEmployees를 변경해 null대신 빈 리스트를 반환

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

 

해결2) 특수 사례 객체(자바의 Collections.emptyList()) 사용

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

 

 

8. null을 전달하지 마라

메서드에서 null을 반환하는 방식도 나쁘지만 null을 전달하는 방식은 더 나쁘다.

인수로 null을 전달하는 것을 해결하기 위한 대안이 2가지가 있다.

  • 예외처리
  • assert 문을 사용

하지만 애초에 null을 전달하는 경우는 금지하는 것이 바람직하다.

왜냐하면 대부분의 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리할 방법 X

 

결론: 오류처리를 프로그램 논리와 분리시키야 코드 유지보수성이 높아지고 결국 깨끗한 코드를 만들 수 있다.