오류가 없는 프로그램을 작성하는 두 가지 방법이 있는데 사실 세 번째 방법만 통한다. - 앨런 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));
}
}
}
위 예제의 예외 메시지에는 다음의 템플릿이 사용되었다.
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
의 메시지에 두 번이나 %s
로 rawMessage
를 넣고 있어서 코드가 중복된다.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);
}
}
}
위 예제는 InputStream
의 readObject()
메서드를 호출하여 메시지를 읽는 클래스를 보여주고 있다.
이러한 스트림은 자바에서 매우 유연한 편이다.
하지만 유연성을 약속하는 대신 정의된 타입 없이 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()
가 호출되지 않는다.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;
}
}
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
블록은 여러 자원을 동시에 처리할 수 있다.;
구분하기만 하면 된다.예외는 예외를 의미 있게 처리할 수 있을 때에만 잡아야 한다.
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;
}
}
이를 위해 먼저 예외 변수명 e
를 ignored
로 변경하였다.
ignored
라는 이름을 이해해서 catch
블록이 비어 있다고 경고하지 않는다.또한 예외를 왜 무시하는지 주석을 추가했다.
CONDITION -> EFFECT
라는 템플릿을 가지고 왜 예외를 던졌는지, 그로 인한 영향을 왜 넘기는지 설명하고 있다.이 장의 내용은 [자바 코딩의 기술: 똑똑하게 코딩하는 법]의 5장 내용을 정리한 것입니다.