[Clean Code] 7. 오류 처리

Dayeon myeong·2021년 2월 18일
0

Clean Code

목록 보기
5/5

이 글은 Clean code 책 내용을 정리한 글입니다.

저작권 관련 문제가 있다면 "meme91322367@gmail.com"으로 메일 보내주시면, 바로 삭제하도록 하겠습니다.

7장 오류 처리

상당수 코드 기반은 전적으로 오류 처리 코드에 좌우되기 때문에 깨끗한 코드와 연관성이 있습니다.
여기저기 흩어진 오류 처리 코드 때문에 실제 코드가 하는 일을 파악하기가 어려워 질 수 있습니다.
오류 처리 코드로 인해 프로그램 논리를 이해하기 어려워진다면 깨끗한 코드라 부르기 어렵습니다.
이 장은 깨끗하고 튼튼한 코드에 한걸음 더 다가가는 단계로 우아하고 고상하게 오류를 처리하는 기법과 고려 사항 몇가지를 소개합니다.

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

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

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

try-catch-finally 문에서 try 블록에 들어가는 코드를 실행하면 어느 시점에서든 실행이 중단된 후 catch 블록으로 넘어갈 수 있습니다.
try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 합니다.
try-catch-finally 문을 시작으로 코드를 짜면 호출자가 기대하는 상태를 정의하기 쉬워집니다.

  • 단위 테스트 를 만든다.
 @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);
     } catch (Exception e) {
         throw new StorageException("retrieval error", e);
     }
     return new ArrayList<RecordedGrip>();
 }

테스트가 성공할 것입니다.

catch 블록에서 예외 유형을 좁혀 리펙터링을 해봅시다.

 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) 예외를 사용하라

메서드에서 확인된 예외를 던졌는데 catch 블록이 세 단계 위에 있다면 그 사이 메서드 모두가 선언부에 해당 예외를 정의해야 합니다. 즉, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 뜻입니다.

메서드를 선언할 때 메서드가 반환할 예외를 모두 열거해야 하기 때문에 메서드 유형의 일부 가 되므로 OCP(Open Closed Principl) 을 위반합니다.

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

OCP 내용 출처

대규모 시스템에서 호출이 일어나는 방식을 상상해봅시다. 최상위 함수가 아래 함수를 호출하고 아래 함수는 그 아래 함수를 호출합니다. 단계를 내려갈수록 호출하는 함수 수는 늘어납니다. 최하위 함수를 변경해 새로운 오류를 던진다고 가정한다면, 변경한 함수를 호출하는 함수 모두가 새로운 예외를 처리해야 하는 연쇄적인 수정이 일어납니다.

모든 함수가 최하위 함수에서 던지는 예외를 알아야 하므로 캡슐화가 깨집니다.
그래서 무조건 확인된 예외 뿐 아니라 미확인 예외도 확인해야 합니다.

캡슐화 : 외부로부터 데이터를 보호하여 내부적으로 사용하는 부분을 감춰야 한다. 외부는 알면 안된다.

  1. 아래 코드는 단순한 출력을 하는 메소드
 public void printA(bool flag) {
     if(flag)
         System.out.println("called");
 }

 public void func(bool flag) {
     printA(flag);
 }
  1. notPrintException을 던지기로 구현 변경했을 때 해당 함수 뿐 아니라 호출하는 함수도 수정해줘야 하기 때문에 ocp를 위반하게 된다.
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);
 }

코드 출처

예외에 의미를 제공하라

오류가 발생한 원인과 위치를 찾기 쉽도록 호출 스택만으로는 부족한 정보를 충분히 덧붙여야 합니다.오류 메시지에 정보를 담아 던집니다.

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

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

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

 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 클래스입니다.
이렇듯 외부 API를 감싸면 아래와 같은 장점이 있습니다.

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

정상 흐름을 정의하라

외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중된된 계산을 처리하는 것은 대게 멋진 처리 방식이만 때로는 중단이 적합하지 않은 때도 있습니다.

바로 클래스나 객체가 예외적인 상황을 캡슐화해 처리하여 클라이언트 코드가 예외적인 상황을 처리할 필요가 없도록 하는 것입니다. 이러한 것을 특수 사례 패턴이라 합니다.

특수 사례 패턴 : 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식

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

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

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

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
// getEmployees가 null을 반환할 수 있다. 하지만 null을 반환할 필요가 있을까?
List<Employee> employees = getEmployees();
if(employees != null) {
	for(Employee e : employees) {
		totalPay += e.getPay();
	}
}
// good
// getEmployees를 변경해 빈 리스트를 반환한다면 코드가 훨씬 깔끔해질 것이다.
//자바에는 collections.emptyList가 있어 미리 정의된 읽기 전용 리스트를 반환한다. 우리 목적에 적합한 리스트다.
List<Employee> employees = getEmployees();
for(Employee e : employees) {
	totalPay += e.getPay();
}

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

null을 전달하지 마라

메서드로 null을 전달하는 방식은 나쁩니다.

// Bad
// calculator.xProjection(null, new Point(12, 13));
// 위와 같이 부를 경우 NullPointerException 발생
public class MetricsCalculator {
  public double xProjection(Point p1, Point p2) {
    return (p2.x – p1.x) * 1.5;
  }
  ...
}

nullPointerException 발생을 해결하기 위해 아래와 같이 새로운 예외 유형을 만들어 던지는 방법이 있습니다. 하지만 아래 코드는 InvalidArgumentException을 잡아내는 처리기가 필요합니다.

// Bad
// NullPointerException은 안나지만 윗단계에서 InvalidArgumentException이 발생할 경우 처리해줘야 함.
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;
  }
}

assert 문을 사용하는 방식도 있습니다. 하지만 그냥 null을 넘기지 못하도록 금지하는 정책이 합리적입니다.

// Bad
// 좋은 명세이지만 첫번째 예시와 같이 NullPointerException 문제를 해결하지 못한다.
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;
  }
}

결론

깨끗한 코드는 읽기도 좋아야 하지만 안정성도 높아야 합니다. 오류 처리를 프로그램의 논리와 분리해 독자적인 사안으로 고려하면 튼튼하고 깨끗한 코드를 작성할 수 있습니다. 오류 처리를 프로그램 논리와 분리하면 독립적인 추론이 가능해지며 코드 유지보수성도 크게 높아집니다.

profile
부족함을 당당히 마주하는 용기

0개의 댓글