한 줄 정의

자동으로 닫히지 않는 Closeable 리소스는 try-finally 대신 닫기와 예외 전파를 모두 보장하는 use 로 닫고, 파일은 useLines 로 한 줄씩 읽으면서 닫아야 합니다.

쉽게 말하면

리소스를 여는 일은 공용 자전거를 빌리는 일 입니다. 거치대의 자전거는 한정되어 있어서 반납하지 않으면 다음 사람이 탈 수 없고, 방치된 자전거를 관리 직원이 언젠가 회수하긴 하지만(가비지 컬렉터) 그게 언제일지는 아무도 모릅니다.

“무슨 일이 있어도 반납하기”를 직접 챙기는 것이 try-finally입니다. 그런데 타다가 넘어졌고 마침 반납 도크도 고장 났다면, 앱에는 “도크 고장” 하나만 남고 넘어졌다는 기록은 사라집니다. use 는 반납을 보장하면서 두 사고를 모두 기록해 주는 대여 앱이라, 사고 처리 규칙을 매번 직접 짜지 않아도 됩니다.

왜 중요한가?

파일 핸들, 소켓, DB 커넥션은 JVM 힙이 아니라 OS나 외부 시스템이 한정된 개수로 내주는 자원입니다. 참조가 사라지면 가비지 컬렉터가 결국 회수하지만 그 시점을 보장하지 않으므로, 닫지 않은 리소스가 쌓이면 힙은 멀쩡한데 핸들이나 커넥션이 고갈되어 서비스가 멈춥니다.

닫는 것 자체는 close() 한 줄이지만 어려운 부분은 따로 있습니다. 본문에서 예외가 나도 닫혀야 하고, 닫다가 예외가 나도 원래 예외가 사라지면 안 됩니다. 이 두 조건을 손으로 맞추는 코드는 길고 틀리기 쉬워서, 표준 라이브러리가 그 부분을 use 로 대신 짜 둔 것입니다.

핵심 내용

닫아야 하는 리소스

코틀린/JVM에서 쓰는 자바 표준 라이브러리에는 다음 리소스가 있고, 모두 AutoCloseable 을 확장한 Closeable 인터페이스를 구현합니다.

  • InputStream · OutputStream
  • java.sql.Connection
  • java.io.Reader (FileReader, BufferedReader, CSSParser)
  • java.net.Socket · java.util.Scanner

try-finally가 틀리는 이유

전통적인 방식은 try-finally로 감싸고 finally 에서 close() 를 호출하는 것입니다.

fun countCharactersInFile(path: String): Int {
    val reader = BufferedReader(FileReader(path))
    try {
        return reader.lineSequence().sumOf { it.length }
    } finally {
        reader.close()
    }
}

이 코드는 장황할 뿐 아니라 제대로 동작하지도 않습니다.

문제무슨 일이 생기는가
close() 의 예외가 처리되지 않음닫다가 난 예외가 그대로 밖으로 튀어나감
두 예외가 겹치면 하나만 전파됨try 와 finally 양쪽에서 예외가 나면 finally 쪽만 남고 원래 원인은 사라짐

기대하는 동작은 나중에 난 예외의 정보가 먼저 난 예외에 덧붙여지는 것입니다. 이를 직접 구현하면 꽤 길고 복잡한 코드가 되는데, 워낙 흔한 패턴이라 표준 라이브러리가 use 로 제공합니다.

use로 닫기

use 는 모든 Closeable 객체에 쓸 수 있고, 람다가 끝나거나 예외로 빠져나갈 때 리소스를 닫고 예외를 올바르게 전파합니다. 리시버가 람다의 인수로도 전달되므로 변수 선언 없이 바로 체이닝할 수 있습니다.

fun countCharactersInFile(path: String): Int {
    BufferedReader(FileReader(path)).use { reader ->
        return reader.lineSequence().sumOf { it.length }
    }
}

useLines로 파일을 한 줄씩 읽기

파일은 한 줄씩 읽는 경우가 대부분이라, 표준 라이브러리는 파일을 줄 단위 시퀀스 로 열어 처리가 끝나면 닫아 주는 useLines 도 제공합니다. 표현식으로도 쓸 수 있습니다.

fun countCharactersInFile(path: String): Int =
    File(path).useLines { lines ->
        lines.sumOf { it.length }
    }

시퀀스는 필요할 때 한 줄씩 읽으므로 메모리를 한 줄 분량만 차지해 대용량 파일에 적합합니다. 대신 한 번만 순회할 수 있어서, 파일을 두 번 읽어야 한다면 파일을 두 번 열어야 합니다.

시퀀스는 람다 안에서 소비를 끝내야 합니다

useLines 는 람다가 끝나는 순간 파일을 닫으므로, lines 를 람다 밖으로 반환해 나중에 순회하면 이미 닫힌 파일을 읽게 됩니다. 집계 결과처럼 소비가 끝난 값만 밖으로 내보냅니다.

비교 / 트레이드오프

기준try-finally 직접 작성useuseLines
두 예외가 겹칠 때finally 쪽만 남음원래 예외에 덧붙임원래 예외에 덧붙임
코드길고 틀리기 쉬움람다 한 줄표현식 한 줄
적용 대상모든 리소스모든 CloseableFile 줄 단위 읽기
메모리 · 순회읽는 방식에 따름읽는 방식에 따름한 줄 분량 · 1회만 순회

내 생각

  • “덧붙여지는 예외”의 실체는 suppressed exception입니다. 자바 7의 try-with-resources가 도입한 Throwable.addSuppressed 와 같은 메커니즘이고 코틀린 use 도 내부에서 이를 씁니다. 스택 트레이스의 Suppressed: 항목이 바로 닫는 단계에서 추가로 실패한 기록입니다.
  • 가장 비싼 리소스는 DB 커넥션입니다. 커넥션 풀에서 close() 는 연결 종료가 아니라 풀 반납이라, 한 곳만 빼먹어도 트래픽이 몰릴 때 풀이 고갈되어 서비스 전체가 멈춥니다. JdbcTemplate·JPA가 대신 닫아 주는 경계 밖에서 raw Connection 을 만지는 코드가 특히 위험합니다.
  • 컬렉션처럼 생긴 스트림도 리소스입니다. Files.lines() 나 JPA의 getResultStream() 이 반환하는 Stream 은 파일·커서를 물고 있는 Closeable 인데, 겉모습이 컬렉션 같아서 use 없이 쓰기 쉽습니다.
  • 1회 순회 제약은 설계 신호입니다. 파일을 두 번 읽고 싶다는 것은 첫 순회에서 필요한 값을 다 뽑지 못했다는 뜻이므로, 파일을 다시 여는 대신 한 번의 순회에서 여러 결과를 함께 집계하도록 람다를 고치는 편이 낫습니다.

관련 개념