반환 코드의 흐름 붕괴
연결과 전송 실패를 문자열 반환 값으로 다루면서 생기는 누락과 자원 해제 버그를 관찰하고 정상 값과 실패 신호의 경계를 다시 설계합니다.
외부 시스템과 통신하는 코드는 성공 경로만 읽어서는 완성되지 않습니다.
연결이 실패하면 전송을 시작하지 않아야 하고, 연결 뒤 전송이 실패하더라도 확보한 자원은 닫아야 합니다.
반환 코드만 사용하는 첫 구현에서 이 두 규칙이 어떻게 호출자의 기억에 의존하는지부터 확인합니다.
실패 코드와 흐름 붕괴
다음 클라이언트는 테스트 입력에 따라 실패 문자열을 돌려줍니다.
서비스는 반환값을 받기만 하고 검사하지 않아 연결되지 않은 상태에서 전송까지 진행합니다.
컴파일러는 String이 성공인지 오류인지 알지 못합니다.
public final class IgnoredReturnCodeBug {
public static void main(String[] args) {
NetworkClient client = new NetworkClient(true, false);
String connectCode = client.connect();
String sendCode = client.send("create post request");
client.disconnect();
System.out.println("connect=" + connectCode);
System.out.println("send=" + sendCode);
}
private static final class NetworkClient {
private final boolean connectError;
private final boolean sendError;
NetworkClient(boolean connectError, boolean sendError) {
this.connectError = connectError;
this.sendError = sendError;
}
String connect() {
if (connectError) return "CONNECT_ERROR";
System.out.println("connected");
return "SUCCESS";
}
String send(String data) {
if (sendError) return "SEND_ERROR";
System.out.println("sent=" + data);
return "SUCCESS";
}
void disconnect() { System.out.println("disconnected"); }
}
}sent=create post request
disconnected
connect=CONNECT_ERROR
send=SUCCESS연결 실패와 전송 성공이 한 결과에 함께 나타납니다.
문제는 실패 문자열의 철자가 아니라 호출 순서를 보호하는 제어 흐름이 없다는 점입니다.
반환 타입이 String이면 "SUCCESS", "success", 빈 문자열도 모두 같은 타입이라 호출자가 매번 해석 규칙을 알아야 합니다.
즉시 분기와 후속 동작 차단
첫 개선은 연결과 전송 뒤에 결과를 확인하는 것입니다.
연결이 실패하면 send를 건너뛰고, 전송이 실패하면 오류를 보고합니다.
그러나 중간 return으로 빠져나가면 마지막 disconnect도 건너뛸 수 있으므로 종료 동작은 분기 바깥에 둡니다.
public final class ReturnCodeNetworkFlow {
public static void main(String[] args) {
sendMessage("error2");
}
private static void sendMessage(String data) {
NetworkClient client = new NetworkClient();
client.initError(data);
String connectResult = client.connect();
if (isSuccess(connectResult)) {
String sendResult = client.send(data);
if (!isSuccess(sendResult)) {
System.out.println("network-error=" + sendResult);
}
} else {
System.out.println("network-error=" + connectResult);
}
client.disconnect();
}
private static boolean isSuccess(String code) {
return "SUCCESS".equals(code);
}
private static final class NetworkClient {
private boolean connectError;
private boolean sendError;
void initError(String data) {
connectError = data.contains("error1");
sendError = data.contains("error2");
}
String connect() {
if (connectError) return "CONNECT_ERROR";
System.out.println("connected");
return "SUCCESS";
}
String send(String data) {
if (sendError) return "SEND_ERROR";
System.out.println("sent=" + data);
return "SUCCESS";
}
void disconnect() { System.out.println("disconnected"); }
}
}connected
network-error=SEND_ERROR
disconnected이 버전은 정해진 두 실패에는 올바르게 동작합니다.
핵심 개선은 정상 흐름에서 실패 조건을 분리하고 연결 성공 여부에 따라 전송을 제한한 것입니다.
두 원문은 입력이 다르다. 반환값을 검사하지 않는 첫 main은 연결 실패 뒤에도 send를 실행하고, error2 입력의 분기 원문은 연결 성공일 때만 send를 실행한다.
IgnoredReturnCodeBug는 (true, false), ReturnCodeNetworkFlow는 "error2"를 사용합니다. 서로 다른 원문 입력의 실제 호출·결과를 비교합니다.
| 진행 위치 | 반환값을 검사하지 않는 원문 | 결과에 따라 분기하는 원문 |
|---|---|---|
| connect | 반환 CONNECT_ERRORconnected는 출력하지 않습니다. | 반환 SUCCESS출력 connected |
| send | 연결 결과와 무관하게 호출합니다. 반환 SUCCESS출력 sent=create post request | isSuccess(connectResult)가 참이어서 호출합니다.반환 SEND_ERROR출력 network-error=SEND_ERROR |
| disconnect 이후 | 출력 disconnected그 뒤 main이 두 반환값을 출력합니다. | 오류 보고 뒤 disconnected를 출력합니다.추가 결과 출력은 없습니다. |
- connect
- 반환값을 검사하지 않는 원문: 반환
CONNECT_ERRORconnected는 출력하지 않습니다.결과에 따라 분기하는 원문: 반환SUCCESS출력connected - send
- 반환값을 검사하지 않는 원문: 연결 결과와 무관하게 호출합니다.반환
SUCCESS출력sent=create post request결과에 따라 분기하는 원문:isSuccess(connectResult)가 참이어서 호출합니다.반환SEND_ERROR출력network-error=SEND_ERROR - disconnect 이후
- 반환값을 검사하지 않는 원문: 출력
disconnected그 뒤 main이 두 반환값을 출력합니다.connect=CONNECT_ERROR send=SUCCESS결과에 따라 분기하는 원문: 오류 보고 뒤disconnected를 출력합니다.추가 결과 출력은 없습니다.
첫 원문의 send=SUCCESS는 테스트 클라이언트의 반환값이지 실제 연결 성공의 증거가 아닙니다. 분기 원문도 finally를 쓰지 않으므로 예상 밖 예외까지 종료 호출을 보장하지 않습니다.
새 반환 코드의 업무 의미는 별도로 해석해야 합니다. 특히 send()가 예상하지 못한 런타임 예외를 던지면, 이 코드에는 finally가 없어 마지막 종료 호출이 보장되지 않습니다.
닫힌 결과 타입
예외를 배우기 전에도 문자열 코드는 enum 또는 결과 객체로 개선할 수 있습니다.
호출자는 가능한 상태를 자동 완성으로 확인하고, switch 식으로 모든 상태를 다뤘는지 컴파일 시점에 확인할 수 있습니다.
public final class TypedNetworkResult {
public static void main(String[] args) {
NetworkClient client = new NetworkClient(false, true);
NetworkResult connect = client.connect();
NetworkResult send = connect == NetworkResult.SUCCESS
? client.send("typed result")
: NetworkResult.SKIPPED;
System.out.println(render(connect));
System.out.println(render(send));
client.disconnect();
}
private static String render(NetworkResult result) {
return switch (result) {
case SUCCESS -> "success";
case CONNECT_ERROR -> "cannot connect";
case SEND_ERROR -> "cannot send";
case SKIPPED -> "not attempted";
};
}
private enum NetworkResult { SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED }
private record NetworkClient(boolean connectError, boolean sendError) {
NetworkResult connect() {
return connectError ? NetworkResult.CONNECT_ERROR : NetworkResult.SUCCESS;
}
NetworkResult send(String data) {
return sendError ? NetworkResult.SEND_ERROR : NetworkResult.SUCCESS;
}
void disconnect() { System.out.println("closed"); }
}
}success
cannot send
closed결과 타입은 실패가 흔하고 호출자가 두 결과를 모두 업무적으로 사용해야 할 때 좋은 선택입니다.
예를 들어 검색 결과 없음, 유효성 검사 실패, 결제 거절처럼 예상 가능한 분기는 값으로 모델링할 수 있습니다.
반면 연결 라이브러리 깊은 곳에서 정상 값을 만들 수 없고 호출 스택을 중단해야 한다면 예외가 더 자연스럽습니다.
실패 신호의 선택 기준
반환 값과 예외는 “오류냐 아니냐”만으로 나누지 않습니다.
호출자가 즉시 대안을 선택할 수 있고 실패 빈도가 정상 사용의 일부라면 명시 결과 타입이 읽기 쉽습니다.
호출자가 현재 계층에서 복구할 수 없고 여러 단계를 건너 경계 처리기로 전달해야 한다면 예외가 중복 분기를 줄입니다.
- 정상적으로 비어 있을 수 있는 조회: Optional이나 sealed 결과 타입
- 사용자 입력 검증: 오류 목록을 포함한 결과 값
- 파일·네트워크·DB 접근 실패: 원인과 문맥을 담은 예외
- 프로그래밍 계약 위반: IllegalArgumentException 같은 언체크 예외
- 반드시 실행할 정리: 반환 방식과 무관하게 구조적으로 보장
예외가 자동으로 자원을 닫아 주는 것은 아닙니다.
다음 문서들에서 try/catch/finally와 try-with-resources가 각각 어떤 흐름을 보장하는지 분리해서 다룹니다.
연습 문제
OperationResult enum을 사용해 connect가 실패하면 send를 실행하지 않고, 세 가지 입력 ok, connect, send 모두 마지막에 close가 출력되도록 완성하세요.
아직 try/finally는 사용하지 않습니다.
해설 보기
public final class ReturnCodeFlowExercise {
public static void main(String[] args) {
run("ok");
run("connect");
run("send");
}
private static void run(String scenario) {
Client client = new Client(scenario);
OperationResult connected = client.connect();
OperationResult sent = OperationResult.SKIPPED;
if (connected == OperationResult.SUCCESS) {
sent = client.send();
}
client.close();
System.out.println(scenario + "=" + connected + "/" + sent);
}
private enum OperationResult { SUCCESS, CONNECT_ERROR, SEND_ERROR, SKIPPED }
private record Client(String scenario) {
OperationResult connect() {
return scenario.equals("connect")
? OperationResult.CONNECT_ERROR
: OperationResult.SUCCESS;
}
OperationResult send() {
return scenario.equals("send")
? OperationResult.SEND_ERROR
: OperationResult.SUCCESS;
}
void close() { System.out.println("close=" + scenario); }
}
}close=ok
ok=SUCCESS/SUCCESS
close=connect
connect=CONNECT_ERROR/SKIPPED
close=send
send=SUCCESS/SEND_ERRORSKIPPED는 실패와 “호출하지 않음”을 구분합니다.
ReturnCodeFlowExercise의 ok, connect, send 세 입력은 연결 반환값에 따라 send 호출 여부가 달라진다. 세 원문 경로는 close를 먼저 출력한 뒤 결과를 출력한다.
연습의 세 입력은 connected == OperationResult.SUCCESS일 때만 send()를 호출합니다. sent의 시작값은 SKIPPED입니다.
| 입력 | 실제 호출과 반환값 | 원문 출력 순서 |
|---|---|---|
ok | connect: SUCCESSsend 호출: SUCCESS | |
connect | connect: CONNECT_ERRORsend 호출 안 함: SKIPPED 유지 | |
send | connect: SUCCESSsend 호출: SEND_ERROR | |
ok- 실제 호출과 반환값: connect:
SUCCESSsend 호출:SUCCESS원문 출력 순서:close=ok ok=SUCCESS/SUCCESS connect- 실제 호출과 반환값: connect:
CONNECT_ERRORsend 호출 안 함:SKIPPED유지원문 출력 순서:close=connect connect=CONNECT_ERROR/SKIPPED send- 실제 호출과 반환값: connect:
SUCCESSsend 호출:SEND_ERROR원문 출력 순서:close=send send=SUCCESS/SEND_ERROR
SKIPPED는 전송을 시도하지 않았다는 값입니다. SEND_ERROR는 전송 메서드를 호출해 받은 실패 값입니다. 표의 close 도달은 이 세 반환 코드 경로의 결과이며 예상 밖 예외의 정리까지 보장하지 않습니다.
단순 boolean 두 개보다 상태 의미가 분명하지만, 예상하지 못한 예외에도 close를 보장하려면 finally 또는 AutoCloseable이 필요합니다.