자바/자바

[Java] I/O 스트림과 안전한 자원 관리: Try-with-Resources 완벽 분석 (Try-Catch-Finally 비교)

불광동 물주먹 2025. 12. 4. 14:00

 

백엔드 개발 환경에서 외부 리소스(파일, 네트워크, DB)와의 입출력(I/O)은 빈번하게 발생합니다. 이

때 자원을 획득하는 것만큼 중요한 것이 **자원의 해제(Release)**입니다.

본 포스팅에서는 Java I/O 스트림의 기본 개념부터, 왜 반드시 close()를 해야 하는지, 그리고 Java 7부터 도입된 Try-with-Resources(TWR) 패턴이 기존의 try-catch-finally 방식에 비해 어떤 기술적 이점을 갖는지 심층적으로 정리합니다.


1. Java I/O Stream의 정의

자바에서 스트림(Stream)은 데이터가 흐르는 단방향 통로를 의미합니다. 출발지(Source)에서 도착지(Destination)로 데이터를 운반하며, 다음과 같은 특징을 가집니다.

  • 단방향성 (Unidirectional): 입력 스트림(InputStream, Reader)과 출력 스트림(OutputStream, Writer)이 분리되어 있습니다.
  • FIFO (First In First Out): 먼저 들어온 데이터가 먼저 나가는 큐(Queue) 구조를 가집니다.
  • 블로킹 (Blocking): 데이터가 읽히거나 쓰일 때까지 스레드가 대기하는 것이 기본 동작입니다(NIO 제외).

2. 그럼 왜 스트림을 닫아야(Close) 하는가?

InputStream이나 OutputStream을 사용한 후 close()를 호출하지 않는 것은 단순한 실수 이상의 문제를 야기합니다.

특히 24시간 가동되는 서버 애플리케이션에서는 치명적인 장애로 이어질 수 있습니다.

1) OS 레벨의 자원 누수

자바 애플리케이션이 파일을 열거나 소켓을 연결하면, JVM 힙 메모리뿐만 아니라 **OS 커널의 자원인 파일 핸들(File Descriptor)**을 할당받습니다.

  • JVM의 가비지 컬렉터(GC)는 더 이상 참조되지 않는 객체의 힙 메모리만 회수할 뿐, OS가 관리하는 파일 핸들까지 반납시키지는 못합니다.
  • close()가 누락되면 파일 핸들이 계속 점유 상태로 남게 되며, OS의 프로세스당 파일 열기 제한(ulimit)에 도달하면 java.io.IOException: Too many open files 오류와 함께 서버가 더 이상 새로운 연결을 맺지 못하는 상태(행, Hang)에 빠집니다.

2) 데이터 무결성 보장

출력 스트림(OutputStream)의 경우 성능 향상을 위해 내부 버퍼(Buffer)를 사용합니다. close()는 내부적으로 flush()를 호출하여 버퍼에 남아있는 잔류 데이터를 목적지로 완전히 밀어내고 연결을 종료합니다. 이를 생략할 경우 파일의 끝부분이 잘리거나 저장되지 않는 데이터 손실이 발생할 수 있습니다.

 


3. 자원 해제 방식의 진화

Legacy: Try-Catch-Finally (Java 7 이전)

과거에는 finally 블록에서 리소스를 해제했습니다. 하지만 이 방식은 코드가 장황해지고(Verbose), 실수할 여지가 많으며, 예외 처리(Exception Handling) 관점에서 치명적인 단점이 존재합니다.

Java
 
// [Bad Practice] 가독성이 떨어지고 예외 처리가 복잡함
BufferedReader br = null;
try {
    br = new BufferedReader(new FileReader("data.txt"));
    // 비즈니스 로직 수행
} catch (IOException e) {
    e.printStackTrace(); // 1. 로직 예외 발생 시점
} finally {
    if (br != null) {
        try {
            br.close(); 
        } catch (IOException e) {
            e.printStackTrace(); // 2. close 예외 발생 시점 (1번 예외를 덮어쓸 위험 있음)
        }
    }
}

Modern: Try-with-Resources (Java 7 이후)

try 구문의 소괄호 (...) 안에 자원 생성 코드를 명시하면, 블록이 끝날 때(정상 종료 혹은 예외 발생 시) 자동으로 close()가 호출됩니다.

Java
 
// [Best Practice] 간결하고 안전함
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
    
    // 비즈니스 로직 수행
    String line = br.readLine();
    
} catch (IOException e) {
    // 로직 에러와 close 에러 모두 안전하게 처리 가능
    e.printStackTrace();
}

4. Try-with-Resources(TWR)를 사용해야 하는 이유

단순히 코드가 짧아지는 것을 넘어, TWR은 다음과 같은 기술적 우위를 가집니다.

1) AutoCloseable 인터페이스 구현

TWR을 사용하기 위한 조건은 해당 클래스가 java.lang.AutoCloseable 인터페이스를 구현하는 것입니다. Java의 거의 모든 I/O 클래스(Stream, Channel, Socket, JDBC Connection 등)는 이를 구현하고 있습니다. 만약 커스텀 클래스에서 TWR을 쓰고 싶다면 이 인터페이스를 구현하고 close() 메서드를 오버라이딩하면 됩니다.

2) 억제된 예외 (Suppressed Exceptions) 처리

이것이 TWR을 사용해야 하는 가장 결정적인 이유입니다.

  • Legacy 방식의 문제: try 블록에서 예외가 발생하고, finally 블록의 close()에서도 예외가 발생하면, finally의 예외가 앞선 예외를 덮어버립니다(Swallowing). 개발자는 정작 중요한 비즈니스 로직의 에러 원인을 파악할 수 없게 됩니다.
  • TWR 방식의 해결: try 블록의 예외와 close() 시점의 예외가 동시에 발생하면, try 블록의 예외(Main Exception)를 던지고, close() 예외는 '억제된 예외(Suppressed Exception)'로 첨부됩니다.
    • e.getSuppressed() 메서드를 통해 close 과정에서 발생한 예외까지 모두 추적할 수 있어 디버깅에 훨씬 유리합니다.

5. Best Practice: 데코레이터 패턴과 Close

Java I/O는 기능 확장을 위해 보조 스트림(Decorator)을 감싸는 형태를 취합니다.

Java
 
try (
    // FileInputStream(기반) -> InputStreamReader(변환) -> BufferedReader(보조)
    BufferedReader br = new BufferedReader(new InputStreamReader(new FileInputStream("file.txt")))
) {
    // ...
}

이때, 가장 바깥쪽의 br(BufferedReader)만 닫아주면 됩니다. Java I/O 스트림은 데코레이터 패턴으로 구현되어 있어, 래퍼(Wrapper) 객체의 close()를 호출하면 내부적으로 연결된 기반 스트림의 close()까지 연쇄적으로 호출하여 자원을 안전하게 반납합니다.


요약

  1. I/O 스트림은 OS 자원(File Descriptor 등)을 사용하므로, 사용 후 반드시 반납해야 한다.
  2. close() 누락 시 Too many open files 에러로 인한 서버 장애가 발생할 수 있다.
  3. try-catch-finally는 가독성이 나쁘고 예외 묵살(Exception Swallowing) 문제가 있다.
  4. Try-with-Resources(TWR) 는 자원의 자동 해제를 보장하며, Suppressed Exception을 통해 디버깅 안정성을 높여준다.
  5. 따라서, AutoCloseable을 구현한 모든 자원 관리는 **TWR을 사용하는 것이 표준(Standard)**이다.

 

 

결론 - Try-with-Resources(TWR)  사용하자...