개발자로 후회없는 삶 살기

[문법] try-with-resources를 사용해야 하는 이유 본문

[백엔드]/[Java | 학습기록]

[문법] try-with-resources를 사용해야 하는 이유

몽이장쥰 2024. 5. 29. 23:33

서론

※ 이 글에서는 자바에서 파일을 다룰 때 사용하는 try-catch-finally 방식의 문제점을 언급하며, try-with-resources를 사용해야 하는 이유를 서술한다.

 

본론

✅ try-with-resources란?

public class Main {
    public static void main(String[] args) throws IOException {
        FileInputStream fis = new FileInputStream("ex.txt");
        BufferedInputStream bis = new BufferedInputStream(fis);
    }
}

파일을 사용하기 위해선 디스크에 저장되어 있는 파일을 메모리에 올려야 하는데, 자바에서는 JVM이 어플리케이션에 필요한 모든 파일의 자원을 관리한다. 예를들어, BufferedInputStream을 사용하여 txt 파일을 한 줄 한 줄 읽을 때마다 메모리 내에 각 줄의 내용이 저장된다.

 

만약 파일을 메모리에 올린 뒤 자원을 반납하지 않으면 어떻게 될까? 🚨

메모리에는 파일의 자원이 쌓이고 결국은 OOM가 발생할 수 있다. txt 파일은 문자열이라서 KB 단위를 가지지만, 이미지는 MB, 영상은 GB 단위로 메모리를 사용하기 때문에 메모리 자원을 제대로 관리하지 않으면 결국 프로그램이 다운될 수 있으므로 처리가 완료된 데이터는 반드시 자원 해제를 해줘야 한다. 따라서, 자바에서는 자원을 반납하는 try-catch-finally을 제공한다. 이러한 배경에서 try-with-resources가 나온 이유를 알아보자

 

-> Java7 이전의 try-catch-finally

먼저, try-catch-finally로 자원을 반납하는 경우를 살펴보자. 사용 후 반납해 주어야 하는 자원들은 Closeable 인터페이스를 구현하고 있으며, 사용 후에 close 메서드를 호출해서 자원을 반납해야 한다.

 

FileInputStream fis = null;
BufferedInputStream bis = null;

try {
    fis = new FileInputStream("");
    bis = new BufferedInputStream(fis);
} finally {
    if (fis != null) fis.close();
    if (bis != null) bis.close();
}

Java7 이전에 close를 호출하기 위해서는 try-catch-finally을 이용해서 Null 검사 후 직접 호출해야 했다.

 

자원 반납에 의해 코드가 복잡해짐
작업이 번거로움
에러로 인해 자원을 반납하지 못하는 경우 ★
실수로 자원을 반납하지 못하는 경우 ★
에러가 발생해도 에러 스택 트레이스가 누락되어 디버깅이 힘든 경우 ★

문제는 이러한 과정이 여러가지 단점을 가지고 있다. 필자가 서두에 말한 부분이 3, 4, 5이다. 파일을 바이트 단위로 저장하는 InputStream 타입 변수를 인자로 주고받을 경우 자원의 디버깅이 어렵고 자원이 반납됐는지 관리를 할 수 없어서 OOM이 발생할 수 있다.

 

-> Java7 부터의 try-with-resources

try (FileInputStream fis = new FileInputStream(""); BufferedInputStream bis = new BufferedInputStream(fis)) {

}

자바는 이러한 문제를 해결하기 위해 자원을 자동으로 반납해 주는 문법을 추가했다. AutoCloseable을 구현하고 있는 자원 클래스에 대해 try-with-resources를 사용하면 코드가 유연해지고, 위 문제들을 해결할 수 있다.

 

-> Closeable과 AutoCloseable의 관계

Closeable은 AutoCloseable의 자식이다. AutoCloseable을 Closeable의 부모로 두어 Closeable을 구현한 클래스라면 전부 AutoCloseable을 사용할 수 있도록 하위 호환성 100%를 만족시켰다. 만약 AutoCloseable이 자식이었다면 Closeable로 구현했던 모든 코드를 try-with-resources로 수정해야 했을 텐데, 부모로 두어 자동으로 반납하기를 원하는 부분만 try-with-resources로 수정하면 되도록 했다.

 

✅ try-with-resources가 더 좋은 이유

=> 에러 스택 트레이스가 누락되는 현상 발생

try-catch-finally를 사용하면 에러가 발생해도 에러 스택 트레이스가 누락되는 경우가 발생할 수 있다.

 

-> try-catch-finally로 반납

class Hsb implements AutoCloseable {

    @Override
    public void close() throws Exception {
        System.out.println("close");
        throw new IllegalStateException();
    }

    public void hello() {
        System.out.println("hello");
        throw new IllegalStateException();
    }
}

위 코드를 보면, 상위 부모인 AutoCloseable를 구현한 클래스 Hsb는 사용 후 반납되어야 하는 자원이다.

 

Hsb hsb = null;

try {
    hsb = new Hsb();
    hsb.hello();
} finally {
    if (hsb != null) {
        hsb.close();
    }
}

자원이 null이 아닌 경우 자원을 반납하도록 코드를 구현했고 hello에서 IllegalStateException 예외가 터지고, close에서도 IllegalStateException 예외가 터지기를 기대한다.

 

하지만 결과를 보면 close의 IllegalStateException만 에러 스택 트레이스가 나오고, hello의 에러 스택 트레이스는 누락됐다. 즉, 먼저 발생한 에러가 누락되고 마지막에 찍힌 에러만 남는다. 이러한 문제가 발생한 이유는 자바의 예외 처리 메커니즘에 따라, finally 블록 내에서 발생한 예외가 먼저 발생한 예외를 덮어쓰기 때문이다. 만약 이러한 에러가 운영 단계에서 발생했다면, 원인을 파악하지 못 할 수도 있다.

 

-> try-with-resources로 반납

try (Hsb hsb = new Hsb()) {
    hsb.hello();
}

하지만, try-with-resources로 작성한 코드는 hello에서 발생한 예외도 누락되지 않고 나타난다.

 

이러한 이유로 우리는 반드시 try-with-resources문을 사용해야 한다.

 

=> 에러로 자원을 반납하지 못 하는 경우

try-with-resources를 사용하는 이유는 누락없이 모든 자원을 반납할 수 있기 때문이다.

 

-> try-catch-finally로 반납

public static void main(String[] args) throws IOException {
    FileInputStream resource1 = null;
    FileInputStream resource2 = null;

    try {
        resource1 = new FileInputStream("");
        resource2 = new FileInputStream("");
    } finally {
        if (resource1 != null) {
            resource1.close();
        }

        if (resource2 != null) {
            resource2.close();
        }
    }
}

위 코드에서 fin문은 자원1과 자원 2 모두 반납되기를 기대하지만, 자원 1에서 예외가 터져서 자원 2는 반납되지 않는다. 이는 자원 1의 close에서 예외를 catch 하는 코드가 없기 때문이다.

 

public static void main(String[] args) throws IOException {
    FileInputStream resource1 = null;
    FileInputStream resource2 = null;

    try {
        resource1 = new FileInputStream("");
        resource2 = new FileInputStream("");
    } finally {
        if (resource1 != null) {
            try {
                resource1.close();
            } catch (Exception e) {

            }
        }

        if (resource2 != null) {
            try {
                resource2.close();
            } catch (Exception e) {

            }
        }
    }
}

이를 해결하기 위해서는 모든 close문 마다 try-catch-finally로 묶어야 하는데, 그만큼 코드가 더 복잡해진다. 만약, 이러한 오류가 누적되어 자원 반납이 이루어지지 않으면 OOM이 발생할 수 있다.

 

-> try-with-resources로 반납

try (FileInputStream resource1 = new FileInputStream(""); FileInputStream resource2 = new FileInputStream("")) {

}

이렇게 코드를 작성하면 두 자원이 모두 정상적으로 반납된다. 이 코드가 원하는대로 동작하는 이유는 try-with-resources이 컴파일 단계에서 모든 close 부분을 try-catch로 변경해 주기 때문이다. 즉, 우리가 작성해야 할 부분을 컴파일러가 처리해 준 것이다. 이러한 편리한 기능이 개발자가 신경 써야 할 부분을 줄여주고 에러 발생률을 낮춰준다. 필자는 대용량 데이터를 다룰 때 자원을 반납하지 않아 OOM이 발생한 경우가 있었는데 이 덕분에 try-with-resources의 중요성을 확실히 느낄 수 있었다.

Comments