Chatper 05. 문제 발생에 대비하기

Yeseong31·2023년 8월 28일
0

자바 코딩의 기술

목록 보기
5/8

오류가 없는 프로그램을 작성하는 두 가지 방법이 있는데 사실 세 번째 방법만 통한다. - 앨런 J. 펄리스

  • 프로그램은 대부분 문제없이 실행되지만, 언제든 일은 틀어질 수 있다.
  • 이에 대비하지 않으면 프로그램이 충돌하거나 잘못된 데이터를 만드는 피해가 생길 수 있다.
  • 무엇보다 최악은 아무 일도 없었던 것처럼 프로그램이 계속 실행되는 경우이다.

버그가 전혀 없는 프로그램(이 있을까 싶지만) 역시 오류에 철저히 대비해야 한다.




빠른 실패

  • 다음의 예제를 살펴보자.
class CruiseControl {

    static final double SPEED_OF_LIGHT_KMH = 1079252850;
    static final double SPEED_LIMIT = SPEED_OF_LIGHT_KMH;

    private double targetSpeedKmh;

    void setTargetSpeedKmh(double speedKmh) {

        if (speedKmh < 0) {
            throw new IllegalArgumentException();
        } else if (speedKmh <= SPEED_LIMIT) {
            targetSpeedKmh = speedKmh;
        } else {
            throw new IllegalArgumentException();
        }
    }
}
  • 위 예제에서 정상적인 제어 흐름 경로는 무엇일까? 잘 보이지 않는다.
  • 개발자는 이 코드를 보고 조건을 모두 이해한 뒤 정상적인 실행 경로를 찾느라 시간을 낭비해야 한다.

class CruiseControl {

    static final double SPEED_OF_LIGHT_KMH = 1079252850;
    static final double SPEED_LIMIT = SPEED_OF_LIGHT_KMH;

    private double targetSpeedKmh;

    void setTargetSpeedKmh(double speedKmh) {

        if (speedKmh < 0 || speedKmh > SPEED_LIMIT) {
            throw new IllegalArgumentException();
        targetSpeedKmh = speedKmh; 
    }
}
  • 위 예제는 매개변수 검증과 일반적인 경로를 분리하고, 조건들을 합쳐서 메서드 상단에 두었다.
  • 즉 조건에 해당하는 순간 바로 빠르게 실패하게 된다.

빠른 실패를 사용하면 메서드 전체를 읽는 데 이해하기 쉬워지고, 들여쓰기도 한 단계 줄어든다.




항상 가장 구체적인 예외 잡기

자바의 예외는 비교적 복잡한 타입 계층 구조를 가진다.
예외를 잡으려면 항상 가장 구체적인 예외 타입을 잡아야 한다.

  • 다음의 예제를 살펴보자.
class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                && rawMessage.length() != Transmission.MESSAGE_LENGTH) {
            throw new IllegalArgumentException("Bad message received!");
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (Exception e) {
            throw new IllegalArgumentException("Bad message received!");
        }
    }
}
  • Exception자바에서 가장 일반적인 예외 유형이다.
    • 이보다 더 일반적인 예외는 Exception의 상위 타입인 Throwable 뿐이다.
    • Throwable을 잡으면 OutOfMemoryError와 같은 VM 오류까지 잡힐 수 있다.
    • Exception과 같이 일반적인 타입을 잡으면 잡아서는 안 될 오류까지 함께 잡힐 위험이 있다.

개발자는 체크 예외만 잡고, 일반적인 예외들은 잡으면 안 된다.


class TransmissionParser {

    static Transmission parse(String rawMessage) {

        if (rawMessage != null 
				&& rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException("Bad message received!");
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Bad message received!");
        }
    }
}
  • 위 예제는 이전에 try ~ catch문에서 Exception을 잡은 부분을 구체적인 예외를 잡도록 수정했다.

구체적인 예외를 잡다 보면 catch 이 여러 개 작성될 수 있다.
하지만 코드가 늘어난다고 해서 일반적인 예외 유형을 잡는 것이 더 나은 것은 아니다.
버그가 적은 긴 코드가 버그가 많은 짧은 코드보다 항상 낫다.

자바 7부터는 다중 캐치 블록으로 여러 개의 체크 예외를 하나의 catch 문으로 잡을 수 있게 되었다.

try {
	...
} catch (NumberFormatException | IOException e) {
	...
}



메시지로 원인 설명

  • 다음의 에제를 살펴보자.
class TransmissionParser {

    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException();
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException("Bad message received!");
        }
    }
}

예외 처리는 예외를 잡는 것뿐만 아니라 던지는 것까지 포함한다.

  • 위 예제에서는 메서드에 잘못된 매개변수가 들어갔을 때 IllegalArgumentException이 발생한다.
  • 하지만 예외의 이유가 보이지 않는다.
    • 타입에는 전후 맥락이 없으므로 정보가 부족하다.
    • 예외가 발생하여 예외 추적을 통해 해당 코드까지 도달했을 때, 왜 이러한 예외가 발생하는지 알 수 없다.
  • 또한 throw new IllegalArgumentException("Bad message received")처럼 작성하더라도 단순히 메시지만 적어놓은 형태이기 때문에 실제 예외 처리에는 도움이 되지 않는다.

class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException(
                String.format("Expected %d, but got %d characters in '%s'",
                    Transmission.MESSAGE_LENGTH, rawMessage.length(),
                    rawMessage));
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                String.format("Expected number, but got '%s' in '%s'",
                         rawId, rawMessage));
        }
    }
}
  • 예외에 메시지를 넣을 때에는 예외를 해결할 때 바라는 것, 받은 것, 전체 맥락 3가지를 골고루 제공하면 좋다.
  • 이와 같은 자세한 정보로 예외의 근본적인 원인을 훨씬 빠르게 추적할 수 있다.
  • 또한 예외를 일으킨 상황을 쉽게 재현할 수도 있다.

위 예제의 예외 메시지에는 다음의 템플릿이 사용되었다.
Expected [EXPECTED], but got [ACTUAL] in [CONTEXT]

예외를 직접 처리할 수 없는 경우에는 어떻게 해야 할까?
이럴 때에는 예외를 다시 던지거나 더 일반적인 예외 유형으로 변환해야 한다.
이는 아래 원인 사슬 깨지 않기에서 설명한다.




원인 사슬 깨지 않기

예외는 또 다른 예외를 발생시킬 수 있다.
예외를 잡았지만 처리할 수 없다면 반드시 다시 던져야 한다.

  • 다음의 예제를 살펴보자.
class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException(
                String.format("Expected %d, but got %d characters in '%s'",
                         Transmission.MESSAGE_LENGTH, rawMessage.length(),
                         rawMessage));
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                String.format("Expected number, but got '%s' in '%s'",
                         rawId, rawMessage));
        }
    }
}
  • 위 예제에서는 NumberFormatException을 잡았지만 IllegalArgumentException을 던지고 있다.
  • 예외 처리에는 문제가 없지만 원인 사슬이 깨지게 된다.
    • IllegalArgumentException의 스택 추적을 살펴보면, NumberFormatException이 왜 발생했는지, 어떤 코드에서 예외가 발생했는지에 대한 힌트가 없다.

class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException(
                String.format("Expected %d, but got %d characters in '%s'",
                         Transmission.MESSAGE_LENGTH, rawMessage.length(),
                         rawMessage));
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                String.format("Expected number, but got '%s' in '%s'",
                    rawId, rawMessage), e);
        }
    }
}
  • 예외에는 다양한 생성자가 있고, 그 중에는 원인으로 Throwable을 전달하는 생성자도 있다.
  • Throwable을 전달함으로써 예외의 원인을 연관시키고, 그로써 원인 사슬이 만들어진다.
  • 따라서 catch 블록에서 예외를 던질 때에는 메시지와 잡았던 예외를 즉시 원인으로 전달해야 한다.



변수로 원인 노출

  • 다음의 예제를 살펴보자.
class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new IllegalArgumentException(
                String.format("Expected %d, but got %d characters in '%s'",
                    Transmission.MESSAGE_LENGTH, rawMessage.length(),
                    rawMessage));
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new IllegalArgumentException(
                String.format("Expected number, but got '%s' in '%s'",
                    rawId, rawMessage), e);
        }
    }
}
  • 위 예제에는 중복된 코드, 감추어진 정보라는 두 가지 문제가 있다.
  • 먼저 IllegalArgumentException의 메시지에 두 번이나 %srawMessage를 넣고 있어서 코드가 중복된다.
  • 또한 rawMessage를 예외 메시지로 인코딩하고 있어서 소프트웨어의 최종 사용자에게 어떤 종류의 메시지가 오류를 일으켰는지 알리고 싶은 경우에 추출이 어렵다.

class TransmissionParser {
    static Transmission parse(String rawMessage) {

        if (rawMessage != null
                 && rawMessage.length() != Transmission.MESSAGE_LENGTH) {

            throw new MalformedMessageException(
                String.format("Expected %d, but got %d characters in '%s'",
                    Transmission.MESSAGE_LENGTH, rawMessage.length()),
                    rawMessage);
        }

        String rawId = rawMessage.substring(0, Transmission.ID_LENGTH);
        String rawContent = rawMessage.substring(Transmission.ID_LENGTH);

        try {
            int id = Integer.parseInt(rawId);
            String content = rawContent.trim();
            return new Transmission(id, content);
        } catch (NumberFormatException e) {
            throw new MalformedMessageException(
                String.format("Expected number, but got '%s'", rawId),
                rawMessage, e);
        }
    }
}

final class MalformedMessageException extends IllegalArgumentException {

    final String raw;

    MalformedMessageException(String message, String raw) {

        super(String.format("%s in '%s'", message, raw));

        this.raw = raw;
    }

    MalformedMessageException(String message, String raw, Throwable cause) {

        super(String.format("%s in '%s'", message, raw), cause);
        this.raw = raw;
    }
}
  • 이는 맞춤형 예외, 즉 그 예외만의 raw 메시지 필드가 들어간 MalformedMessageException을 정의하면 된다.
  • 향후 최종 사용자 정보를 자세히 알고 싶거나 예외를 더 철저히 처리하고 싶을 때 raw 필드를 추출하기만 하면 된다.

message는 이전에 메시지로 원인 설명에서 소개했던 템플릿을 그대로 사용하여 일관성을 유지하고 있다.




타입 변환 전에 항상 타입 검증하기

프로그램에서 동적 객체를 사용하려면 명시적으로 어떤 타입으로든 변환해야 한다.
변환을 제대로 하지 않으면 RuntimeException과 같은 예외가 발생할 수 있다.

  • 다음의 예제를 살펴보자.
class Network {

    ObjectInputStream inputStream;
    InterCom interCom;

    void listen() throws IOException, ClassNotFoundException {

        while (true) {
            Object signal = inputStream.readObject();
            CrewMessage crewMessage = (CrewMessage) signal;
            interCom.broadcast(crewMessage);
        }
    }
}
  • 위 예제는 InputStreamreadObject() 메서드를 호출하여 메시지를 읽는 클래스를 보여주고 있다.

  • 이러한 스트림은 자바에서 매우 유연한 편이다.

    • 파일, 네트워크로부터 입력을 읽을 수 있을 수 있기 때문이다.
  • 하지만 유연성을 약속하는 대신 정의된 타입 없이 Object로 제공되고 있다.

    • 지금은 다행히 CrewMessage로 강제 형 변환이 잘 되고 있지만 스트림이 다른 타입을 반환하면 문제가 된다.
    • 메서드는 스트림에 실제 어떤 타입이 들어올지 제어할 수 없기 때문이다.

class Network {

    ObjectInputStream inputStream;
    InterCom interCom;

    void listen() throws IOException, ClassNotFoundException {

        while (true) {
            Object signal = inputStream.readObject();

            if (signal instanceof CrewMessage) {
                CrewMessage crewMessage = (CrewMessage) signal;
                interCom.broadcast(crewMessage);
            }
        }
    }
}
  • 이 문제는 변환 전에 타입 검증만 적절히 하면 된다.
  • instanceof 연산자를 통해 타입을 검증하고, signal에 대해 변환을 수행하는 식으로 코드를 수정하면 된다.
  • 여러 타입으로 된 집합, 다양한 타입의 메시지를 받으려면 instanceof로 차례대로 검증하면 된다.
  • 이때 검증에 걸린다면 ClassNotFoundException이 발생한다.
    • ClassNotFoundException타입 검증으로 거를 수 없다.

프로그램이 외부와 상호작용할 때에는 항상 예상치 못한 입력을 처리할 수 있도록 대비해야 한다.




항상 자원 닫기

한 프로그램이 자원을 해제하지 않으면 전체 환경이 망가질 수 있다.

  • 다음의 예제를 살펴보자.
class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final String FILE_FILTER = "*.log";

    List<Path> getLogs() throws IOException {

        List<Path> result = new ArrayList<>();

        DirectoryStream<Path> directoryStream =
                 Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER);

        for (Path logFile : directoryStream) {
            result.add(logFile);
        }

        directoryStream.close();

        return result;
    }
}
  • 위 예제에서는 시스템 자원인 DirectoryStream을 사용하고 close()로 자원을 해제하고 있다.
  • 프로그램에 문제가 없다면 상관없지만, 자원을 사용하다가 예외가 발생하면 예외로 인해 close()가 호출되지 않는다.
  • 예외로 인해 자원을 해제하지 못할 수 있다는 뜻이다.
  • 이러한 상황을 자원 누출(resource leak)이라고 한다.

class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final String FILE_FILTER = "*.log";

    List<Path> getLogs() throws IOException {

        List<Path> result = new ArrayList<>();

        try (DirectoryStream<Path> directoryStream =
                      Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER)) {

            for (Path logFile : directoryStream) {
                result.add(logFile);
            }
        }

        return result;
    }
}
  • 자바 7부터는 try-with-resources 구문으로 자원을 안전하게 닫을 수 있다.
    • try 뒤의 소괄호 안에서 자원을 열기만 하면 된다.
    • 컴파일러는 내부적으로 finally 블록을 만들고, 자원을 닫는다.



항상 다수 자원 닫기

  • 다음의 예제를 살펴보자.
class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final Path STATISTICS_CSV = LOG_FOLDER.resolve("stats.csv");
    static final String FILE_FILTER = "*.log";

    void createStatistics() throws IOException {

        DirectoryStream<Path> directoryStream =
                 Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER);
        BufferedWriter writer =
                 Files.newBufferedWriter(STATISTICS_CSV);

        try {
            for (Path logFile : directoryStream) {

                final String csvLine = String.format("%s,%d,%s",
                         logFile,
                         Files.size(logFile),
                         Files.getLastModifiedTime(logFile));

                writer.write(csvLine);
                writer.newLine();
            }
        } finally {
            directoryStream.close();
            writer.close();
        }
    }
}
  • 위 예제에서는 try 전에 자원을 열고 finally 블록 안에서 자원을 닫고 있다.
  • 각 자원마다 여러 가지의 이유로 실패할 수 있기 때문에 실수 없이 다수의 자원을 항상 닫는 것은 쉽지 않다.
  • 지금처럼 finally로 자원을 닫게 되면, 이전에 살펴보았듯이 중간에 예외가 발생하면 자원이 닫히지 않는다.
  • 이는 이전의 예제와 마찬가지로 try-with-resource 구문을 사용하면 문제가 해결된다.

class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final Path STATISTICS_CSV = LOG_FOLDER.resolve("stats.csv");
    static final String FILE_FILTER = "*.log";

    void createStatistics() throws IOException {

        try (DirectoryStream<Path> directoryStream =
                      Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER);
            BufferedWriter writer =
                      Files.newBufferedWriter(STATISTICS_CSV)) {

            for (Path logFile : directoryStream) {

                String csvLine = String.format("%s,%d,%s", 
                               logFile,
                               Files.size(logFile),
                               Files.getLastModifiedTime(logFile));

                writer.write(csvLine);
                writer.newLine();
            }
        }
    }
}
  • try-with-resources 블록은 여러 자원을 동시에 처리할 수 있다.
    • 각 자원은 세미콜론 ; 구분하기만 하면 된다.



빈 catch 블록 설명하기

예외는 예외를 의미 있게 처리할 수 있을 때에만 잡아야 한다.

  • 다음의 예제를 살펴보자.
class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final String FILE_FILTER = "*.log";

    List<Path> getLogs() throws IOException {
        List<Path> result = new ArrayList<>();

        try (DirectoryStream<Path> directoryStream =
                      Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER)) {

            for (Path logFile : directoryStream) {
                result.add(logFile);
            }
        } catch (NotDirectoryException e) {

        }

        return result;
    }
}
  • 예외는 그냥 넘기고 아무 일도 하지 말아야 할 때도 있다. 지금의 catch 블록이 그러한 상태이다.
  • 하지만 catch 블록을 마주하면 아무 것도 하지 않아도 되는 것인지 괜히 의심스러울 수 있다.
  • 이는 비어 있는 catch 블록에 대한 설명 내지는 힌트가 전혀 없기 때문이다.

class Logbook {

    static final Path LOG_FOLDER = Paths.get("/var/log");
    static final String FILE_FILTER = "*.log";

    List<Path> getLogs() throws IOException {

        List<Path> result = new ArrayList<>();

        try (DirectoryStream<Path> directoryStream =
                       Files.newDirectoryStream(LOG_FOLDER, FILE_FILTER)) {

            for (Path logFile : directoryStream) {
                result.add(logFile);
            }
        } catch (NotDirectoryException ignored) {
            // 디렉터리가 없으면 -> 로그도 없다!
        }
        return result;
    }
}
  • 이를 위해 먼저 예외 변수명 eignored로 변경하였다.

    • 이렇게 하면 스스로 예외를 잘 설명할 뿐만 아니라 예외를 무시하겠다고 명시적으로 드러낸다.
    • 최근에는 IDE에서도 이 ignored라는 이름을 이해해서 catch 블록이 비어 있다고 경고하지 않는다.
  • 또한 예외를 왜 무시하는지 주석을 추가했다.

    • 주석은 다른 프로그래머가 구현 결정을 더 쉽게 이해하는 데 반드시 필요하다.
    • 예제에서는 CONDITION -> EFFECT라는 템플릿을 가지고 왜 예외를 던졌는지, 그로 인한 영향을 왜 넘기는지 설명하고 있다.


이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 5장 내용을 정리한 것입니다.

profile
역시 개발자는 알아야 할 게 많다.

0개의 댓글

관련 채용 정보