1. 사전 검사를 했더라도 try/catch가 필요한 이유
처음 사전 검사를 해 보면 이런 느낌이 듭니다. “좋아, exists()랑 canRead()를 확인했으니 읽으면 되겠네!” 그런데 여기서 Kotlin(그리고 OS)이 부드럽게 상기시킵니다. “하하, 아니.” 사전 검사는 그저 검사 시점에만 그럴듯해 보였다는 뜻입니다. 반면 실제 readText()는 데이터를 진짜로 읽으려는 시도이고, 여전히 예외로 끝날 수 있습니다.
간단한 상황을 가정해 봅시다. exists() == true를 확인했는데, 1ms 뒤에 다른 프로세스/사용자가 파일을 삭제(또는 이름 변경)했습니다. 이건 공상과학이 아니라, 컴퓨터에서 흔히 벌어지는 일입니다.
import java.io.File
fun main() {
val file = File("data/input.txt")
if (file.exists()) {
// exists()와 readText() 사이에 파일이 사라질 수 있다.
val text = file.readText()
println("OK: ${text.length}") // 운이 좋으면
}
}
운이 나쁘면 읽기 단계에서 바로 예외가 납니다. 그리고 그건 정상입니다. try/catch는 사전 검사를 대체하는 것이 아니라, 두 번째 방어선이며 실제 I/O를 감싸고 있어야 합니다.
2. “좁은” try: 안에는 실제 I/O만
초보자가 가장 자주 하는 실수는 이렇습니다. “어차피 안 터지게 크게 try로 전부 감싸자.” 이건 냉장고에 방탄복을 입히는 것과 비슷합니다. 무겁고 불편하고, 냉장고에는 별 의미도 없습니다. 큰 try는 코드를 흐리게 만듭니다. 정확히 어디에서 망가질 수 있었는지 불분명해지고, catch 안에서 데이터 상태를 모른 채로 “어쨌든 계속” 하려는 시도가 자주 발생합니다.
안 좋은 예: 너무 넓은 try.
import java.io.File
import java.io.IOException
fun main() {
val file = File("data/amount.txt")
try {
val text = file.readText()
val amount = text.trim().toInt() // 이건 I/O가 아니라 데이터 형식(파싱)이다
println("amount=$amount")
} catch (e: IOException) {
println("파일을 읽지 못했습니다")
}
}
여기 문제는 미묘합니다. toInt()는 NumberFormatException을 던질 수 있는데, 이것은 IOException이 아닙니다. 즉 프로그램은 결국 “어딘가 이상하게” 그대로 터집니다. “다 감쌌는데?”라고 느끼겠지만, 실제로 감싼 건 전부가 아니라 I/O 부분뿐입니다.
더 나은 방법은 책임을 분리하는 것입니다. 파일 읽기는 I/O이므로 IOException으로 처리하고, 텍스트 파싱은 “데이터/포맷 오류”로 따로 다뤄야 합니다(오늘은 I/O에 집중하지만, 분리는 여전히 중요합니다).
import java.io.File
import java.io.IOException
fun readTextOrNull(file: File): String? =
try {
file.readText()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/amount.txt")
val text = readTextOrNull(file) ?: run {
println("파일을 읽지 못했습니다: ${file.path}")
return
}
val amount = text.trim().toIntOrNull()
println("amount=$amount") // amount=123 또는 amount=null
}
코드의 느낌을 보세요. 읽기가 분리되어 있고 try는 좁으며, 그 다음부터는 내용물을 어떻게 할지 차분히 결정할 수 있습니다.
작은 콘솔 프로젝트에서도 책임의 “지도”를 잡아 두면 유용합니다:
flowchart TD
A[CLI / main] --> B["loadRawText()"]
B -->|I/O: readText| C[(File)]
B -->|String? / error| A
A --> D[parse / validate]
D -->|format error| A
3. try를 표현식으로: “무너지는” 대신 값을 반환하기
Kotlin은 오류 처리를 훨씬 편하게 만드는 기능이 있습니다. try/catch는 표현식이라서 값을 반환할 수 있습니다. 그래서 “성공하면 결과를, 실패하면 대체 값을”이라는 명확한 계약을 가진 함수를 작성할 수 있습니다. Kotlin에서는 이런 스타일이 관용적입니다. try는 한 값을 반환하고, catch는 다른 값을 반환합니다.
파일 읽기의 가장 단순한 선택지는 “텍스트 또는 null”입니다.
import java.io.File
import java.io.IOException
fun readTextOrNull(file: File): String? = try {
file.readText()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/input.txt")
val text = readTextOrNull(file)
println(text?.take(20) ?: "NO DATA") // 예: NO DATA
}
같은 방식으로 쓰기는 “성공/실패” 계약을 선택할 수 있습니다.
import java.io.File
import java.io.IOException
fun writeTextSafely(file: File, text: String): Boolean = try {
file.writeText(text)
true
} catch (e: IOException) {
false
}
fun main() {
val ok = writeTextSafely(File("data/out/report.txt"), "Hello!\n")
println("written=$ok") // written=true 또는 written=false
}
이 아이디어는 단순해 보이지만 프로그램을 단련시킵니다. “뭐 안 터지겠지”라고 기대하는 대신, 미리 설계하게 됩니다. 문제가 생기면 무엇을 반환할지.
4. I/O에서 함수 계약 선택: T?, Boolean, 또는 예외 전파
try/catch를 두는 순간, 항상 결정해야 합니다. 호출하는 코드가 무엇을 보게 할 것인가? 학습용 콘솔 앱이라면 보통 세 가지 계약이면 충분합니다. T?를 반환하거나, Boolean을 반환하거나, 여기서 예외를 잡지 않고 위로 올려 보내는 것 (이것도 때로는 정상적인 전략입니다).
이 비교를 표처럼 머릿속에 두면 좋습니다:
| 함수 계약 | 형태 | 언제 편한가 | 실수 비용 |
|---|---|---|---|
|
|
결과가 없는 것이 정상적인 로직 분기일 때 | 처리를 잊지 않아야 함 |
|
|
데이터는 필요 없고 “성공했는가”만 중요할 때 | 진단이 약함: 왜 인가? |
| 예외 전파 | try/catch 없이 (또는 throw로 계속 던지기) | 파일이 없으면 프로그램 자체가 의미가 없을 때 | 상위에서 처리하지 않으면 프로그램이 종료될 수 있음 |
중요한 점: “영원히 정답인 선택”을 고르는 게 아닙니다. 여러분의 앱과, 그 코드 지점에서 무엇이 편한지를 고르는 것입니다.
예를 들어 학습용 콘솔 앱으로 간단한 “지출 기록”을 만든다고 합시다. 지출을 텍스트 파일에 저장하고 짧은 리포트를 보여줍니다. 파일이 읽히지 않으면 이해하기 쉬운 메시지를 보여 주고 빈 목록으로 시작하면 됩니다. 이는 List<String>? 계약의 좋은 후보입니다.
import java.io.File
import java.io.IOException
fun readLinesOrNull(file: File): List<String>? = try {
file.readLines()
} catch (e: IOException) {
null
}
fun main() {
val file = File("data/expenses.txt")
val lines = readLinesOrNull(file) ?: emptyList()
println("loaded=${lines.size}") // loaded=0, 파일이 없거나/읽을 수 없으면
}
반면 리포트 저장은 Boolean으로 해도 됩니다. 저장에 실패하면 그냥 알려 주면 됩니다.
5. 오류 메시지: 사용자에게도, 나에게도
오류 메시지는 문학 작품이 아니고 “문제가 발생했습니다” 같은 말도 아닙니다. 좋은 메시지는 최소한 세 가지에 답합니다. 무엇을 하려고 했는지, 대상이 무엇인지(경로), 어떤 종류의 문제인지. 로깅 시스템이 없어도 이것만으로 큰 도움이 됩니다.
여기서는 I/O용 메시지 포매터를 하나 만들어 두는 것이 유용합니다. 똑똑할 필요는 없고, 안정적이며 앱 전체에서 일관되면 됩니다.
import java.io.File
import java.io.IOException
fun ioErrorMessage(action: String, file: File, e: IOException): String {
val type = e::class.simpleName ?: "IOException"
val detail = e.message ?: "no details"
return "$action 하지 못했습니다: ${file.path}. 원인: $type: $detail"
}
그리고 catch에서 이렇게 사용합니다:
import java.io.File
import java.io.IOException
fun readTextOrNullVerbose(file: File): String? = try {
file.readText()
} catch (e: IOException) {
println(ioErrorMessage("파일을 읽기", file, e))
null
}
fun main() {
val text = readTextOrNullVerbose(File("data/input.txt"))
println(text?.length ?: "no text") // no text
}
네, 콘솔에 바로 출력합니다. 학습용 CLI에서는 충분히 정상입니다. 더 진지한 프로젝트라면 “사용자 메시지”와 “로그용 진단”을 분리하겠지만, 오늘은 의도적으로 로깅으로 넘어가지 않습니다.
여러 catch: 순서와 분리의 의미
가끔은 catch 하나(IOException)로 전부 잡고 싶어집니다. 괜찮습니다. 하지만 최소한 한 가지 정도의 특수 케이스, 즉 “파일이 없거나 열리지 않음”(FileNotFoundException)을 구분하면 도움이 됩니다. 그러면 “I/O 오류” 대신 “경로를 확인하세요” 같은 더 인간적인 메시지를 줄 수 있습니다.
Kotlin(그리고 JVM 전반)의 규칙: 더 구체적인 예외를 먼저, 더 일반적인 예외를 나중에 잡아야 합니다. 그렇지 않으면 구체적인 쪽은 절대 실행되지 않습니다.
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
fun main() {
val file = File("data/input.txt")
try {
val text = file.readText()
println("OK: ${text.length}") // OK: ...
} catch (e: FileNotFoundException) {
println("파일을 찾을 수 없거나 열 수 없습니다: ${file.path}")
} catch (e: IOException) {
println("읽는 중 I/O 오류가 발생했습니다: ${file.path}")
println("세부 정보: ${e.message}")
}
}
여기서 중요한 건 과하게 복잡하게 만들지 않는 것입니다. 가장 흔한 케이스 하나만 분리하고, 나머지는 IOException에 맡겼습니다.
6. try/catch를 어디에 둘 것인가: 책임의 경계
예외를 main에서 잡고 싶어집니다. “거기 있으면 다 보이니까.” 하지만 앱이 커지면 main이 거대한 콤바인으로 변합니다. 입력도 읽고, 명령도 파싱하고, 데이터도 다루고, 거기에 온갖 문제까지 처리합니다. 좋은 스타일은 I/O가 일어나는 곳에서 I/O를 잡고, 바깥으로는 이해 가능한 결과를 반환하는 것입니다.
우리의 지출 트래커에서는, “저장소” 함수들이 readText()/writeText()를 try/catch로 감싸고, main은 데이터 또는 “실패” 신호를 받는다는 뜻입니다.
흐름은 이렇게 단순해집니다:
flowchart TD
M[main: user commands] --> S[storage: read/write file]
S -->|List⟨String⟩? / Boolean| M
S -->|IOException inside| S
미니 저장 계층 예시(“마법” 없이, 짧고 명시적으로):
import java.io.File
import java.io.IOException
fun loadExpensesOrNull(file: File): List<String>? = try {
file.readLines()
} catch (e: IOException) {
null
}
fun saveExpenses(file: File, lines: List<String>): Boolean = try {
file.writeText(lines.joinToString(separator = "\n"))
true
} catch (e: IOException) {
false
}
main에서의 사용도 “원자력 발전소를 운영하는 느낌” 없이 깔끔합니다:
import java.io.File
fun main() {
val file = File("data/expenses.txt")
val lines = loadExpensesOrNull(file)
if (lines == null) {
println("지출을 불러오지 못해 빈 목록으로 시작합니다.")
} else {
println("불러온 항목 수: ${lines.size}") // 예: 불러온 항목 수: 12
}
}
심리적으로도 중요한 포인트입니다. main은 OS가 읽기를 어떻게 거부했는지까지 알 필요가 없습니다. main이 해야 할 일은 “빈 데이터로 계속할지, 사용자에게 경로 수정을 요청할지, 종료할지” 같은 결정을 내리는 것입니다.
7. finally와 use {}: 언제 필요한가
finally는 종종 “제다이의 비밀 기술”처럼 들립니다. 하지만 실제로 필요한 이유는 딱 하나입니다. try 내부에서 오류가 나더라도, 마지막에 어떤 코드 조각이 반드시 실행되도록 보장하는 것. 파일 작업에서는 특히, 스트림/리더를 수동으로 열었다면 리소스를 닫기 위해 중요합니다.
하지만 Kotlin은 더 편한 스타일도 제공합니다. use {}는 리소스를 자동으로 닫아 줍니다(개념적으로는 finally와 비슷하지만 더 깔끔합니다). 이는 AutoCloseable에 대해 관용적인 접근입니다.
오늘은 스트림을 깊게 파고들지는 않겠지만, 경계를 보기 위한 작은 예시는 유용합니다. readText()는 use가 필요 없지만, bufferedReader()는 필요합니다.
import java.io.File
import java.io.IOException
fun readFirstLineOrNull(file: File): String? = try {
file.bufferedReader().use { reader ->
reader.readLine()
}
} catch (e: IOException) {
null
}
fun main() {
val first = readFirstLineOrNull(File("data/input.txt"))
println(first ?: "<empty>") // 예: <empty>
}
간단한 규칙을 기억하세요. ...Reader(), ...Writer(), InputStream, OutputStream을 보면 거의 항상 옆에 use {}(또는 최소한 finally)가 있어야 합니다. 반대로 readText()/writeText() 같은 “완성형” 함수는 내부에서 열고 닫기를 처리하므로, 수동으로 finally를 쓸 필요가 없습니다.
Kotlin 2.x의 작은 변화: 컴파일러가 catch/finally를 거치는 smart-cast 정보를 더 잘 추적하게 되어서, 일부 시나리오에서는 변수가 다시 nullable이 되는 지점을 더 정교하게 알려 줍니다. 이는 K2의 예외 처리 개선과 관련된 내용입니다.
8. 파일 작업에서 try/catch를 사용할 때의 흔한 실수
실수 1: 읽기, 데이터 파싱, 비즈니스 로직 절반까지 모두 한 덩어리의 거대한 try 안에 넣기.
이런 코드는 실패 원인에 대한 감각을 잃게 만듭니다. I/O인지, 포맷인지, 내 로직인지? 게다가 catch에서 데이터가 로드되지 않았을 수도 있는데도 “뭔가 계속” 하려는 유혹이 생깁니다. 해결은 단순합니다. I/O를 작은 함수로 분리하고 try를 readText()/writeText() 및 가까운 작업 주변으로 “좁게” 두세요.
실수 2: 예외를 조용히 “눌러 버리기”.
null이나 false를 반환하는 건 괜찮지만, 아무 메시지도 남기지 않으면 실제 문제가 생겼을 때 “왜 비어 있지?”를 디버깅하느라 시간을 씁니다. “왜 읽지 못했지?”가 되어야 합니다. 학습용 CLI에서도 최소한 한 번은 출력하세요: 동작, 경로, e.message. 그렇지 않으면 진단이 점술이 됩니다.
실수 3: “혹시 몰라서” Exception을 잡기.
안정성을 챙기는 것처럼 보이지만, 실제로는 많은 경우 덮어버리는 습관입니다. I/O 오류, 포맷 오류, 내 버그를 한데 섞게 됩니다. 그 결과 프로그램은 “겉으로는” 돌아가지만 이상한 동작을 합니다. 파일 작업에서는 IOException(그리고 경우에 따라 FileNotFoundException 같은 특수 케이스)에 집중하고, 다른 오류는 이유 없이 숨기지 마세요.
실수 4: Boolean을 반환하면서 “왜 false인지” 정보를 전혀 주지 않기.
Boolean은 성공 여부만 중요할 때 편리합니다. 하지만 원인이 가끔이라도 필요하다면, (오늘 범위에서는) 최소한 catch 안에서 메시지를 출력하거나 null/String?로 오류 텍스트를 반환하는 방식도 가능합니다. 핵심은 false가 열 가지 다른 원인을 의미하는데 어느 곳에서도 이유를 알 수 없는 API를 만들지 않는 것입니다.
실수 5: catch 순서를 잘못 두기.
먼저 catch(e: IOException)를 쓰고 그 다음에 catch(e: FileNotFoundException)를 쓰면, 두 번째 블록은 도달 불가능해집니다. FileNotFoundException은 IOException의 하위 타입이기 때문입니다. 순서는 “구체적인 것 먼저, 일반적인 것 나중”이어야 하며, 이는 예외 처리의 고전 규칙입니다.
실수 6: 읽기에 실패했는데도 데이터를 계속 사용하기.
미묘한 버그입니다. 예외를 잡고 “실패했다”고 출력했지만 변수가 예를 들어 빈 문자열로 남아 있고, 그 다음에 파일 내용인 것처럼 파싱합니다. 그러면 2차 오류(예: NumberFormatException)가 나고, 실제로 무엇이 깨졌는지 헷갈립니다. 읽기에 실패했다면 null/false를 반환하고, 그 실행 경로를 명시적으로 중단하세요.
실수 7: canRead()/canWrite()를 보장으로 착각하기.
이 검사는 유용하지만 성공을 약속하지는 않습니다. 권한이 바뀔 수도 있고, 파일이 점유될 수도 있고, 파일 시스템이 끊길 수도 있습니다. 그래서 올바른 조합은 이렇습니다. 사전 검사는 메시지 품질과 빠른 실패를 개선하고, try/catch는 실제 작업을 안전하게 감쌉니다.
GO TO FULL VERSION