코틀린(Kotlin)은 Java 가상 머신(JVM) 위에서 돌아가며 자바와 100% 호환되는 간결하고 안전한 현대적인 언어입니다.
기존 자바(Java)에서는 단순한 글자 하나를 출력하기 위해서도 무조건 클래스를 만들고, 길고 긴 public static void main을 적어야만 했습니다. 하지만 코틀린은 최상위 레벨(Top-level) 함수를 지원하므로, 불필요한 껍데기 없이 바로 fun main() 함수만 작성하여 프로그램을 시작할 수 있습니다.
코틀린은 자바 개발자들이 지긋지긋하게 쓰던 보일러플레이트(의미 없이 반복되는 코드)를 과감하게 다이어트했습니다.
쓸데없는 class Main 포장지도 버렸고, 너무 길었던 System.out.println도 println으로 줄였으며, 문장 끝마다 강박적으로 찍어야 했던 세미콜론(;)마저 없애버렸습니다.
| 비교 항목 | Java | Kotlin |
|---|---|---|
| 프로그램 시작점 | 클래스 내부의 public static void main() |
클래스 밖의 독립적인 fun main() |
| 콘솔 출력 | System.out.println() |
println() |
| 세미콜론 (;) | 문장 끝에 필수 | 생략 가능 (권장) |
| 함수 선언 키워드 | 반환 타입 (예: void) |
fun (function의 약자) |
// 코틀린 프로그램의 진입점은 무조건 main 함수입니다.
// 자바처럼 억지로 class Main { ... } 포장지를 만들 필요가 없습니다. (Top-level 함수)
// 함수를 선언할 때는 function의 약자인 'fun' 키워드를 사용합니다.
fun main() {
// System.out.println() 대신 아주 간결한 println()을 지원합니다.
println("Hello, Kotlin World!")
// 문장이 끝날 때 세미콜론(;)을 붙이지 않아도 됩니다!
println("코틀린은 자바보다 간결합니다.")
}
자바에서는 변수를 선언할 때 항상 int, String처럼 타입을 먼저 명시해야 했습니다. 하지만 코틀린은 아주 똑똑한 타입 추론(Type Inference) 능력을 갖추고 있어서 굳이 타입을 적어주지 않아도 스스로 알아냅니다.
코틀린의 변수 선언은 크게 두 가지, val(Value)과 var(Variable)로 나뉩니다. 프로그램의 버그를 줄이고 안전성을 높이기 위해, 코틀린은 기본적으로 값이 변하지 않는 val을 사용하는 것을 강력하게 권장합니다.
val (Value): 한 번 물건을 넣으면 자물쇠로 잠가버리는 '안전 금고'입니다. 중간에 값을 절대 바꿀 수 없습니다. (Java의 final과 동일)
var (Variable): 뚜껑이 열려있어서 언제든 내용물을 뺐다 꼈다 할 수 있는 '열린 상자'입니다. 값이 수시로 변하는 카운터 등에 사용합니다.
| 문법 / 특징 | 설명 및 예시 |
|---|---|
| 타입 추론 | val price = 5000 우측의 값을 보고 스스로 Int임을 파악합니다. (생략 가능) |
| 명시적 선언 | 타입을 꼭 쓰고 싶다면 변수명 뒤에 콜론(:)을 씁니다.val name: String = "Alice" |
| 문자열 템플릿 ($) | Java처럼 귀찮게 +로 더하지 않고, 문자열 안에 $변수명을 쓰면 바로 치환됩니다.println("가격은 $price 원") |
fun main() {
// 1. val: 불변 변수 (Read-only, Value)
// 우측의 값(5000)을 보고 자동으로 정수(Int) 타입으로 똑똑하게 '추론'합니다.
val popcornPrice = 5000
// popcornPrice = 6000 // (에러!) val로 선언된 변수는 자물쇠가 채워져 값을 절대 변경할 수 없습니다.
// 2. var: 가변 변수 (Mutable, Variable)
var ticketCount = 2
println("현재 티켓 수: " + ticketCount)
ticketCount = 3 // 뚜껑이 열려있는 상자이므로 값 변경이 자유롭습니다.
println("변경 후 티켓 수: " + ticketCount)
// 3. 명시적으로 타입을 선언할 수도 있습니다. (변수명: 타입)
val movieName: String = "인터스텔라"
val rating: Double = 9.8
// 4. 문자열 템플릿 ($ 기호)
// 자바처럼 번거롭게 + 기호로 변수를 이어 붙일 필요 없이, 문자열 안에 바로 변수명을 적어줍니다.
println("영화명: $movieName, 평점: $rating, 티켓 수: $ticketCount")
}
코틀린의 컬렉션 설계 철학은 "안전제일주의"입니다. 자바와 다르게 컬렉션을 만들 때부터 불변(Read-only)과 가변(Mutable)을 엄격하게 분리하여 강제합니다.
기본 함수인 listOf(), setOf(), mapOf()로 만든 컬렉션은 읽기만 가능하며 절대 값을 추가하거나 삭제할 수 없습니다. 내용물을 변경해야 한다면 반드시 이름 앞에 mutable이 붙은 mutableListOf() 등을 사용해야 합니다.
listOf()는 자물쇠로 잠겨 있는 유리 전시용 진열장입니다. 밖에서 구경(Read)만 할 수 있고, 물건을 빼거나 넣을 수 없습니다. (데이터 오염 방지)
mutableListOf()는 물건을 자유롭게 넣고 뺄 수 있는 작업용 오픈 선반입니다.
| 자료구조 | 불변 (Read-only) | 가변 (Mutable) |
|---|---|---|
| List (순서O, 중복O) | listOf() |
mutableListOf() |
| Set (순서X, 중복X) | setOf() |
mutableSetOf() |
| Map (Key-Value) | mapOf(A to B) |
mutableMapOf(A to B) |
fun main() {
// 1. List (순서 보장, 중복 허용)
// 읽기 전용(Immutable) 리스트
val readOnlyList = listOf("Apple", "Banana", "Cherry")
println("읽기 전용: " + readOnlyList)
// readOnlyList.add("Orange")
// 컴파일 에러! listOf로 만들면 add(), remove() 같은 메서드 자체가 존재하지 않습니다.
// 가변(Mutable) 리스트
val mutableList = mutableListOf("Apple", "Banana")
mutableList.add("Cherry") // 추가 가능!
println("수정 가능: " + mutableList)
// 2. Map (Key-Value 쌍)
// 'to' 라는 특별한 키워드(중위 연산자)를 사용하여 매우 직관적으로 키-값 쌍을 연결합니다.
val menuPrices = mutableMapOf(
"아메리카노" to 4000,
"카페라떼" to 4500
)
// 값을 추가하거나 읽을 때 자바의 put(), get() 대신
// 마치 배열을 다루듯 대괄호[]를 사용할 수 있습니다.
menuPrices["바닐라라떼"] = 5000 // 값 추가
println("아메리카노 가격: " + menuPrices["아메리카노"]) // 값 읽기
}
코틀린에서 if와 when은 단순한 문장(Statement)이 아니라, 결과를 즉시 반환할 수 있는 표현식(Expression)입니다. 즉, 조건문의 결과를 변수에 바로 대입할 수 있어 불필요한 변수 선언을 줄일 수 있습니다.
또한 자바에서 장황하고 버그를 유발하기 쉬웠던 switch문은 코틀린에서 when으로 완벽하게 진화했습니다. break 키워드가 전혀 필요 없고, 훨씬 유연하게 조건을 검사할 수 있습니다.
자바의 if는 단지 행동을 지시(Statement)할 뿐이라서, int max; if(...) max=a; else max=b; 처럼 코드를 길게 짜거나 삼항 연산자를 써야 했습니다.
코틀린의 if와 when은 스스로 값을 만들어내는 수식(Expression)이므로, val max = if(...) a else b 처럼 깔끔하게 변수에 직행(대입)할 수 있습니다.
| 비교 항목 | Java (switch) | Kotlin (when) |
|---|---|---|
| break 키워드 | 안 쓰면 아래로 줄줄이 실행됨 (치명적 버그 원인) | break가 아예 필요 없음 (조건 맞으면 자동 탈출) |
| 조건의 유연성 | 단순한 값(상수, 문자열)만 매칭 가능 | 범위(in), 타입 확인(is), 복잡한 수식 등 모든 것 매칭 가능 |
| 기본값(default) | default: |
else -> |
fun main() {
// 1. 표현식(Expression)으로서의 if
val a = 10
val b = 20
// 코틀린에서는 삼항 연산자( a > b ? a : b )가 없는 대신,
// if문 전체의 마지막 줄 결과가 변수에 그대로 대입됩니다!
val max = if (a > b) {
println("a가 큽니다.")
a // <- 이 값이 리턴됨
} else {
println("b가 크거나 같습니다.")
b // <- 이 값이 리턴됨
}
println("최대값: $max")
// 2. 강력하고 안전한 when 표현식 (switch의 완벽한 진화)
val score = 85
val grade = when {
score >= 90 -> "A" // "조건 -> 결과" 문법 사용 (break 불필요!)
score >= 80 -> "B"
score >= 70 -> "C"
else -> "F" // when 결과를 변수에 담을 때는 모든 경우의 수를 커버해야 에러가 안 납니다.
}
println("당신의 학점은 $grade 입니다.")
// 3. 값, 타입 매칭 등 무엇이든 검사하는 when
val obj: Any = "Hello" // Any는 자바의 Object와 같습니다.
when (obj) {
1 -> println("숫자 1입니다.")
"Hello" -> println("인삿말입니다.")
is String -> println("문자열 타입입니다.") // 타입 확인도 is 로 곧바로 가능!
else -> println("기타 등등")
}
}
코틀린에서 함수는 fun 키워드로 시작합니다. 자바의 무거운 메서드 구조를 벗어던지고, 훨씬 유연하고 강력한 파라미터(매개변수) 시스템을 갖추고 있습니다.
특히 기본값(Default Arguments)과 이름 지정(Named Arguments) 기능을 지원하여, 자바 개발자들을 괴롭혔던 '오버로딩 지옥(똑같은 이름의 메서드를 파라미터 개수별로 수십 개씩 만드는 현상)'에서 완벽하게 해방시켜 줍니다.
자판기에 '돈'만 넣고 '음료수 버튼'을 안 누르면, 코틀린 자판기는 에러를 뱉는 대신 미리 설정된 기본값(예: 물)을 알아서 뱉어줍니다.
덕분에 사용자는 옵션이 많은 함수라도 꼭 필요한 값만 넘겨주면 되며, 순서를 무시하고 음료수 = "콜라" 처럼 이름표(Named)를 달아 전달할 수도 있습니다.
| 기능명 | 문법 | 장점 |
|---|---|---|
| Default Arguments (기본값) | fun greet(msg: String = "안녕") |
값을 안 넘기면 미리 정해둔 "안녕"이 대신 들어감. (자바의 오버로딩 대체) |
| Named Arguments (이름 지정) | greet(name = "Kim", msg = "Hi") |
인자가 많을 때, 순서를 무시하고 명시적으로 매핑 가능. (가독성 극대화) |
// 1. 기본 함수 선언 (파라미터명: 타입): 반환타입
// 자바의 메서드와 구조가 비슷하지만 'fun' 키워드를 사용하고 반환타입이 뒤에 붙습니다.
fun sum(a: Int, b: Int): Int {
return a + b
}
// 2. 단일 표현식 함수 (Single Expression Function)
// 함수 안의 로직이 단 한 줄이라면, 중괄호{}와 return 키워드를 없애고
// 등호(=)를 써서 극도로 짧게 줄일 수 있습니다! (반환타입도 추론되므로 생략 가능)
fun multiply(a: Int, b: Int) = a * b
// 3. 기본값을 갖는 매개변수 (Default Arguments)
// 호출자가 message를 넘기지 않으면, 기본값인 "안녕하세요"가 자동으로 들어갑니다.
// 자바처럼 메서드 오버로딩을 여러 개 만들 필요가 없습니다!
fun greet(name: String, message: String = "안녕하세요") {
println("$name 님, $message")
}
fun main() {
println("합계: " + sum(10, 20))
println("곱셈: " + multiply(5, 4))
// 파라미터 1개만 던짐 -> 나머지 하나는 Default 값이 작동!
greet("홍길동")
// 4. 이름 붙인 인자 (Named Arguments)
// 파라미터가 5~6개 넘어갈 때, 순서를 외울 필요 없이 변수명을 직접 지정해서 던집니다.
// 심지어 순서를 바꿔치기 해도 아무 문제 없이 작동합니다.
greet(message = "반갑습니다", name = "이순신")
}
자바에서 데이터 클래스 하나를 만들려면 필드 선언, 생성자(Constructor) 작성, 수많은 Getter/Setter 메서드까지 작성해야 해서 코드가 끝없이 길어졌습니다.
코틀린은 이를 단 한 줄로 압축하는 주 생성자(Primary Constructor)를 도입했습니다. 클래스 이름 옆의 괄호에 val이나 var를 적어 넣기만 하면, 필드 선언과 생성자 할당, 심지어 Getter/Setter까지 내부적으로 한 번에 자동 생성됩니다.
자바의 장황한 클래스 설계도(필드 5줄 + 생성자 5줄 + Getter/Setter 20줄)를 코틀린의 압축 프레스기인 클래스명 옆의 소괄호 ()에 넣고 누르면, 단 한 줄의 깔끔한 캡슐 코드로 압축되어 튀어나옵니다.
| 비교 항목 | Java | Kotlin |
|---|---|---|
| 인스턴스 생성 | Person p = new Person(); |
val p = Person() (new 생략) |
| 필드 값 읽기 (Getter) | p.getName() |
p.name (프로퍼티 접근) |
| 필드 값 쓰기 (Setter) | p.setAge(20) |
p.age = 20 (프로퍼티 접근) |
| 생성 시 초기화 블록 | 생성자 내부 | init { ... } 블록 |
// 주 생성자(Primary Constructor)를 이용한 초간단 클래스 선언
// 클래스 이름 옆의 괄호가 주 생성자 역할을 하며, val/var를 붙이면 해당 변수들은
// 내부 필드로 선언됨과 동시에 Getter(val의 경우)와 Setter(var의 경우)까지 자동 완성됩니다!
class Person(val name: String, var age: Int) {
// 인스턴스가 생성될 때 제일 먼저 실행되는 초기화 블록
init {
println("새로운 사람(${name})이 생성되었습니다.")
}
// 멤버 함수
fun introduce() {
println("제 이름은 $name 이고 나이는 $age 입니다.")
}
}
fun main() {
// 1. 객체 생성 시 자바의 거추장스러운 'new' 키워드를 쓰지 않습니다!
// 마치 함수를 호출하듯 아주 자연스럽게 객체를 만듭니다.
val p1 = Person("Alice", 25)
p1.introduce()
// 2. 프로퍼티 접근 방식
// 코틀린은 p1.getName(), p1.setAge() 같은 메서드를 직접 부르지 않습니다.
// 마침표(.)를 찍어 필드에 직접 접근하듯 코드를 작성하면,
// 코틀린 컴파일러가 알아서 내부적으로 Getter와 Setter를 호출해 줍니다.
println("이름 읽기: " + p1.name) // 내부적으로 p1.getName() 호출
p1.age = 26 // 내부적으로 p1.setAge(26) 호출
println("나이 증가: " + p1.age)
}
자바 개발자들의 가장 큰 적은 바로 NullPointerException (NPE) 입니다. 코틀린은 언어 차원에서 이 폭탄을 완전히 제거했습니다.
코틀린의 모든 변수는 기본적으로 null을 가질 수 없도록 방어막이 쳐져 있습니다. 꼭 null이 들어가야 한다면 타입 뒤에 물음표(?)를 붙여야 하며, 이를 안전하게 다룰 수 있는 특수한 연산자들을 제공합니다.
안전한 호출 ?. : 지뢰(null)가 있는지 톡톡 두드려 봅니다. 지뢰면 터지지 않고 그냥 null을 뱉고 실행을 취소합니다.
엘비스 연산자 ?: : 만약 앞의 값이 null이라면, 미리 준비해둔 안전한 우회로(기본값)로 빠져나가게 해 줍니다.
| 기호 | 이름 | 설명 |
|---|---|---|
| ? | Nullable 타입 | String? 처럼 타입 뒤에 붙여서 null을 허용하게 만듭니다. |
| ?. | 안전한 호출 (Safe Call) | 객체가 null이면 메서드를 실행하지 않고 null을 반환합니다. |
| ?: | 엘비스 연산자 (Elvis) | 좌항이 null일 경우, 우항의 기본값을 대신 반환합니다. |
fun main() {
// 1. 코틀린의 기본 타입은 절대 null을 가질 수 없는 'Non-Null' 타입입니다.
var nonNullString: String = "Hello"
// nonNullString = null // (컴파일 에러!) 폭탄 자체를 들이지 못하게 막습니다.
// 2. Nullable 타입 (타입명 뒤에 ?)
// 정말로 null이 필요하다면 타입 뒤에 물음표를 붙여 허락을 받아야 합니다.
var nullableString: String? = "Kotlin"
nullableString = null // 정상적으로 null 대입 가능
// 3. 안전한 호출 연산자 (?.)
// 변수가 null 일지도 모르는 상태에서 강제로 메서드(length)를 호출하면 원래 NPE가 터집니다.
// 하지만 ?. 를 쓰면 "null이면 뒤의 length를 실행하지 말고 그냥 null을 리턴해라"가 됩니다.
val length = nullableString?.length
println("문자열의 길이는? " + length) // 런타임 에러 없이 null 이 출력됨
// 4. 엘비스 연산자 (?:) - 기호 모양이 엘비스 프레슬리 머리를 닮아 붙여진 이름
// null이 떨어졌을 때, 프로그램 로직 상 null을 계속 끌고 가기보다
// 기본값(0)으로 안전하게 교체하고 싶을 때 사용합니다.
val safeLength = nullableString?.length ?: 0
println("안전한 문자열의 길이는? " + safeLength) // null 대신 0 이 출력됨
}
만약 자바에서 제공하는 기본 String 클래스에 내가 원하는 커스텀 기능을 추가하고 싶다면 어떻게 해야 할까요? 자바에서는 보통 StringUtils 같은 잡동사니 클래스를 만들어 정적(Static) 메서드로 우회해야 했습니다.
코틀린에서는 확장 함수(Extension Functions)를 통해, 이미 남이 만들어둔 클래스(원본 소스코드)를 전혀 건드리지 않고도 마치 내가 직접 멤버 함수를 하나 더 만든 것처럼 감쪽같이 기능을 갖다 붙일 수 있습니다.
공장에서 출시된 멀티툴(기본 클래스)을 뜯어서 분해하지 않아도 됩니다. String.나만의함수() 라는 문법의 '마법의 접착제'를 바르면, 그 멀티툴 끝에 나만의 도구를 철컥 하고 붙여서 다른 기본 기능들처럼 자유롭게 뽑아 쓸 수 있습니다.
| 방식 | 호출 코드 예시 | 특징 |
|---|---|---|
| Java (Utility Class) | StringUtils.removeFirstLast(myText) |
인자로 객체를 넘겨야 해서 직관적이지 않고, 수많은 Utils 파일이 양산됨. |
| Kotlin (Extension) | myText.removeFirstLast() |
마치 원래 있던 멤버 함수인 것처럼 매우 자연스럽게 체이닝 호출 가능. |
// String 클래스에 나만의 커스텀 함수(removeFirstLast)를 갖다 붙입니다.
// 수신 객체 타입(이 경우 String)에 마침표를 찍고 내가 원하는 함수명을 정의합니다.
// 함수 내부에서 쓰이는 'this' 키워드는 이 함수를 호출하는 대상 문자열 자체를 가리킵니다.
fun String.removeFirstLast(): String {
// 길이가 2보다 작으면 양 끝을 자를 수 없으니 그냥 원본을 반환
if (this.length <= 2) return this
// 첫 번째 글자(인덱스 1)부터 맨 마지막 글자 직전(length - 1)까지 자름
return this.substring(1, this.length - 1)
}
fun main() {
val myText = "Hello Kotlin"
// 원래 String 클래스에 내장되어 있던 기본 메서드인 것처럼,
// 마침표(.)를 찍고 아주 자연스럽게 호출 가능!
val result = myText.removeFirstLast()
println("원본: " + myText)
println("확장 함수 적용 결과: " + result) // 앞뒤 H와 n이 잘린 "ello Kotli" 출력
}
코틀린에는 객체의 이름을 일일이 반복하지 않고, 특정 블록(스코프) 안에서 우아하게 코드를 묶어서 처리할 수 있도록 도와주는 5가지 마법의 함수(let, apply, run, with, also)가 있습니다.
이 함수들을 사용하면 객체의 초기화 셋팅을 하나로 묶거나, null 체크 로직을 간소화하는 등 코틀린 특유의 가독성 높고 세련된 체이닝 코드를 작성할 수 있습니다.
스코프 함수를 쓴다는 것은 특정 객체 위에 '조명(Scope)'을 켜는 것과 같습니다. 조명이 켜진 중괄호 {} 내부에서는 계속 robot.name, robot.battery 처럼 주어(robot)를 반복할 필요 없이, 바로 name, battery 처럼 핵심 부품만 만지면서 조립할 수 있습니다.
| 함수 | 수신 객체 참조 | 반환값 | 주요 사용 목적 |
|---|---|---|---|
| let | it (생략 불가) |
람다의 마지막 줄 | ?.let 형태로 Null 체크 후 무언가 실행할 때 |
| apply | this (생략 가능) |
객체 자신 | 객체 생성과 동시에 내부 프로퍼티들을 쫙 셋팅할 때 |
| run | this (생략 가능) |
람다의 마지막 줄 | 객체 초기화와 동시에 계산 결과를 반환받아야 할 때 |
class Robot(var name: String, var battery: Int) {
fun status() = "이름: $name, 배터리: $battery%"
}
fun main() {
// 1. apply: 객체 생성과 동시에 초기화 셋팅을 할 때 가장 많이 쓰입니다.
// 블록 안에서는 'this'로 접근하기 때문에 this를 생략하고 곧바로 필드명을 쓸 수 있습니다.
// 셋팅이 끝난 후에는 셋팅 완료된 객체 자신(r1)을 반환합니다.
val r1 = Robot("R2D2", 10).apply {
battery = 100 // this.battery = 100 과 동일
name = "Super R2D2" // this.name = "Super R2D2" 과 동일
}
println(r1.status())
// 2. let: 안전한 호출(?.)과 함께 연계하여 값이 null이 아닐 때만 블록을 실행할 때 주로 쓰입니다.
// 블록 안에서는 'it' 키워드로 객체에 접근합니다.
var nullableName: String? = "Kotlin"
val lengthMessage = nullableName?.let {
println("문자열 $it 이(가) null이 아닙니다!")
"길이는 ${it.length} 입니다." // 블록의 가장 마지막 줄 수식 결과가 반환되어 lengthMessage에 들어갑니다.
}
println(lengthMessage)
}
코틀린에서 함수는 특별한 취급을 받는 일급 시민(First-class citizen)입니다. 즉, 숫자를 변수에 담듯 함수 그 자체를 변수에 저장할 수 있고, 다른 함수의 파라미터로 넘겨줄 수도 있습니다.
이처럼 함수를 파라미터로 받거나 반환하는 함수를 고차 함수(Higher-Order Function)라 부르며, 이름 없이 중괄호 {}로 묶여서 전달되는 함수 블록을 람다(Lambda)라고 부릅니다.
고차 함수는 뼈대만 있고 배터리 슬롯이 비어 있는 '장난감 공장 기계'입니다.
람다(Lambda)는 우리가 직접 로직을 짜서 그 슬롯에 끼워 넣는 '배터리 블록'입니다. 코틀린은 마지막 파라미터가 배터리(람다)라면 괄호 밖으로 빼서 끼울 수 있는 매우 예쁜 문법(Trailing Lambda)을 지원합니다.
| 개념 | 문법 / 예시 | 설명 |
|---|---|---|
| 함수 타입 | (Int, Int) -> Int |
정수 2개를 받아서 정수 1개를 반환하는 함수의 설계도(타입)입니다. |
| 람다식 | { x, y -> x + y } |
이름이 없는 익명 함수입니다. 화살표(->) 왼쪽이 파라미터, 오른쪽이 몸통입니다. |
단일 파라미터 it |
{ println(it) } |
람다의 파라미터가 딱 1개라면 x -> 를 생략하고 it 이라는 키워드로 씁니다. |
// 1. 고차 함수 (Higher-Order Function) 선언
// 세 번째 파라미터 'action'은 (Int, Int)를 받아서 Int를 반환하는 '함수 타입'을 요구합니다.
fun calculate(a: Int, b: Int, action: (Int, Int) -> Int): Int {
// 뼈대 기계가 작동하다가, 외부에서 주입받은 함수(배터리)를 여기서 실행시킵니다.
val result = action(a, b)
return result
}
fun main() {
// 2. 람다식을 변수에 저장하기
// 함수를 숫자나 문자열처럼 변수 sumLambda 에 저장합니다.
val sumLambda: (Int, Int) -> Int = { x, y -> x + y }
// 저장된 함수를 다른 함수의 파라미터로 넘겨줍니다.
println("람다 변수 테스트: " + calculate(10, 5, sumLambda))
// 3. 트레일링 람다 (Trailing Lambda)의 미학
// calculate의 마지막 파라미터가 '함수'이기 때문에, 소괄호()를 닫아버리고
// 그 뒤에 중괄호{}를 열어서 코드를 작성할 수 있습니다.
// (코틀린의 가독성을 극대화시키는 아주 중요한 문법입니다!)
val multiResult = calculate(10, 5) { x, y ->
x * y // 람다 블록의 가장 마지막 줄이 함수의 반환값이 됩니다.
}
println("트레일링 람다(곱셈): " + multiResult)
}
코틀린은 자바에서 빈번하게 일어나는 상속 오남용을 막기 위해 모든 클래스가 기본적으로 상속 불가(final) 상태로 잠겨 있습니다. 부모 클래스가 되려면 명시적으로 open 키워드를 붙여 상속을 허락해야 합니다.
반면 인터페이스(Interface)는 자바 8과 유사하게 구현체가 없는 추상 메서드뿐만 아니라, 본문이 있는 디폴트 메서드와 추상 프로퍼티까지 가질 수 있어 매우 유연한 설계가 가능합니다.
자바는 아무 클래스나 문이 열려 있어서 무분별한 상속(무단침입)이 일어났습니다.
코틀린은 모든 클래스의 문이 final이라는 자물쇠로 잠겨 있습니다. 누군가 나를 상속하게 놔두려면 반드시 내 이름 앞에 open (열쇠)을 걸어두어야 합니다.
| 동작 | Java 키워드 | Kotlin 키워드 |
|---|---|---|
| 클래스 상속 | extends |
콜론( : ) 사용 |
| 인터페이스 구현 | implements |
동일하게 콜론( : ) 사용 |
| 메서드 재정의 | @Override (어노테이션) |
override (키워드 강제) |
// 1. 인터페이스 정의
// 자바와 달리 프로퍼티(val maxSpeed)를 추상화할 수 있습니다.
interface Flyable {
val maxSpeed: Int
fun fly() // 추상 메서드
// 본문이 있는 디폴트 메서드도 인터페이스에 작성 가능
fun landing() {
println("안전하게 착륙합니다.")
}
}
// 2. 상속을 허용하는 open 클래스
// 코틀린 클래스는 기본이 final이므로, 부모가 되려면 반드시 'open'을 써야 합니다.
// 메서드 역시 오버라이딩을 허용하려면 'open'을 붙여야 합니다.
open class Animal(val name: String) {
open fun makeSound() {
println("$name 가 소리를 냅니다.")
}
}
// 3. 상속과 인터페이스 다중 구현
// 자바의 extends, implements 대신 콜론( : ) 하나로 통일합니다.
// Animal은 클래스이므로 생성자()를 호출해야 하고, Flyable은 인터페이스이므로 이름만 적습니다.
class Bird(name: String, override val maxSpeed: Int) : Animal(name), Flyable {
// 자바의 @Override 어노테이션 대신, 코틀린은 override라는 '키워드'를 강제합니다.
override fun makeSound() {
println("짹짹!")
}
override fun fly() {
println("$name 가 최대 속도 $maxSpeed 로 하늘을 납니다!")
}
}
fun main() {
val sparrow = Bird("참새", 50)
sparrow.makeSound()
sparrow.fly()
sparrow.landing() // 인터페이스에 구현된 디폴트 메서드 호출
}
자바에서 싱글톤(Singleton) 패턴을 구현하려면 private constructor, getInstance() 등 복잡한 보일러플레이트 코드를 작성해야 했습니다. 코틀린은 class 대신 object 키워드 단 하나로 이 모든 것을 끝냅니다.
또한 코틀린에는 자바의 static 키워드가 아예 삭제되었습니다. 대신 클래스 내부에 companion object(동반 객체)를 만들어, 객체지향의 원칙을 해치지 않으면서도 정적(Static) 멤버의 역할을 완벽하게 대체합니다.
object는 붕어빵 틀(클래스)이 아니라 '이미 만들어져 존재하는 단 하나의 거대한 붕어빵'입니다. 언제 어디서든 그 이름 자체로 부르면 됩니다.
companion object는 붕어빵 틀(클래스) 옆에 딱 붙어 있는 '매니저'입니다. 클래스의 인스턴스(붕어빵)가 없어도 매니저에게 상수나 팩토리 메서드를 요구할 수 있습니다.
| 키워드 | 의미와 용도 | 호출 방식 |
|---|---|---|
| object | 완벽한 싱글톤(Singleton) 객체 생성. 앱 전체에서 1개만 유지해야 하는 매니저급 기능에 사용. | Manager.doSomething() |
| companion object | 클래스 내부의 정적(Static) 멤버 공간. 팩토리 메서드나 클래스 공통 상수를 넣을 때 사용. | User.create() |
// 1. 싱글톤 선언 (object)
// class 대신 object 키워드를 쓰면, 메모리 상에 딱 1개만 존재하는 싱글톤 객체가 즉시 생성됩니다.
object DataBaseManager {
var isConnected = false
fun connect() {
isConnected = true
println("데이터베이스에 연결되었습니다!")
}
}
// 2. 동반 객체 (Companion Object)
class User(val name: String) {
// 코틀린에는 static 키워드가 없습니다.
// 대신 클래스 안에 companion object를 두어 자바의 static 영역처럼 사용합니다.
companion object {
val MAX_AGE = 120 // 공통 상수 (Java의 static final)
// 팩토리 메서드 (Java의 static 메서드)
fun createGuest(): User {
return User("Guest_001")
}
}
}
fun main() {
// object는 인스턴스를 생성할 필요가 없습니다. 이름 그 자체로 바로 접근합니다.
DataBaseManager.connect()
println("연결 상태: " + DataBaseManager.isConnected)
// companion object 내부에 있는 멤버는 소속 클래스의 이름으로 직접 접근합니다.
println("사람의 최대 수명 한계: " + User.MAX_AGE)
val guest = User.createGuest()
println("환영합니다, " + guest.name)
}
코틀린은 특정 용도에 최적화된 특수 클래스들을 제공합니다. 데이터를 담아 나르는 데 최적화된 Data Class는 단어 하나만 붙이면 toString(), equals(), hashCode(), copy() 등의 메서드를 전부 자동 생성해 줍니다.
또한 상속받을 자식 클래스의 종류를 제한하여 when 문에서 절대적으로 안전한 분기 처리를 보장하는 Sealed Class (봉인 클래스)도 코틀린의 강력한 무기 중 하나입니다.
자바에서 DTO(데이터 전송 객체)를 만들 때 IDE의 자동 생성 기능을 이용해 수십 줄짜리 equals()와 toString()을 만들던 시절은 끝났습니다. 코틀린에서는 data 키워드 하나면 컴파일러가 모든 걸 보이지 않게 뒤에서 다 만들어줍니다.
| 클래스 종류 | 키워드 | 주요 목적과 특징 |
|---|---|---|
| 데이터 클래스 | data class |
데이터를 담고 전달하는 DTO 역할. 값의 비교(equals)와 예쁜 출력(toString) 자동 지원. |
| 열거형 클래스 | enum class |
요일, 방향처럼 정해진 상수들의 집합. (자바의 enum과 동일) |
| 봉인 클래스 | sealed class |
상속할 수 있는 자식의 종류를 컴파일러가 모두 알 수 있도록 파일을 봉인. when식과 환상의 짝꿍. |
// 1. 데이터 클래스 (Data Class)
// 네트워크 요청 결과나 DB 데이터를 담는 껍데기(DTO)로 씁니다.
// toString(), equals(), hashCode(), copy() 가 모두 자동 생성됩니다!
data class UserDto(val id: Int, val username: String, var email: String)
// 2. 봉인 클래스 (Sealed Class)
// 상속받는 자식들을 동일한 패키지(혹은 파일) 내로 강제 제한하여, "자식의 종류는 이것뿐이야!" 라고 확정 짓습니다.
sealed class NetworkResult {
data class Success(val data: String) : NetworkResult()
data class Error(val exceptionMsg: String) : NetworkResult()
object Loading : NetworkResult() // 데이터가 없는 상태만 나타낼 땐 object 사용
}
fun main() {
// === Data Class 활용 ===
val user1 = UserDto(1, "alice", "alice@test.com")
val user2 = UserDto(1, "alice", "alice@test.com")
// toString()이 예쁘게 오버라이딩 되어 필드값들이 모두 출력됨
println(user1)
// equals()가 주소가 아닌 '내부 값'들을 비교하도록 오버라이딩 되어 있음
println("두 유저가 같은가? " + (user1 == user2)) // true 출력!
// 불변성을 지키기 위해, 일부 값만 바꾸면서 똑같은 구조의 새 객체를 복제할 수 있습니다.
val user3 = user1.copy(id = 2, username = "bob")
println(user3)
// === Sealed Class 와 when 식의 찰떡 궁합 ===
// 자식의 종류가 3개뿐이라는 것을 컴파일러가 완벽히 알고 있으므로 else 블록을 적을 필요가 없습니다.
val response: NetworkResult = NetworkResult.Success("서버 응답 데이터")
when (response) {
is NetworkResult.Success -> println("성공: " + response.data)
is NetworkResult.Error -> println("실패: " + response.exceptionMsg)
NetworkResult.Loading -> println("로딩 중...")
}
}
코틀린은 필드(Field) 대신 프로퍼티(Property)를 사용합니다. 변수처럼 보이지만 내부적으로는 게터(getter)와 세터(setter)가 암묵적으로 호출됩니다.
하지만 안드로이드의 View 바인딩이나 외부 라이브러리의 의존성 주입처럼 "변수는 미리 선언해 둬야 하는데 값은 나중에 들어오는" 상황이 많습니다. 이때 Nullable(?)을 쓰면 매번 널 체크를 해야 하는 불편함이 생기므로, 코틀린은 lateinit과 lazy라는 훌륭한 지연 초기화 기법을 제공합니다.
lateinit (지각생): "지금은 값을 못 주지만, 내가 쓰기 전까지는 무조건 책임지고 값을 채워놓을게! 그러니까 일단 Null 말고 var로 믿고 선언해 줘!"
lazy (게으름뱅이): "나는 복잡한 계산식이라 미리 만들어 두기 귀찮아. 누군가 나를 처음으로 부르는 순간에만 val을 만들어서 줄게!"
| 비교 항목 | lateinit | lazy |
|---|---|---|
| 허용 변수 | var (가변) 전용 |
val (불변) 전용 |
| 작동 방식 | 선언만 해두고, 나중에 외부에서 값을 = 로 대입해줌. |
자신이 선언된 블록 코드를 최초 접근 시 실행하고 그 결과를 가짐. |
| 위험성 | 값을 셋팅하기 전에 읽으면 Uninitialized... 크래시 발생 |
안전함 (부르면 무조건 생성되므로) |
class Rectangle(var width: Int, var height: Int) {
// 1. 커스텀 Getter
// 필드처럼 보이지만, 읽을 때마다(get) 매번 계산식이 실행되어 값을 반환합니다.
val isSquare: Boolean
get() = (width == height)
val area: Int
get() = width * height
}
class Profile {
// 2. lateinit: 객체를 미리 선언은 해야 하지만 지금 당장 값을 넣을 수 없을 때 씁니다.
// 안드로이드의 뷰(View) 바인딩 등에서 필수적으로 사용됩니다. (null을 피하기 위한 궁여지책)
lateinit var nickname: String
// 3. lazy: 프로그램이 시작될 때 미리 만들어두면 메모리와 시간이 아까운 무거운 데이터를 다룰 때 씁니다.
// 'heavyData'가 코드 상에서 최초로 불려지는 순간, 딱 1번만 중괄호 안의 코드가 실행됩니다.
val heavyData: String by lazy {
println("=> 복잡하고 오래 걸리는 데이터 로딩 중...")
"거대한 용량의 데이터셋"
}
}
fun main() {
val rect = Rectangle(10, 10)
println("정사각형인가? " + rect.isSquare) // true
println("면적: " + rect.area) // 100
val p = Profile()
// 만약 여기서 닉네임을 할당하기 전에 println(p.nickname)을 부르면 크래시가 납니다!
// lateinit 은 개발자가 책임지고 쓰기 전에 할당하겠다는 약속입니다.
p.nickname = "코틀린러버"
println("설정된 닉네임: " + p.nickname)
// lazy 동작 확인
println("lazy 데이터는 아직 로딩 안 됨.")
// 아래에서 처음으로 heavyData를 부르는 순간, lazy 블록이 실행되어 로그가 찍힙니다.
println("1차 호출: " + p.heavyData)
// 두 번째 부를 땐 로직을 다시 실행하지 않고, 메모리에 저장해둔 결과값만 빠르게 리턴합니다.
println("2차 호출: " + p.heavyData)
}
자바에서는 타입 캐스팅 연산자인 (String) obj 를 덕지덕지 붙여야 했지만, 코틀린 컴파일러는 코드의 흐름을 스스로 이해할 정도로 매우 똑똑합니다.
개발자가 is 키워드로 타입을 확인했거나 != null 로 안전함을 증명했다면, 해당 if 블록 내부에서는 컴파일러가 알아서 타입을 변환(스마트 캐스트) 해줍니다.
코틀린 코드에 !! (Not-null Assertion) 연산자가 많다면 그건 코틀린을 자바처럼 짜고 있다는 증거입니다. !!는 컴파일러에게 "나를 믿고 null 검사를 무시해!" 라고 억지를 부리는 것과 같으며, 만약 null이 들어오면 여지없이 앱이 폭발(NPE)하므로 웬만해선 사용을 금지해야 합니다.
| 문법 / 키워드 | 동작 방식 | 위험도 |
|---|---|---|
| 스마트 캐스트 (if + is) | if문으로 검사하면 블록 안에서 자동으로 형변환됨. |
가장 안전함 |
| 안전한 캐스트 (as?) | 캐스팅에 실패하면 크래시를 내지 않고 null을 반환함. |
안전함 |
| 강제 캐스트 / 단언 (as / !!) | 무조건 캐스팅하거나 무조건 null이 아니라고 우김. | 매우 높음 (NPE 발생) |
fun printLength(obj: Any) {
// 매개변수 obj는 코틀린의 최상위 부모 타입인 'Any' 이므로, 기본적으로 length 프로퍼티를 쓸 수 없습니다.
// 1. 스마트 캐스트 (is 연산자 활용)
if (obj is String) {
// 코틀린 컴파일러는 천재적입니다.
// 여기서 "아, obj는 확실하게 String이구나!" 라고 판단하고
// 자바처럼 (String) obj 처럼 수동 캐스팅을 할 필요 없이 바로 length를 꺼내게 해줍니다.
println("문자열 길이: " + obj.length)
} else {
println("문자열이 아닙니다.")
}
}
fun printNotNull(str: String?) {
// str은 Nullable(물음표가 붙음)이므로 곧바로 length를 부르면 에러가 납니다.
// 하지만 if문을 통해 null이 아님을 확실하게 증명했다면?
if (str != null) {
// 이 블록 안에서는 자동으로 Non-Null (일반 String)로 스마트 캐스트 됩니다!
println("안전한 길이: " + str.length)
}
}
fun main() {
printLength("안녕하세요") // 문자열을 보냄
printLength(12345) // 정수를 보냄
printNotNull("코틀린")
// 2. 단언 연산자 (!!) - 극도로 주의!
var danger: String? = "값"
// "이건 null이 절대 아니니까 컴파일러야 묻지 말고 그냥 벗겨!" 라고 강제하는 명령입니다.
// 임시방편으로 쓸 수는 있지만, 만약 진짜 null이었다면 바로 앱이 튕깁니다.
println(danger!!.length)
}
자바에서 리스트 내의 데이터를 필터링하거나 변환하려면 for문을 돌리면서 장황한 코드를 작성하거나 Stream API를 억지로 끼워 맞춰야 했습니다.
하지만 코틀린은 표준 라이브러리(Standard Library) 자체가 어마어마한 확장 함수들의 집합소입니다. filter, map, groupBy, any 등의 함수를 파이프라인처럼 연결하면, 복잡한 데이터 조작 로직도 단 한 줄로 우아하게 끝낼 수 있습니다.
컨베이어 벨트(리스트)에 수많은 데이터가 지나갑니다.
filter는 체를 받쳐 불량품을 걸러내는 거름망 역할을 하고, map은 통과한 정상품에 포장지를 씌우거나 형태를 변환하는 가공 기계 역할을 합니다. 이 기계들을 꼬리에 꼬리를 무는 체이닝 기법으로 연결할 수 있습니다.
| 함수명 | 기능 요약 | 반환 타입 |
|---|---|---|
| filter { } | 람다 안의 조건이 참(True)인 원소들만 살려냅니다. | List |
| map { } | 원본 데이터를 람다식의 결과 형태(예: 객체 -> 문자열)로 변환합니다. | List |
| groupBy { } | 특정 기준(키)으로 데이터들을 묶어 카테고리화 시켜버립니다. | Map<K, List> |
data class User(val name: String, val age: Int, val city: String)
fun main() {
val users = listOf(
User("Alice", 25, "Seoul"),
User("Bob", 30, "Busan"),
User("Charlie", 25, "Seoul"),
User("David", 40, "Jeju")
)
// 1. filter 와 map 체이닝 (파이프라인)
// - filter: 나이가 30 이상인 사람만 통과시킴
// - map: 통과한 사람 객체에서 '이름(name)'만 쏙 빼서 새 리스트로 만듦
val over30Names = users.filter { it.age >= 30 }.map { it.name }
println("30세 이상 이름: " + over30Names)
// 2. groupBy (SQL의 GROUP BY와 완벽히 동일)
// 도시(city) 이름을 Key로 삼아 해당하는 유저들을 그룹으로 묶은 Map을 반환합니다.
val usersByCity: Map<String, List<User>> = users.groupBy { it.city }
// 서울을 Key로 갖는 리스트의 크기(size)를 구합니다.
println("Seoul 유저 수: " + usersByCity["Seoul"]?.size + "명")
// 3. maxByOrNull, minByOrNull
// 나이(age)를 기준으로 순회를 돌며 가장 수치가 높은 유저 객체 1개를 리턴합니다.
val oldest = users.maxByOrNull { it.age }
println("가장 나이 많은 사람: " + oldest?.name)
// 4. any, all, none (Boolean 리턴 함수들)
val hasSeoul = users.any { it.city == "Seoul" } // 서울 사는 사람이 하나라도 있는가? (true)
val allAdults = users.all { it.age >= 18 } // 싹 다 18세 이상인가? (true)
println("서울 거주자 존재? " + hasSeoul)
println("모두 성인인가? " + allAdults)
}