한 줄 정의
자동으로 닫히지 않는
Closeable리소스는 try-finally 대신 닫기와 예외 전파를 모두 보장하는use로 닫고, 파일은useLines로 한 줄씩 읽으면서 닫아야 합니다.
쉽게 말하면
리소스를 여는 일은 공용 자전거를 빌리는 일 입니다. 거치대의 자전거는 한정되어 있어서 반납하지 않으면 다음 사람이 탈 수 없고, 방치된 자전거를 관리 직원이 언젠가 회수하긴 하지만(가비지 컬렉터) 그게 언제일지는 아무도 모릅니다.
“무슨 일이 있어도 반납하기”를 직접 챙기는 것이 try-finally입니다. 그런데 타다가 넘어졌고 마침 반납 도크도 고장 났다면, 앱에는 “도크 고장” 하나만 남고 넘어졌다는 기록은 사라집니다. use 는 반납을 보장하면서 두 사고를 모두 기록해 주는 대여 앱이라, 사고 처리 규칙을 매번 직접 짜지 않아도 됩니다.
왜 중요한가?
파일 핸들, 소켓, DB 커넥션은 JVM 힙이 아니라 OS나 외부 시스템이 한정된 개수로 내주는 자원입니다. 참조가 사라지면 가비지 컬렉터가 결국 회수하지만 그 시점을 보장하지 않으므로, 닫지 않은 리소스가 쌓이면 힙은 멀쩡한데 핸들이나 커넥션이 고갈되어 서비스가 멈춥니다.
닫는 것 자체는 close() 한 줄이지만 어려운 부분은 따로 있습니다. 본문에서 예외가 나도 닫혀야 하고, 닫다가 예외가 나도 원래 예외가 사라지면 안 됩니다. 이 두 조건을 손으로 맞추는 코드는 길고 틀리기 쉬워서, 표준 라이브러리가 그 부분을 use 로 대신 짜 둔 것입니다.
핵심 내용
닫아야 하는 리소스
코틀린/JVM에서 쓰는 자바 표준 라이브러리에는 다음 리소스가 있고, 모두 AutoCloseable 을 확장한 Closeable 인터페이스를 구현합니다.
InputStream·OutputStreamjava.sql.Connectionjava.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 직접 작성 | use | useLines |
|---|---|---|---|
| 두 예외가 겹칠 때 | finally 쪽만 남음 | 원래 예외에 덧붙임 | 원래 예외에 덧붙임 |
| 코드 | 길고 틀리기 쉬움 | 람다 한 줄 | 표현식 한 줄 |
| 적용 대상 | 모든 리소스 | 모든 Closeable | File 줄 단위 읽기 |
| 메모리 · 순회 | 읽는 방식에 따름 | 읽는 방식에 따름 | 한 줄 분량 · 1회만 순회 |
내 생각
- “덧붙여지는 예외”의 실체는 suppressed exception입니다. 자바 7의 try-with-resources가 도입한
Throwable.addSuppressed와 같은 메커니즘이고 코틀린use도 내부에서 이를 씁니다. 스택 트레이스의Suppressed:항목이 바로 닫는 단계에서 추가로 실패한 기록입니다. - 가장 비싼 리소스는 DB 커넥션입니다. 커넥션 풀에서
close()는 연결 종료가 아니라 풀 반납이라, 한 곳만 빼먹어도 트래픽이 몰릴 때 풀이 고갈되어 서비스 전체가 멈춥니다. JdbcTemplate·JPA가 대신 닫아 주는 경계 밖에서 rawConnection을 만지는 코드가 특히 위험합니다. - 컬렉션처럼 생긴 스트림도 리소스입니다.
Files.lines()나 JPA의getResultStream()이 반환하는Stream은 파일·커서를 물고 있는Closeable인데, 겉모습이 컬렉션 같아서use없이 쓰기 쉽습니다. - 1회 순회 제약은 설계 신호입니다. 파일을 두 번 읽고 싶다는 것은 첫 순회에서 필요한 값을 다 뽑지 못했다는 뜻이므로, 파일을 다시 여는 대신 한 번의 순회에서 여러 결과를 함께 집계하도록 람다를 고치는 편이 낫습니다.
관련 개념
- Ch04 빈틈없이 견고하고 편리한 구조적 동시성 — 자바의
StructuredTaskScope도 try-with-resources로close()를 암묵 호출해 서브태스크 정리를 보장하는, “스코프가 끝나면 반드시 닫는다”는 같은 패턴 위에 있습니다