지난 편에서는 enum class와 when을 엮어서 완전성 검사를 다시 짚어보고, when이 자바의 switch보다 훨씬 유연하게 쓰이는 지점들을 살펴봤습니다.
https://dmoritle.tistory.com/288?category=1362768
[Kotlin] 코틀린 온보딩 4편 - enum과 when: 완전성 검사 다시 보기
지난 편에서는 변수를 프로퍼티로 승격시키는 클래스 선언과 커스텀 접근자, 그리고 패키지·디렉터리 구조의 자유로움까지 살펴봤습니다.https://dmoritle.tistory.com/287 [Kotlin] 코틀린 온보딩 3편 -
dmoritle.tistory.com
이번 편의 핵심은 1편에서 "여전히 문인 것들"로 잠깐 언급하고 넘어간 for/while 루프와, 예외를 던지고 잡는 방법입니다.
for 루프: 범위와 컬렉션을 순회하기
코틀린의 for는 자바처럼 for (int i = 0; i < n; i++) 형태로 쓰지 않습니다. 범위(range)나 컬렉션을 순회하는 형태만 있습니다.
for (i in 1..5) {
println(i) // 1 2 3 4 5
}
끝 값을 포함하지 않으려면 until이나 ..<을 씁니다. 둘은 동일하게 동작하는데, ..<이 더 최근에 추가된 문법이고 『코틀린 인 액션』에서도 이 표기를 쓰는 만큼, 앞으로는 ..<으로 쓰겠습니다.
for (i in 0..<5) {
println(i) // 0 1 2 3 4
}
거꾸로 순회하거나 간격을 두려면 downTo, step을 씁니다.
for (i in 10 downTo 0 step 2) {
println(i) // 10 8 6 4 2 0
}

..는 Int뿐 아니라 Char에도 쓸 수 있어서, 문자 범위도 그대로 순회할 수 있습니다.
for (c in 'a'..'e') {
print(c) // abcde
}
배열이나 컬렉션은 원소를 직접 순회합니다.
val names = listOf("모리", "예진")
for (name in names) {
println(name)
}
인덱스가 필요하면 withIndex()로 인덱스와 값을 함께 꺼낼 수 있습니다.
for ((index, name) in names.withIndex()) {
println("$index: $name")
}
자바 개발자가 걸려 넘어지는 포인트: 코틀린에는 자바의 전통적인
for (int i = 0; i < n; i++)형태가 없습니다. 인덱스를 직접 증감시키며 도는 루프가 필요하다면0..<n이나list.indices처럼 범위로 표현해야 합니다. 처음에는 "루프 변수를 내가 직접 조작할 수 없다"는 게 답답하게 느껴질 수 있지만, range 자체가IntRange같은 값이라 재사용하거나 함수 인자로 넘기기 쉽다는 이점이 있습니다.
in과 !in: 포함 여부를 검사하는 연산자
for (i in 1..5)처럼 루프에 쓴 in과 똑같이 생겼지만, if 조건 같은 자리에서 쓰는 in은 역할이 다릅니다. 값이 범위나 컬렉션에 포함되는지 참/거짓으로 검사합니다. (4편에서 when의 분기 조건으로 in 1..10을 잠깐 썼던 게 바로 이 연산자입니다.)
val x = 5
if (x in 1..10) {
println("1~10 사이에 있습니다")
}
if (x !in 1..10) {
println("1~10 사이에 없습니다")
}
컬렉션에도 그대로 쓸 수 있습니다.
val names = listOf("모리", "예진")
println("모리" in names) // true
..는 Int, Char 같은 내장 타입만이 아니라, Comparable을 구현한 아무 타입에나 쓸 수 있습니다. String도 Comparable<String>을 구현하고 있어서, 문자열끼리도 바로 범위를 만들 수 있습니다.
println("Kotlin" in "Java".."Scala") // true
사전 순으로 "Java" ≤ "Kotlin" ≤ "Scala"이기 때문에 참이 됩니다. 다만 이렇게 만든 범위는 "포함 여부"만 검사할 수 있고, for로 순회할 수는 없습니다.
겉보기엔 같은 in이지만, 루프의 in은 순서대로 값을 꺼내는 순회이고, if의 in은 "포함되어 있는가"만 확인하는 검사라 동작 방식이 다릅니다. Int/Char처럼 순회까지 가능하려면 Comparable만으로는 부족하고 별도의 순회 규칙(Iterable)이 필요한데, 이 둘을 직접 구현하는 방법(연산자 오버로딩)은 이후 편에서 다루겠습니다.
while과 do-while
while, do-while은 자바와 문법이 같습니다.
var count = 0
while (count < 3) {
println(count)
count++
}
do-while은 조건 검사 전에 본문을 최소 한 번 실행합니다.
do {
println("최소 한 번은 실행됩니다")
} while (false)
for/while은 여전히 문이다
1편에서 for, while은 값을 반환하지 않으므로 문이라고 짚었습니다. enum과 when을 다루면서 식의 세계를 넓혀왔지만, 루프만큼은 예외 없이 이 규칙을 따릅니다.
val x = for (i in 1..10) {} // 컴파일 에러! for는 값이 없음
throw도 식이다
의외로 throw는 문이 아니라 식입니다. throw의 타입은 Nothing이라는 특별한 타입인데, 모든 타입의 하위 타입이라 어떤 자리에도 들어갈 수 있습니다.
val name = person.name ?: throw IllegalArgumentException("이름이 없습니다")
엘비스 연산자(
?:): 2편에서 잠깐 소개했던 연산자로, "왼쪽 값이 null이면 오른쪽 값을 대신 사용한다"는 뜻입니다. 즉person.name이 null이 아니면 그 값을 그대로 쓰고, null이면 오른쪽 식을 평가한 결과를 씁니다. 보통은 오른쪽에 기본값을 넣지만, 여기서는 그 자리에throw식이 들어가 있습니다.
엘비스 연산자의 오른쪽은 String 타입의 값이 와야 할 자리인데, throw 식이 그 자리에 들어갈 수 있는 이유가 바로 Nothing이 모든 타입과 호환되기 때문입니다. if의 한쪽 분기에 throw를 넣는 것도 같은 원리입니다.
fun describe(age: Int): String
= if (age >= 0) "나이: $age" else throw IllegalArgumentException("나이는 음수일 수 없습니다")
주의할 점:
Nothing은 "값이 없다"는 뜻의Unit과 다릅니다.Unit은 "의미 있는 값은 없지만 정상적으로 끝난다"는 뜻이고,Nothing은 "이 지점에 절대 도달하지 못한다"(예외를 던지거나 무한 루프에 빠짐)는 뜻입니다. 이 둘의 차이는 별도 포스트에서 더 자세히 다루겠습니다.
try-catch-finally: 이미 식으로 다룬 문법
try가 식으로 쓰일 수 있다는 건 1편에서 다뤘습니다. if, when과 마찬가지로 try도 그 결과를 바로 변수에 대입할 수 있고, try·catch 블록의 마지막 식이 전체 결과값이 됩니다.
val num = try {
"abc".toInt()
} catch (e: NumberFormatException) {
0
}
// num은 0 (예외가 나서 catch 블록의 마지막 식이 결과가 됨)
finally를 덧붙여도 식으로 쓰는 데는 문제가 없습니다. finally는 결과값에 관여하지 않고, 예외 발생 여부와 무관하게 항상 실행되기만 합니다.
fun parseOrDefault(s: String): Int {
return try {
s.toInt()
} catch (e: NumberFormatException) {
0
} finally {
println("파싱 시도 완료")
}
}
try 블록은 catch나 finally 중 최소 하나는 있어야 합니다.
자바와 다른 점 하나는, 코틀린에는 여러 예외 타입을 한 번에 잡는 멀티캐치 문법이 없다는 것입니다. 자바의 catch (IOException | SQLException e)에 해당하는 문법이 없어서, 타입별로 catch 블록을 따로 써야 합니다.
try {
withdraw(amount)
} catch (e: InsufficientFundsException) {
println("잔액 부족: ${e.message}")
} catch (e: WithdrawalException) {
println("잘못된 출금: ${e.message}")
}
커스텀 예외 클래스
예외도 클래스이므로, Exception이나 그 하위 타입을 상속해서 직접 정의할 수 있습니다.
class InsufficientFundsException(message: String) : Exception(message)
fun withdraw(amount: Int, balance: Int) {
if (amount > balance) {
throw InsufficientFundsException("잔액이 부족합니다")
}
}
클래스 상속 문법(: 뒤에 부모 클래스)은 아직 다루지 않았으니, 지금은 "이런 형태로 쓴다"는 정도로만 봐두면 됩니다. 상속 자체는 이후 편에서 자세히 다루겠습니다.
checked exception이 없다
자바는 IOException처럼 복구 가능성이 있는 예외를 checked exception으로 구분해서, 메서드 시그니처에 throws를 명시하거나 반드시 catch하도록 컴파일러가 강제합니다. 코틀린은 이 구분이 아예 없습니다. 모든 예외가 unchecked이고, throws 선언도 없습니다.
// 자바: IOException을 throws로 선언하거나 catch해야 컴파일됨
void readFile() throws IOException { ... }
// 코틀린: 예외를 명시하지 않아도 컴파일됨
fun readFile() {
File("data.txt").readText() // IOException이 날 수 있지만 강제되는 것 없음
}
자바 개발자가 걸려 넘어지는 포인트: 자바에서 몸에 밴 "checked exception은 반드시 처리하거나 던지거나"라는 습관을 코틀린에서는 컴파일러가 챙겨주지 않습니다. 예외가 날 수 있는 코드를 호출하고도 아무
catch없이 컴파일이 통과되니, 처리가 꼭 필요한 예외라면 문서나 코드 리뷰로 챙겨야 합니다. 이는 실수를 줄이려던 checked exception이 오히려 무분별한throws남발이나 빈catch블록으로 이어지는 경우가 많다는 코틀린 설계진의 판단에 따른 것입니다.
정리
| 구분 | 설명 |
| for | 범위/컬렉션 순회 전용. ..<(= until), downTo, step, withIndex() |
| while / do-while | 자바와 동일한 문법 |
| for/while | 값을 반환하지 않는 문 (1편 규칙 그대로) |
| throw | Nothing 타입의 식. ?:, if 분기 등 값이 필요한 자리에 쓸 수 있음 |
| try-catch-finally | try는 식으로 쓸 수 있음(1편). 멀티캐치 문법 없음 |
| checked exception | 코틀린에는 없음. 모든 예외가 unchecked, throws 선언 불필요 |
마무리
여전히 문으로 남아있는 for/while 루프의 순회 방식과, 식으로도 쓰이는 throw, 그리고 자바와 다른 코틀린의 예외 처리 방식(멀티캐치 없음, checked exception 없음)까지 살펴봤습니다. 다음 편에서는 기본값 있는 파라미터, 이름 붙인 인자 같은 함수 호출과 관련된 문법들을 다뤄보겠습니다.
이 시리즈는 『코틀린 인 액션(Kotlin in Action)』을 참고하여 작성했습니다.
이번 편에서 다룬 내용은 아래 문서로도 검증했습니다.
'Language > Kotlin' 카테고리의 다른 글
| [Kotlin] 코틀린 온보딩 7편 - 확장 함수: 바이트코드로 확장 프로퍼티까지 이해하기 (0) | 2026.09.26 |
|---|---|
| [Kotlin] 코틀린 온보딩 6편 - 함수 호출: 이름 있는 인자부터 infix까지 이해하기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 4편 - enum과 when: 완전성 검사 다시 보기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 3편 - 클래스와 프로퍼티: 커스텀 접근자로 이해하기 (0) | 2026.09.26 |
| [Kotlin] 코틀린 온보딩 2편 - 변수: val/var와 문자열 템플릿으로 이해하기 (1) | 2026.09.06 |