[클린코드] 7. 오류 처리
깨끗한 코드와 오류 처리는 확실한 연관성이 있으므로 오류 처리는 상당히 중요합니다.
하지만 오류 처리 코드로 인해 프로그램 논리를 이해하지 어려워진다면 깨끗한 코드라 부르기 어렵습니다.
이 장에서는 오류를 처리하는 기법과 고려 사항 몇 가지를 소개합니다.
오류 코드보다 예외를 사용하라
예외를 지원하지 않는 프로그래밍 언어에서는 오류 플래그를 설정하거나 호출자에게 오류 코드를 반환하는 방법이 전부였기 때문에 호출자의 코드가 복잡해지고 이 단계를 잊어버리기 쉽습니다.
예외 처리를 사용한다면 오류 프로그램 논리와 오류 처리 코드가 섞이지 않아 코드가 더 깔끔해집니다.
// 예외 처리 미사용
public void orderProduct(long productId, int orderCount) {
Product product = getProduct(productId);
if (product != null) {
boolean isOrderPossible = checkProductInventory(product, orderCount);
if (isOrderPossible) {
orderRepository.save(Order.create(productId, orderCount));
} else {
logger.log("상품 주문불가 : 상품 재고가 부족합니다.");
}
} else {
logger.log("상품 주문불가 : 존재하지 않는 상품입니다.");
}
}
// 예외 처리 사용
public Product getProduct(long productId) throws OrderProductImpossibleException {
...
throw new OrderProductImpossibleException("존재하지 않는 상품입니다.");
}
public void checkProductInventory(Product product, int orderCount) throws OrderProductImpossibleException {
...
throw new OrderProductImpossibleException("상품 재고가 부족합니다.");
}
public void orderProduct(long productId, int orderCount) {
try {
Product product = getProduct(productId);
checkProductInventory(product, orderCount);
} catch(OrderProductImpossibleException e) {}
logger.log("상품 주문불가 : " + e.getMessage());
}
}
오류 Try-Catch-Finally 문부터 작성하라
try 블록은 트랜잭션과 비슷하기 때문에 try 블록에서 어떤 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 합니다.
try 블록에서 무슨 일이 생기든지 호출자가 기대하는 상태를 정의하기 쉬워지기 때문에 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 시작하는 편이 낫습니다.
미확인 예외를 사용하라
자바 첫 버전이 공개되었을 때 확인된 예외는 멋진 아이디어로 여겨지곤 했습니다.
메서드를 선언할 때 반환할 예외를 모두 선언해야 하고 만약 모두 선언하지 않았을 때는 아예 컴파일이 불가했습니다.
하지만 확인된 오류가 치르는 비용에 상응하는 이익을 제공하는지 따져봐야 합니다.
만약 메서드 안에 다른 메서드를 사용하고 그 안에서 또 다른 메서드를 사용한다고 하였을 때 맨 위에 정의된 메서드는 하위 메서드의 예외를 모두 열거해주어야 합니다.
간단한 시스템에서는 안정적이겠지만 대규모에 경우 최상위 메서드에서 최하위 메서드에 열거된 예외를 모두 알아야 하므로 캡슐화가 깨지는 심각한 단점이 발생하게 됩니다.
또한 최하위 메서드의 예외가 수정이 되었을 때 최상위 메서드까지 수정해야 하므로 엄청난 부담으로 다가옵니다.
때로는 확인된 예외도 유용하지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 큽니다.
예외에 의미를 제공하라
예외를 던질 때는 전후 상황을 충분히 덧붙여야 오류가 발생한 원인과 위치를 찾기 쉬워집니다.
오류 메시지에 정보를 담아 예외와 함께 던져 로깅해야 합니다.
호출자를 고려해 예외 클래스를 정의하라
오류를 분류하는 방법은 수없이 많습니다.
애플리케이션에서 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법입니다.
아래 코드는 아주 형편없는 예외 처리의 예제입니다.
try {
orderProduct(productId, orderCount);
} catch (NotExistProductException e) {
throw new OrderProductImpossibleException("상품이 존재하지 않습니다.");
} catch (ImpossibleOrderProductStatusException e) {
throw new OrderProductImpossibleException("상품이 주문이 불가한 상태입니다.");
} catch (NotEnoughProductInventoryException e) {
throw new OrderProductImpossibleException("상품 재고가 부족합니다.");
} catch (...) { ... }
위 코드는 중복이 심하지만 예외에 대응하는 방식이 예외 유형과 무관하게 거의 동일하기 때문에 코드를 간결하게 고치기 아주 쉽습니다.
public class OrderProductDomain {
private final long productId;
private final int orderCount;
public OrderProductDomain(long productId, int orderCount) {
this.productId = productId;
this.orderCount = orderCount;
}
public void order() {
try {
orderProduct(productId, orderCount);
} catch (NotExistProductException e) {
throw new OrderProductImpossibleException("상품이 존재하지 않습니다.");
} catch (ImpossibleOrderProductStatusException e) {
throw new OrderProductImpossibleException("상품이 주문이 불가한 상태입니다.");
} catch (NotEnoughProductInventoryException e) {
throw new OrderProductImpossibleException("상품 재고가 부족합니다.");
} catch (...) { ... }
}
}
OrderProductDomain orderProductDomain = new OrderProductDomain(productId, orderCount);
try {
orderProductDomain.order();
} catch (OrderProductImpossibleException e) {
logger.log("상품 주문불가 : " + e.getMessage());
}
OrderProductDomain처럼 감싸는 클래스는 외부 API를 사용할 때 매우 유용합니다.
외부 라이브러리와 프로그램 사이에 의존성이 크게 줄어들고 테스트하기도 쉬워질 뿐만 아니라 외부 라이브러리가 바뀌더라도 쉽게 유지보수가 가능합니다.
예외에 대한 처리가 동일하다면 하나의 예외 클래스로 유형을 정의해 사용해도 괜찮은 경우가 많습니다.
하지만 어느 예외는 잡아내야 하고 어느 예외는 무시해도 괜찮은 경우라면 여러 예외 클래스를 사용합니다.
정상 흐름을 정의하라
코드를 작성하다 보면 언제부터인지 오류 감지가 프로그램 언저리로 밀려날 때가 있습니다.
예외를 처리하여 정상 흐름을 가져간다면 로직의 논리를 따라가기 어렵습니다.
아래 코드는 주문에 대한 총 결제금액을 구하는 예제입니다.
BigDecimal totalPayPrice = BigDecimal.ZERO;
try {
Order order = getOrder(orderId);
totalPayPrice = totalPayPrice.add(order.getPayPrice());
} catch (NotPaidOrderException e) {
totalPayPrice = totalPayPrice.add(BigDecimal.ZERO);
}
아직 결제가 되지 않은 주문 건에 대해서는 getPayPrice()를 호출할 때 NotPaidOrderException이 발생한다고 하겠습니다.
위 예제에서 특수한 상황에 대한 처리를 하지 않는다면 더욱 깔끔한 코드를 작성할 수 있습니다.
public class NotPaidOrder implements Order {
public BigDecimal getPayPirce() {
return BigDecimal.ZERO;
}
}
public class PaidOrder implements Order {
public BigDecimal getPayPirce() {
return super.getPayPrice();
}
}
BigDecimal totalPayPrice = BigDecimal.ZERO;
Order order = getOrder(orderId);
totalPayPrice = totalPayPrice.add(order.getPayPrice());
getOrder 메서드에서 결제한 주문에 경우 PaidOrder를 반환하고 결제가 아직 진행되지 않은 주문에 경우 NotPaidOrder를 반환하면 클라이언트 코드가 예외 처리할 필요가 없어집니다.
해당 방식은 클래스를 만들거나 객체를 조작해 특수 사례를 처리하는 방식으로, 특수 사례 패턴(Special Case Pattern)이라고 부릅니다.
null을 반환하지 마라
우리가 가장 흔하게 발생하는 예외 중 하나가 NPE(NullPointerException)입니다.
메서드에서 null을 반환하는 실수를 많이 하기 때문에 이를 처리하기 위해서는 상당히 많은 if 문이 필요하게 됩니다.
null을 반환하는 코드는 하나라도 null 처리를 빼먹는다면 애플리케이션이 통제 불능으로 빠질지도 모릅니다.
메서드에서 null을 반환하고픈 유혹이 든다면 예외를 던지거나 위 예제처럼 특수 사례 객체를 반환하면 코드가 깔끔해질뿐더러 NPE가 발생할 가능성도 줄어듭니다.
null을 전달하지 마라
메서드에서 null을 반환하는 방식도 나쁘지만 메서드에 null을 전달하는 방식은 더 나쁩니다.
필수적이지 않은 파라미터를 기대하는 API가 아니라면 메서드로 null을 전달하는 코드는 최대한 피해야 합니다.
public class Calculator {
public BigDecimal plus(BigDecimal number1, BigDecimal number2) {
BigDecimal result = number1.add(number2);
return result;
}
}
plus(null, BigDecimal.ONE);
위 계산기에 덧셈 예제에서 매개변수로 null을 전달하게 된다면 당연하게 NPE가 발생할 것입니다.
이때 내부에서 예외 처리를 하게 되면 호출하는 코드에서 null로 전달되는 부분(오류적인)을 발견할 수 없게 됩니다.
또한 assert문을 사용하여 가독성을 높일 순 있지만 호출하는 코드에서 예외 처리를 해야 하므로 완벽히 문제를 해결할 순 없습니다.
public class Calculator {
public BigDecimal plus(BigDecimal number1, BigDecimal number2) {
if (number1 == null) number1 = BigDecial.ZERO;
if (number2 == null) number2 = BigDecial.ZERO;
BigDecimal result = number1.add(number2);
return result;
}
}
public class Calculator {
public BigDecimal plus(BigDecimal number1, BigDecimal number2) {
assert number1 == null : "number1 should not be null";
assert number2 == null : "number2 should not be null";
BigDecimal result = number1.add(number2);
return result;
}
}
대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법은 없습니다.
하지만 애초에 null이 전달된다면 코드에 문제가 있다는 정책으로 새워 따른다면 그만큼 부주의한 실수를 저지를 확률도 작아지게 될 것입니다.
결론
깨끗한 코드는 읽기도 좋지만 안정성도 높아야 합니다. 이 둘은 상출하는 목표가 아닙니다.
오류 처리를 프로그램 논리와 분리해 독자적인 사안으로 고려한다면 튼튼하고 깨끗한 코드를 작성할 수 있고 코드 유지보수성도 크게 높아질 것입니다.