try·catch·finally 흐름
예상하지 못한 예외가 disconnect를 건너뛰는 자원 누수를 재현하고 try/catch/finally의 실행 순서와 책임을 단계별로 고칩니다.
예외의 가장 큰 장점은 메서드마다 오류 코드를 확인하지 않아도 정상 경로를 연속해서 쓸 수 있다는 점입니다.
그러나 catch만 추가했다고 자원 정리가 자동 보장되지는 않습니다.
어떤 예외가 발생해도 실행해야 하는 동작은 정상 흐름이나 특정 catch 뒤가 아니라 finally에 놓아야 합니다.
알 수 없는 예외와 정리 누락
서비스는 네트워크 전용 예외만 잡고 catch 다음에 disconnect를 호출합니다.
전송 중 IllegalStateException이 발생하면 해당 catch와 마지막 문장을 모두 건너뛰고 호출자에게 전파됩니다.
public final class DisconnectSkippedByUnexpectedFailure {
public static void main(String[] args) {
NetworkClient client = new NetworkClient();
try {
client.connect();
client.send();
} catch (NetworkException error) {
System.out.println("network=" + error.getMessage());
}
client.disconnect();
}
private static final class NetworkClient {
void connect() throws NetworkException {
System.out.println("connected");
}
void send() {
throw new IllegalStateException("serializer crashed");
}
void disconnect() { System.out.println("disconnected"); }
}
private static final class NetworkException extends Exception {
NetworkException(String message) { super(message); }
}
}connected
Exception in thread "main" java.lang.IllegalStateException: serializer crasheddisconnected가 없습니다.
catch가 없어서가 아니라 정리 호출의 위치가 특정 예외 목록에 의존하기 때문입니다.
앞으로 새 예외가 추가될 때마다 모든 catch 뒤 경로를 검토해야 한다면 구조적으로 안전하지 않습니다.
try·catch·finally의 역할
finally는 try 진입 뒤 정상 종료, 일치하는 catch 처리, 처리되지 않은 예외 전파 모두에서 실행됩니다.
연결이 실제로 성립한 뒤에만 닫아야 한다면 client가 내부 상태로 중복·미연결 close를 안전하게 처리하거나, 연결 여부를 서비스가 추적해야 합니다.
public final class FinallyProtectedNetworkFlow {
public static void main(String[] args) {
NetworkClient client = new NetworkClient();
try {
client.connect();
client.send("error2");
} catch (NetworkException error) {
System.out.println("handled=" + error.getMessage());
} finally {
client.disconnect();
}
System.out.println("normal-end");
}
private static final class NetworkClient {
private boolean connected;
void connect() throws NetworkException {
connected = true;
System.out.println("connected");
}
void send(String data) throws NetworkException {
if (data.contains("error2")) throw new NetworkException("send failed");
System.out.println("sent=" + data);
}
void disconnect() {
if (connected) {
connected = false;
System.out.println("disconnected");
}
}
}
private static final class NetworkException extends Exception {
NetworkException(String message) { super(message); }
}
}connected
handled=send failed
disconnected
normal-endcatch가 예외를 처리했으므로 finally 뒤 정상 문장까지 진행합니다.
만약 catch에서 처리하지 않은 RuntimeException이 생기면 finally는 실행되지만 normal-end는 실행되지 않고 원래 예외가 상위로 전파됩니다.
정리를 보장하는 것과 오류를 복구하는 것은 서로 다른 책임입니다.
try·finally 구성
현재 계층에 복구 방법이 없고 자원만 정리해야 한다면 catch로 잡았다가 다시 던질 필요가 없습니다.
try/finally는 원래 예외를 그대로 보존하면서 정리만 수행합니다.
public final class FinallyWithoutCatch {
public static void main(String[] args) {
try {
execute();
} catch (BoardSendException error) {
System.out.println("boundary=" + error.getMessage());
}
}
private static void execute() throws BoardSendException {
Session session = new Session();
try {
session.open();
session.send();
} finally {
session.close();
}
}
private static final class Session {
void open() { System.out.println("open"); }
void send() throws BoardSendException { throw new BoardSendException("timeout"); }
void close() { System.out.println("close"); }
}
private static final class BoardSendException extends Exception {
BoardSendException(String message) { super(message); }
}
}open
close
boundary=timeout서비스는 로그를 남기거나 메시지를 바꾸지 않았습니다.
경계가 한 번만 처리하므로 중복 로그가 없습니다.
단, close 자체가 실패하면 원래 예외가 가려질 수 있습니다.
try-with-resources는 여러 close와 본문 실패를 다룰 때 억제 예외로 원인을 보존합니다.
예외 계층과 catch 범위
연결 실패는 다른 주소로 재시도할 수 있지만 전송 실패는 사용자에게 입력 보존 안내만 할 수 있다고 가정합니다.
하나의 오류 코드 필드보다 하위 예외 타입을 만들면 catch가 타입별 데이터와 복구 정책을 선택할 수 있습니다.
public final class NetworkExceptionHierarchyFlow {
public static void main(String[] args) {
run("connect");
run("send");
}
private static void run(String scenario) {
NetworkClient client = new NetworkClient(scenario);
try {
client.connect();
client.send("board-data");
} catch (ConnectException error) {
System.out.println("retry-address=" + error.address());
} catch (SendException error) {
System.out.println("preserve-data=" + error.data());
} finally {
client.disconnect();
}
}
private record NetworkClient(String scenario) {
void connect() throws ConnectException {
if (scenario.equals("connect")) {
throw new ConnectException("https://board.local");
}
}
void send(String data) throws SendException {
if (scenario.equals("send")) throw new SendException(data);
}
void disconnect() { System.out.println("close=" + scenario); }
}
private static class NetworkException extends Exception {
NetworkException(String message) { super(message); }
}
private static final class ConnectException extends NetworkException {
private final String address;
ConnectException(String address) { super("connect " + address); this.address = address; }
String address() { return address; }
}
private static final class SendException extends NetworkException {
private final String data;
SendException(String data) { super("send " + data); this.data = data; }
String data() { return data; }
}
}retry-address=https://board.local
close=connect
preserve-data=board-data
close=send부모 NetworkException은 클라이언트의 안정된 계약이고 자식은 복구에 필요한 문맥을 제공합니다.
모든 자식을 같은 방식으로 처리하면 부모 하나를 잡아도 되지만, 구체 정책이 다르면 자식 catch를 먼저 둡니다.
finally에 부적합한 코드
finally에는 자원 해제, 잠금 해제, 임시 상태 복구처럼 성공 여부와 관계없이 필요한 최소 동작을 둡니다.
return을 넣으면 try의 반환값이나 진행 중인 예외를 덮을 수 있으므로 피합니다.
정리 중 발생한 예외를 무조건 삼키는 것도 장애 증거를 없앱니다.
긴 업무 로직을 finally에 넣으면 원래 실패와 정리 실패의 우선순위가 모호해집니다.
닫기 작업은 가능하면 멱등적으로 설계하고, 미연결 상태 close가 안전한지 계약을 정합니다.
여러 자원을 역순으로 닫아야 하거나 close 실패까지 보존해야 하면 AutoCloseable과 try-with-resources를 선택합니다.
연습 문제
Connection을 먼저 열고 Channel을 나중에 열었습니다.
전송 중 예외가 발생해도 Channel, Connection 순서로 닫히고 경계에서 원래 메시지를 확인하도록 중첩 try/finally를 작성하세요.
해설 보기
public final class NestedFinallyExercise {
public static void main(String[] args) {
try {
useResources();
} catch (IllegalStateException error) {
System.out.println("boundary=" + error.getMessage());
}
}
private static void useResources() {
Resource connection = new Resource("connection");
connection.open();
try {
Resource channel = new Resource("channel");
channel.open();
try {
throw new IllegalStateException("send failed");
} finally {
channel.close();
}
} finally {
connection.close();
}
}
private record Resource(String name) {
void open() { System.out.println("open=" + name); }
void close() { System.out.println("close=" + name); }
}
}open=connection
open=channel
close=channel
close=connection
boundary=send failed나중에 확보한 자원을 먼저 반환해야 의존 관계가 깨지지 않습니다.
중첩 구조가 복잡해지는 순간 try-with-resources가 같은 순서를 더 안전하고 간결하게 표현합니다.