2023년 4월 19일 작성

Clean Code - 오류를 잘 처리하는 법

올바른 오류 처리를 통해 code의 안정성 뿐만 아니라 가독성도 함께 높일 수 있습니다.

튼튼하고 깨끗한 Code

  • code는 읽기 쉬울 뿐만 아니라, 안정성도 높아야 합니다.
    • 가독성과 안정성은 상충하는 목표가 아닙니다.
  • 오류와 논리의 독립적인 추론이 가능하게 code를 작성해야 합니다.
    • 따라서 오류 처리를 program 논리와 분리해야 합니다.

Error Code보다 Exception을 사용하기

  • 논리와 오류 처리 code가 섞이지 않도록 해야 합니다.
  • exception은 오류를 논리 code와 떨어진 곳에서 처리하기 위해 사용합니다.

Error Code 사용

  • 함수를 호출한 즉시 오류를 확인해야 하기 때문에 호출자 code가 복잡해집니다.
public class DeviceController {

    ...

    public void sendShutDown() {
        DeviceHandle handle = getHandle(DEV1);

        // 디바이스 상태를 점검한다.
        if (handle != DeviceHandle.INVALID) {
            // 레코드 필드에 디바이스 상태를 저장한다.
            retrieveDeviceRecord(handle);

            // 디바이스가 일시정지 상태가 아니라면 종료한다.
            if (record.getStatus() != DEVICE_SUSPENDED) {
                closeDevice(handle);
            } else {
                logger.log("Device suspended. Unable to shut down");
            }
        } else {
            logger.log("Invalid handle");
        }
    }

    ...

}

Exception 사용

  • 논리를 처리하는 부분과 오류를 처리하는 부분을 독립적으로 읽고 이해할 수 있습니다.
public class DeviceController {

    ...

    public void sendShutDown() {
        try {
            tryToShutDown();
        }
        catch (DeviceShutDownError e) {
            logger.log(e);
        }
    }

    private void tryToShutDown() {
        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 문부터 작성하기

  • 예외가 발생할 code를 짤 때는, try-catch-finally 문으로 시작하여 범위를 정의합니다.
    • try block에서 무슨 일이 생기든, catch block은 program 상태를 일관성 있게 유지해야 합니다.
      • transaction과 비슷합니다.
void pay() {
    try {
        approval();
        updateLedgerFinish();
        sendFinishTalk();

    } catch (ApprovalFailException e) {

    } catch (UpdateFailException e) {
        /* 승인난 결제 취소하기 */

    } catch (SendFailException) {
        /* 승인난 결제 취소하기 */
        /* 완료 갱신한 원장 되돌리기 */

    } finally {
        /* 관제 메세지 보내기 */
    }
}

void approavl() throws ApprovalFailException {
    try {
        /* 승인 요청 보내기 */
    } catch (Exception e) {
        throw new ApprovalFailException();
    }
}

void updateLedgerFinish() throws UpdateFailException {
    try {
        /* 원장을 완료 처리하기 */
    } catch (Exception e) {
        throw new UpdateFailException();
    }
}

void sendFinishTalk() throws SendFailException {
    try {
        /* 결제 완료 알림 보내기 */
    } catch (Exception e) {
        throw new SendFailException();
    }
}

Unchecked Exception을 사용하기

  • checked exception은 개방-폐쇄 원칙(OCP, Open-Closed Principle)을 위반합니다.
    • 모든 함수가 최하위 함수에서 던지는 예외를 알아야 하기 때문에 캡슐화(encapsulation)가 깨집니다.
      • method에서 checked exception를 던지고 catch block이 상위 단계에 있다면, 그 사이의 모든 method가 해당 exception을 정의해야 합니다.
      • 하위 단계에서 code를 수정하면, 상위 단계 method 선언부를 전부 고쳐야 합니다.
        • module과 관련된 code가 바뀌지 않았더라도, 선언부가 바뀌었으므로 module을 다시 build하고 배포해야 합니다.

Checked Exception & Unchecked Exception

Checked Exception Unchecked Exception
확인된 예외 미확인된 예외
compile 단계에서 확인하는 예외입니다. 실행 단계에서 확인하는 예외입니다.
반드시 예외 처리(try-catch or throw)를 해줘야 합니다. 예외 처리를 강제하지 않습니다.
FileNotFoundException, ClassNotFoundException, IOException, SQLException, … RuntimeException, NullPointerException, IllegalArgumentException, ArrayIndexOutOfBoundsException, …

예외에 의미를 제공하기

  • 예외를 던지는 전후 상황이 충분히 설명되어야 합니다.
    • catch block에서 오류 정보, 실패한 연산 이름, 실패 유형 등을 log로 기록합니다.
      • 오류 정보는 exception의 message나 code에 넣을 수 있습니다.
  • 예외에 의미를 주면 오류가 발생한 원인과 위치를 찾기 쉬워집니다.
    • stacktrace만으로 원인을 찾을 수도 있지만, 보기 힘들고, 찾는 데에 오래 걸립니다.

호출자를 고려해 Exception Class를 정의하기

  • exception class를 만들 때, 호출자가 어떤 방식으로 예외를 잡을지 고려해야 합니다.
    • 한 예외는 잡아내고 다른 예외는 무시해도 괜찮은 경우라면, 예외 class를 여러 개 사용할 수도 있습니다.
  • 발생할 수 있는 예외 case를 묶으면, 예외 유형에 대한 관리가 쉬워집니다.
    • 예외를 묶을 때는 wrapper class를 활용합니다.
  • 외부 library를 사용할 때, 외부 class를 wrapper class로 감싸서 사용하는 것이 좋습니다.
    • 외부 library와 program 사이의 의존성이 낮아집니다.
Good Bad
외부 library를 사용하는 class를 wrapper class로 한 번 감싼 뒤 이 class에 대한 예외를 처리하기 외부 library가 던질 모든 예외를 catch로 구분하여 예외를 처리하기

Example : ACMEPort class(외부 API class)를 사용하는 상황

Good Code

  • ACME class를 LocalPort class로 wrapping해 new ACMEPort().open() method에서 던질 수 있는 exception들을 간략화합니다.
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);
    }
    }
    ...
}

Bad Code

  • catch 문의 내용이 거의 같습니다.
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 {
    ...
}

Null을 반환/전달하지 않기

  • null을 반환하면, 호출자에게 null check에 대한 문제를 떠넘기는 것입니다.
    • 호출자는 null check logic을 작성해야만 합니다. 많아져야 함
  • null 확인 누락의 문제가 많이 발생한다면, 먼저 null 확인이 너무 많지는 않은지 봐야 합니다.

  • null이면 안 되는 경우에 null이 사용되는 경우는 오류 상황이므로, 예외 처리합니다.
    • Java에서는 NullPointerException을 잡아서 던지거나 처리합니다.
  • 정상적인 인수로 null을 기대하는 API라면, null의 반환/전달을 필요에 의해 사용할 수도 있습니다.
    • 반환하는 쪽과 호출하는 쪽 모두 사전에 null의 사용을 약속하고, 지속적으로 관리해야 합니다.
    • 그러나 null 사용은 최대한 피하는 것이 좋기 때문에, 특수 사례 객체(special case object)를 사용을 권장합니다.

Null 대신 Empty List

Collections.emptyList();    // []

Good Code

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

public List<Employee> getEmployees() {
    if ( .. there are no employees .. ) {
        return Collections.emptyList();
    }
}

Bad Code

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

Null 대신 Optional 객체

  • Java8 이상을 사용한다면, Optional객체를 사용합니다.
Null 처리를 위한 Optional의 함수 설명
orElse() 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 값을 반환합니다.
orElseGet() 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 Lambda 표현식의 결괏값을 반환합니다.
orElseThrow() 저장된 값이 존재하면 그 값을 반환하고, 값이 존재하지 않으면 인수로 전달된 예외를 발생시킵니다.

Good Code

Optional<String> opt = Optional.ofNullable("Optional 객체");
if (opt.isPresent()) {
    System.out.println(opt.get());
}

Bad Code

String s = "String 객체";
if (s != null) {
    System.out.println(s);
}

Reference

  • Clean Code (도서) - Robert C. Martin

목차