좋은 코드는 오류나 예외에 대한 대응 메커니즘을 가지고 있어야 한다.
문제가 생기지 않는 코드는 없다. 하지만 문제가 생겼을 때 바로 잡을 책임은 프로그래머에게 있다.
오류 처리는 중요하다. 하지만 이로 인해 프로그램의 논리를 이해하기 어려워진다면, 그것은 깨끗한 코드라 부르기 어렵다.
이 장에서는 깨끗하면서 튼튼한 코드를 만드는 방법에 대해 알아본다.
오류 코드보다 예외를 사용하라
예전에는 예외를 지원하지 않는 프로그래밍 언어가 많았다. 그렇기에 오류 코드를 반환하거나 오류 플래그를 설정하는 방법이 전부였다.
if (A예외 발생) {
return A_예외_발생;
if (B예외 발생) {
return B_예외_발생;
}
...
}
문제가 무엇일까? depth가 늘어난다. 이 말인 즉슨 이해하기 어려운 코드가 된다. 구조가 굉장해 복잡해진다는 의미이다.
그래서 예외를 던지는 방법을 사용해야 한다. 아래는 위 코드를 예외를 던지는 방법으로 바꾼 예시이다.
public void sendDoSomething {
try {
tryDoSomething();
} catch (Exception e) {
Sytem.out.println(e.getMessage());
}
}
private void tryDoSomething() {
// 예외가 발생할 수 있는 작업 수행
}
또한 실제 비즈니스 로직과 오류를 처리하는 로직을 분리함으로써 코드 품질이 더 좋아졌다.
Try-Catch-Finally 문부터 작성하라
try 블록은 트랜잭션과 비슷하다. try 블록에서 무슨 일이 생기든지 catch 블록은 프로그램 상태를 일관성 있게 유지해야 한다. 그러므로 예외가 발생할 코드를 짤 때는 try-catch-finally 문으로 작성하자.
미확인(unchecked) 예외를 사용하라
확인된(checked) 예외의 장단점을 두고 여러 해 동안 자바 프로그래머들은 논쟁을 벌여왔다.
이전에는 확인된 예외가 멋진 아이디어로 여겨졌다. 메서드를 선어할 때는 메서드가 반환할 예외를 모두 열거했다. 다음과 같이.
public void doSomeThing() throws WhateverException, HelloWorldException ... {
...
}
하지만 지금은 상황이 반대다. C++, C#은 아예 확인된 예외를 지원하지 않는다. 파이썬이나 루비도 마찬가지이다. 그럼에도 좋은 소프트웨어를 만드는 데 무리가 없다.
확인된 예외를 사용하면 비용이 따른다. OCP를 위반하기 때문이다.
메서드에서 확인된 예외를 던졌는데 catch 블록이 세 단계 위에 있다면 그 사이 메서드 모두가 선언부에 해당 예외를 정의해야 한다. 즉, 하위 단계에서 코드를 변경하면 상위 단계 메서드 선언부를 전부 고쳐야 한다는 의미이다.
모듈과 관련된 코드는 바뀌지 않았음에도 다시 빌드를 해 배포해야 한다는 말과 같다.
public void doSomething() throws WhateverException {
doRoutine1();
doRoutine2();
}
private void doRoutine1() throws WhateverException {
...
}
private void doRoutine2() throws WhateverException {
...
}
위 예시에서 doRoutine2에서 발생할 수 있는 예외가 추가된다면 어떨까? 'HelloWorldException'이 추가된다고 해보자.
public void doSomething() throws WhateverException, HelloWorldException {
doRoutine1();
doRoutine2();
}
private void doRoutine1() throws WhateverException {
...
}
private void doRoutine2() throws WhateverException, HelloWorldException {
...
}
위처럼 doRoutine2을 호출하는 doSomething 메소드도 변경되어야 한다.
오류가 원거리에서 처리되어야 한다면, 확인된 예외는 이처럼 캡슐화를 망가뜨리기 쉽다.
아주 중요한 라이브러리라면, 모든 예외를 잡아야 한다. 하지만 일반적인 애플리케이션은 의존성이라는 비용이 이익보다 크다.
예외에 의미를 제공하라
예외를 던질 때는 전후 상황을 충분히 덧붙인다.
자바는 기본적으로 모든 예외에 호출 스택 정보를 제공하지만, 오류 메세지에 정보를 담아 함께 던져라. 실패한 연산 이름과 실패 유형 이름도 언급하면 좋다. 충분한 정보를 전달해라.
호출자를 고려해 예외 클래스를 정의하라
오류 분류 방법은 수없이 많다. 예를 들어 디바이스 실패, 네트워크 실패, 프로그래밍 오류 등으로 분류할 수 있다.
그러나 오류를 정의할 때 프로그래머에게 가장 중요한 관심사는 오류를 잡아내는 방법이 되어야 한다.
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를 감싸면서 예외 유형 하나를 반환하면 좋다.
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);
...
}
위처럼 감싸면 아래와 같이 본 코드가 간단해지고 외부 라이브러리와의 직접적인 의존성이 줄어든다.
LocalPort port = new LocalPort(12);
try {
port.open();
catch (PortDeviceFailure e) {
reportError(e);
logger.log(e.getMessage(), e);
} finally {
...
}
정상 흐름을 정의하라
앞에서 충고한 지침을 충실히 따른다면 비즈니스 논리와 오류 처리가 잘 분리된 코드가 나온다.
하지만 그러다 보면 오류 감지가 프로그램 언저리로 밀려난다. 외부 API를 감싸 독자적인 예외를 던지고, 코드 위에 처리기를 정의해 중단된 계산을 처리한다. 하지만 때로는 중단이 적합하지 않은 때도 있다.
클래스를 만들거나 객체를 조작해 예외 케이스를 처리하게 하면, 클라이언트 코드가 예외 케이스를 처리할 필요가 사라진다. 클래스나 객체가 예외 상황을 캡슐화해서 처리하므로.
예외 케이스를 정의하는 것에 매몰된 나머지, 정상 흐름이 중요하다는 것을 잊지 마라.
null을 반환하지 마라
null을 반환하는 코드는 일거리를 늘릴 뿐만 아니라 호출자에게 문제를 떠넘긴다.
또한 누구 하나라도 null 확인을 빼먹는다면 애플리케이션이 통제 불능에 빠질지도 모른다.
null을 반환하고픈 유혹이 든다면 그 대신 예외를 던지거나 특수 사례 객체를 반환한다.
사용하려는 외부 API가 null을 반환한다면 감싸기 메서드를 구현해 예외를 던지거나 특수 사례 객체를 반환하는 방식을 고려한다.
많은 경우 특수 사례 객체가 손쉬운 해결책이다.
null을 전달하지 마라
메서드에 null을 반환하는 방식도 나쁘지만, 메서드로 null을 전달하는 방식은 더 나쁘다.
다음과 같은 코드에서 인자에 null이 들어가면 어떨까?
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
return (p2.x - p1.x) * 1.5;
}
...
}
calculator.xProjection(null, new Point(1, 2)); // 이렇게 넘긴다면?
NPE가 발생한다. 하지만 이를 처리하기 위해 메소드 내에 null 체크문을 넣을것인가?
public class MetricsCalculator {
public double xProjection(Point p1, Point p2) {
if (p1 == null || p2 == null) {
throw new InvalidArgumentException();
}
return (p2.x - p1.x) * 1.5;
}
...
}
이는 추가비용이 너무 크다. 따라서 이는 개발자가 맡아야 할 부분이다. null을 인수로 넣지 마라. 대다수 프로그래밍 언어는 호출자가 실수로 넘기는 null을 적절히 처리하는 방법이 없다. 즉, 인수로 null이 넘어오면 코드에 문제가 있음을 의심해라.
'클린 코드' 카테고리의 다른 글
[클린 코드] 9장: 단위 테스트 (0) | 2022.12.04 |
---|---|
[클린 코드] 8장: 경계 (0) | 2022.12.03 |
[클린 코드] 6장: 객체와 자료 구조 (0) | 2022.11.29 |
[클린 코드] 5장: 형식 맞추기 (0) | 2022.11.29 |
[클린 코드] 4장: 주석 (0) | 2022.11.28 |