1. 언제 try/catch가 필요할까?
솔직히 말해, 이런 질문은 타당합니다: “try/catch가 왜 필요하지? 그냥 readln().toIntOrNull()로 쓰고 편하게 살면 되잖아?” 그리고 많은 경우 실제로 그렇게 하는 게 맞습니다. 하지만 예외에는 불편한 특성이 있습니다. 예외는 우리가 기대하는 지점에서만 발생하는 것이 아니고, 어떤 연산에는 “안전한 버전”이 아예 없을 때도 있습니다.
예를 들어, toInt()는 문자열이 올바르지 않으면 예외를 던집니다( null을 반환하는 게 아닙니다). 0으로 나누기도 예외를 던집니다. 배열 인덱스가 범위를 벗어나도 마찬가지입니다. 그리고 프로그램이 비정상 종료하지 않게 하려면, “문제를 가로채고 계속 진행하는” 메커니즘이 필요합니다. 바로 그 역할을 하는 것이 try/catch입니다: “위험한 코드를 시도하되, 예상 가능한 오류가 발생하면—죽지 말고—이해 가능한 동작을 하라.”
Kotlin 관점에서 보면 이렇게 동작합니다: try 안의 코드는 예외로 인해 중단될 수 있고, 그 경우 제어 흐름은 알맞은 catch로 점프합니다.
멘탈 모델: try/catch는 “또 하나의 if”가 아니다
머릿속에 올바른 “그림”을 잡는 것이 매우 중요합니다. if는 정상적인 실행 흐름 안에서 분기를 선택하는 것입니다. 조건을 확인하고, 필요한 길로 깔끔하게 갑니다. 예외는 길이 아니라 바닥의 함정문입니다. 걷다가 갑자기 아래로 떨어지는 것이죠.
try/catch는 그 함정 아래에 안전망을 까는 방식입니다. “오류면 이렇게 해”가 아니라 “일단 해봐; 떨어지면 내가 잡을게”라고 말하는 것입니다. 그래서 try/catch는 사전에 조건으로 모두 검사하기 어렵거나(혹은 검사가 너무 번거로운 경우), 그리고 문제의 원인이 ‘예외’로 표현되는 지점에서 특히 유용합니다.
try/catch 문법과 예외 객체 e
가장 자주 쓰게 될 형태를 정리해 봅시다. Kotlin에서 예상 가능한 오류를 처리하는 최소 형태는 다음과 같습니다: try { ... } catch (e: SomeException) { ... }. try에는 잠재적으로 “터질 수 있는” 코드를 넣고, catch에는 프로그램이 비정상 종료되는 대신 수행하길 원하는 동작을 넣습니다.
초보자에게 catch에서 가장 “쓸모 있는” 것은 변수 e(보통 이렇게 이름을 붙입니다), 즉 예외 객체입니다. 여기에는 최소한 message(오류 메시지 문자열, 때로는 null)와 예외의 타입 정보가 있습니다. 지금 단계에서 예외 계층 전체를 외울 필요는 없지만, 한 가지는 이해해야 합니다: 예외 타입은 “무엇이 잘못되었는지”에 대한 힌트입니다.
미니 템플릿:
fun main() {
try {
// here is what can "crash"
} catch (e: Exception) {
println("Something went wrong: ${e.message}")
}
}
다만 주의하세요: 처음부터 Exception을 잡는 것은 “너무 넓은 그물”입니다. 가끔 그렇게 하기도 하지만, 오늘은 올바른 습관을 연습합니다: 예상 가능한 구체적인 오류를 잡기. “전부 다”를 잡는 것이 아닙니다.
2. 예시: 문자열 → 숫자, toInt()로 변환
고전적인 상황부터 시작해 봅시다: 사용자가 숫자를 입력해야 하는데 숫자가 아닌 값을 입력합니다. 이전에는 toIntOrNull()로 해결했었습니다. 이번에는 일부러 toInt()를 사용해서 “크래시”가 어떻게 생기는지, 그리고 try/catch가 그 크래시를 정상적인 메시지로 어떻게 바꾸는지 보겠습니다.
fun main() {
print("Enter your age: ")
val raw = readln()
try {
val age = raw.toInt()
println("Age = $age") // for example: Age = 25
} catch (e: NumberFormatException) {
println("Error: this doesn't look like an integer") // if they entered "12a"
}
}
여기서 중요한 동작을 하나 짚고 갑시다: toInt()가 “터지면”, 다음 줄인 println("Age = $age")는 실행되지 않습니다. 대신 catch로 점프합니다.
그리고 작지만 실용적인 포인트 하나: e.message를 “맞혀서” 그것만 사용자에게 출력하려고 하지 마세요. 메시지는 그다지 친절하지 않을 수 있습니다. 사용자에게는 보통 여러분의 문장(예: “정수를 입력하세요”)이 더 좋고, e.message는 여러분을 위한 힌트에 가깝습니다.
예시: 나눗셈과 ArithmeticException
이번엔 사용자가 숫자를 올바르게 입력했더라도 오류가 발생할 수 있는 상황입니다. 0으로 나누기는 완벽한 예시입니다: “입력은 유효하지만 의미상 맞지 않는” 경우죠.
학습용 앱을 위한 작은 조각을 만들어 봅시다: 하루 평균 지출을 계산하려고 합계와 일수를 입력합니다.
fun main() {
print("Total expenses: ")
val total = readln().toIntOrNull() ?: 0
print("Number of days: ")
val days = readln().toIntOrNull() ?: 0
try {
val avg = total / days
println("On average: $avg per day") // for example: On average: 100 per day
} catch (e: ArithmeticException) {
println("Error: cannot divide by 0 days (makes sense, right?)")
}
}
여기서 왜 try/catch가 필요할까요? toIntOrNull()을 썼는데도요? 왜냐하면 오류는 문자열 파싱에서 발생하는 것이 아니라 산술 연산에서 발생하기 때문입니다. 0을 “안전하게 파싱”하는 것은 얼마든지 가능하지만, total / 0은 여전히 0으로 나누기입니다.
3. try의 크기
초보자가 try/catch를 처음 알게 되면 보통 두 가지 극단으로 갑니다: 아예 어디에도 try를 쓰지 않거나, main 전체를 하나의 거대한 try로 감싸고 모든 것을 잡아버리는 경우입니다. 두 번째는 편해 보입니다: “어쨌든 안 죽잖아.” 하지만 그 편리함에는 대가가 있습니다. 무엇이 어디에서 터졌는지 이해하기 어려워집니다.
시작 단계에서의 좋은 스타일: try는 최소한이어야 합니다. 즉, 그 지점에서 실제로 예상되는 예외를 던질 수 있는 연산만 감싸야 합니다. try가 작을수록 “마법”이 줄고, 디버깅이 쉬워지며, 예외 처리도 더 정확해집니다.
두 가지 버전을 비교해 봅시다. 나쁜 예(너무 넓음):
fun main() {
try {
print("Enter a number: ")
val n = readln().toInt()
println("Square: ${n * n}")
println("Done!")
} catch (e: NumberFormatException) {
println("Not a number")
}
}
이 코드는 얼핏 괜찮지만, 오류가 toInt()가 아니라 그 이후(오늘은 아니더라도 언젠가)에서 발생한다고 상상해 보세요. 그러면 실제 원인이 다른데도 NumberFormatException만 잡으려 할 수 있습니다.
조금 더 깔끔하게(toInt() 주변에만 최소 try):
fun main() {
print("Enter a number: ")
val raw = readln()
val n = try {
raw.toInt()
} catch (e: NumberFormatException) {
println("Not a number, using 0")
0
}
println("Square: ${n * n}") // if they entered "abc" -> Square: 0
}
형식적으로 여기서는 try를 표현식으로 사용합니다(값을 반환). Kotlin은 이를 허용합니다: 결과는 try 또는 catch의 마지막 표현식에서 가져옵니다.
4. “잡는다 → 설명한다 → 계속한다” 패턴
콘솔 프로그램에서 try/catch의 가장 실용적인 가치는, 사용자가 실수해도 프로그램이 “잘못 입력했으니 안녕”으로 끝나지 않게 만드는 것입니다. 이를 위해 보통 반복문이 필요합니다: 질문하고, 처리해 보고, 오류면 설명하고, 다시 묻습니다.
정수를 입력받고 성공할 때까지 재시도하는 함수 readIntWithRetry(prompt: String): Int를 작성해 봅시다.
fun readIntWithRetry(prompt: String): Int {
while (true) {
print(prompt)
val raw = readln()
try {
return raw.toInt()
} catch (e: NumberFormatException) {
println("An integer is required. Please try again.")
}
}
}
스타일을 잘 보세요: try 안에는 “위험한” 연산이 딱 하나, toInt()만 있습니다. 성공하면 return으로 함수에서 빠져나옵니다. 실패하면 메시지를 출력하고 반복문이 다시 실행됩니다.
이제 main에서 사용하면 코드가 더 깔끔해집니다:
fun main() {
val total = readIntWithRetry("Total expenses: ")
val days = readIntWithRetry("Number of days: ")
println("You entered total=$total, days=$days")
}
5. 미니 앱: 견고한 입력을 갖춘 지출 기록
예시들이 분리된 조각들의 모음이 되지 않도록, 이후 과정에서 계속 확장할 수 있는 작은 콘솔 앱을 조립해 봅시다. “지출 저금통”이라고 해봅시다: 지출을 추가하고, 합계를 보여주고, 평균을 계산하되(그리고 크래시하지 않게).
아직 when은 사용하지 않습니다(나중에 나옵니다). 그래서 명령은 if/else로 처리하겠습니다. 데이터는 IntArray에 저장합니다. 배열은 이미 알고 있으니까요.
저장소 뼈대와 지출 추가
먼저 저장을 스케치합니다: expenses 배열과 count 카운터. 그리고 작은 추가 함수 하나.
fun addExpense(expenses: IntArray, count: Int, value: Int): Int {
expenses[count] = value
return count + 1
}
네, 아직 배열 오버플로를 검사하진 않습니다. 이건 학습용 예시이고(배열은 무한하지 않다는 점을) 머릿속에만 두겠습니다.
지출 합계
fun total(expenses: IntArray, count: Int): Int {
var sum = 0
var i = 0
while (i < count) {
sum += expenses[i]
i++
}
return sum
}
평균값: 0으로 나누기 오류 가능
여기서 try/catch가 자연스럽게 등장합니다: 평균 = 합계 / 개수. 만약 count == 0이면 0으로 나누기가 발생합니다.
fun average(expenses: IntArray, count: Int): Int {
val sum = total(expenses, count)
return try {
sum / count
} catch (e: ArithmeticException) {
0
}
}
이 예시는 try/catch를 표현식으로 쓰는 유용한 실용적 의미를 보여줍니다: 성공하면 숫자를 반환하고, 계산이 불가능하면 “합리적인” 0을 반환하고 싶습니다.
main: 명령과 견고한 입력
이제 간단한 명령 루프를 만듭니다. 명령 목록은 다음과 같이 하겠습니다: "add", "total", "avg", "exit".
fun main() {
val expenses = IntArray(100)
var count = 0
while (true) {
print("Command (add/total/avg/exit): ")
val cmd = readln().trim()
if (cmd == "add") {
val value = readIntWithRetry("Enter an expense (integer): ")
count = addExpense(expenses, count, value)
println("Added: $value") // for example: Added: 150
} else if (cmd == "total") {
println("Total: ${total(expenses, count)}")
} else if (cmd == "avg") {
println("Average: ${average(expenses, count)}")
} else if (cmd == "exit") {
println("Bye!")
return
} else {
println("Unknown command: $cmd")
}
}
}
의미상 중요한 점: 이 앱은 이제 숫자 대신 "qwe" 같은 값을 입력해도 더 이상 죽지 않습니다. 차분하게 “정수가 필요합니다”라고 말하고 다시 입력을 요청합니다. 바로 이것이 “잡는다 → 설명한다 → 계속한다”입니다.
6. try/catch 사용 시 흔한 실수
실수 1: 무엇을 기대하는지도 모르면서 “일단” Exception을 잡아버리기.
동기는 선합니다: “어쨌든 안 죽게 하자.” 하지만 그러면 오류가 뒤죽박죽이 됩니다. 프로그램은 “뭔가 잘못됐어요”라고만 말하고, 왜 그런지 파악하기가 어려워집니다. 초반 규율은 단순합니다: “숫자가 아닌 값을 입력했다”를 기대한다면 NumberFormatException을 잡고, 0으로 나누기를 기대한다면 ArithmeticException을 잡으세요. Exception 같은 일반적인 것은, 처리 전략을 이해한 다음에 남겨두는 편이 낫습니다.
실수 2: try를 너무 크게 만들고 그 안에 입력, 계산, 출력을 섞어 넣기.
거대한 try는 “블랙박스” 효과를 만듭니다. 안에서 뭔가 터졌는데 맥락을 잃어버립니다. 게다가 실제로 기대하던 지점이 아닌 곳에서 예외를 우연히 잡아버리기도 쉽습니다. 초보자를 위한 실천 규칙: try에는 구체적인 예상 오류를 던질 수 있는 구체적인 연산 1~2개만 넣으세요.
실수 3: 아무 것도 하지 않는 빈 catch로 오류를 조용히 “삼키기”.
catch에서 아무 것도 하지 않으면, 프로그램은 이상한 상태로 계속 실행될 수 있습니다: 데이터는 읽히지 않았고, 값은 계산되지 않았는데 겉보기엔 “정상”처럼 보일 수 있습니다. 이런 건 나중에 디버깅할 때 정말 고통스럽습니다. 무엇을 해야 할지 몰라도, 최소한 사용자나 자기 자신에게 이해 가능한 메시지는 출력하는 편이 낫습니다.
실수 4: 사용자에게 ${e.message}만 출력해 놓고 그게 UX라고 믿기.
예외 메시지는 종종 기술적이고, 때로는 아예 null일 수도 있습니다. 사용자에게는 “정수를 입력하세요” 또는 “0으로 나눌 수 없습니다”가 더 낫습니다. e.message는 보조 정보에 가깝고, 디버그 모드를 만들 때 괄호로 덧붙일 수 있는 정도입니다.
실수 5: try/catch가 검증(validation)을 대체한다고 기대하기.
try/catch는 도움은 되지만, 코드를 “똑똑하게” 만들지는 않습니다. 예를 들어 음수 지출을 금지하고 싶다면, 그건 예외가 아니라 비즈니스 규칙입니다. 그건 if (value < 0)로 검사하고 사용자에게 설명하는 게 낫습니다. 예외는 일반적인 로직이 아니라, 정말로 “비정상적인” 실행 상황을 위한 것입니다.
실수 6: 예외를 잡고 계속 진행하되, 상태를 복구하는 것을 잊기.
변수를 바꾸기 시작한 뒤에 무언가가 터지면, 프로그램을 “반쯤 망가진” 상태로 남길 수 있습니다. 그래서 좋은 스타일은: 먼저 입력을 받아 확인한 뒤, 그 다음에 상태를 변경하는 것입니다(예: count 증가). 우리 예시에서도 먼저 value를 읽고, 그 다음 배열에 추가합니다. 작지만 중요한 습관입니다.
GO TO FULL VERSION