Kotlin 시작하기: Android 공식 언어의 특징과 개발 환경 설정

이 글의 핵심

Java를 써 본 사람이라면 Kotlin에서 세미콜론과 타입 선언이 사라지고 if와 when이 값을 돌려주는 표현식이 되는 점부터 낯설게 느낍니다. 개발 환경을 고르는 기준을 먼저 짚고, 같은 코드를 Java와 나란히 비교하며 null 안전성과 표현식 중심 문법이 왜 실수를 줄여 주는지 체감하도록 구성했습니다.

시리즈 안내

#01 | 📋 전체 목차 | 다음: #02 변수와 타입


들어가며

Kotlin이란?

Kotlin은 JetBrains가 만든 JVM(자바 바이트코드를 실행하는 가상 머신)·Android에서 널리 쓰이는 언어로, 2017년 이후 Android 공식 언어로 자리 잡았습니다. Java와 상호 운용되며, 문법이 더 간결한 편입니다.

JetBrains가 새 언어를 만든 동기는 자사 IDE를 Java로 개발하면서 겪은 불편, 즉 장황한 코드와 잦은 NullPointerException이었습니다. 그래서 Kotlin은 “Java를 대체하는 완전히 다른 언어”보다 “기존 Java 코드베이스에 파일 단위로 섞어 넣을 수 있는 더 나은 Java”를 목표로 설계되었습니다. 이 설계 덕분에 팀이 Kotlin을 도입할 때 기존 코드를 다시 쓸 필요 없이 새 파일부터 Kotlin으로 작성할 수 있고, 이것이 Android 생태계에서 빠르게 퍼진 가장 현실적인 이유입니다. 특징:

  • 간결함: 같은 로직을 Java보다 짧게 쓰는 경우가 많음
  • 안전성: null 가능 여부를 타입에 표시
  • 상호운용성: 같은 프로젝트에서 Java 클래스를 그대로 호출하고, Java에서도 Kotlin 코드를 호출 가능
  • 함수형: 람다, 고차 함수, 컬렉션 연산 지원
  • 멀티플랫폼: Android, 서버, 그리고 Kotlin Multiplatform으로 iOS·웹까지 Kotlin vs Java: | 특징 | Kotlin | Java | |------|--------|------| | 코드 길이 | 짧음 | 김 | | Null 안정성 | 내장 | 별도 처리 | | 함수형 | 지원 | 제한적 | | 확장 함수 | 지원 | 미지원 | | 데이터 클래스 | 자동 | 수동 |

IntelliJ IDEA와 Android Studio 설치

IntelliJ IDEA 설치

  1. IntelliJ IDEA 다운로드
  2. Community Edition (무료) 또는 Ultimate Edition
  3. Kotlin 플러그인 자동 포함

Android Studio 설치

  1. Android Studio 다운로드
  2. Kotlin 기본 지원
  3. Android 앱 개발에 최적화

온라인 실행

문법을 익히는 단계라면 Playground나 IntelliJ IDEA Community로 충분하고, Android 앱을 만들 계획이라면 처음부터 Android Studio를 쓰는 편이 좋습니다. Android Studio는 IntelliJ 기반이라 편집 경험은 같지만, 에뮬레이터·SDK 관리·레이아웃 도구가 함께 들어 있습니다. 어느 쪽이든 실제 프로젝트는 Gradle로 빌드되므로, 처음 프로젝트를 만들 때 Gradle 동기화에 수 분이 걸리고 JDK 버전이 맞지 않으면 Unsupported class file major version 같은 에러가 날 수 있습니다. 이때는 IDE 설정에서 Gradle이 쓰는 JDK를 프로젝트 요구 버전(보통 17 이상)으로 맞추면 해결됩니다.


첫 Kotlin 프로그램과 Java 비교

Hello World

fun main() {
    println("Hello, Kotlin!")
}

실행 결과:

Hello, Kotlin!

Java와 비교

Java:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("Hello, Java!");
    }
}

Kotlin:

fun main() {
    println("Hello, Kotlin!")
}

차이점:

  • 클래스 불필요
  • public static 불필요
  • 세미콜론 선택적
  • System.out 대신 println

클래스 없이 main을 쓸 수 있는 것은 Kotlin이 파일 최상위(top-level) 함수를 허용하기 때문입니다. JVM은 여전히 클래스 안의 static 메서드만 실행할 수 있으므로, 컴파일러가 Main.kt 파일을 MainKt라는 클래스로 만들고 main을 그 안의 static 메서드로 넣습니다. Java 코드에서 Kotlin의 최상위 함수를 호출할 때 MainKt.main()처럼 파일 이름 뒤에 Kt가 붙은 클래스를 쓰는 이유가 이것입니다.


변수, 타입 추론, 함수 정의

변수 선언

// 읽기 전용 (val = value)
val name = "홍길동"
val age = 25
// 변경 가능 (var = variable)
var score = 90
score = 95  // OK
// 타입 명시
val name: String = "홍길동"
val age: Int = 25

Kotlin 스타일은 “기본은 val, 바꿔야 할 때만 var”입니다. val은 재대입만 막을 뿐 객체 내부까지 불변으로 만들지는 않습니다. val list = mutableListOf(1)에서 list = ...는 에러지만 list.add(2)는 됩니다. 그래도 val을 기본으로 쓰면 “이 변수가 중간에 바뀌는가”를 추적할 곳이 줄어들어 코드를 읽기 쉬워집니다. IntelliJ는 한 번도 재대입되지 않는 var에 “Variable is never modified and can be declared immutable using ‘val’” 경고를 띄워 줍니다. (위 블록의 name, age처럼 같은 스코프에서 같은 이름을 두 번 선언하면 Conflicting declarations 에러가 나므로, 예제는 설명용으로 나눠 보면 됩니다.)

타입 추론

val number = 10        // Int
val text = "Hello"     // String
val pi = 3.14          // Double
val isActive = true    // Boolean

타입 추론은 동적 타입이 아닙니다. number는 컴파일 시점에 Int로 확정되고, 나중에 number = "hi"를 대입하려 하면 컴파일 에러가 납니다. 추론이 기대와 다른 경우도 있습니다. val big = 3_000_000_000은 Int 범위를 넘어 자동으로 Long이 되고, val ratio = 1 / 2는 정수 나눗셈이라 0입니다. 함수의 public 반환 타입처럼 API 경계에서는 타입을 명시해 두는 편이 의도가 분명합니다.

함수 정의

// 기본 함수
fun greet(name: String) {
    println("안녕하세요, $name님!")
}
// 반환값이 있는 함수
fun add(a: Int, b: Int): Int {
    return a + b
}
// 표현식 함수
fun add(a: Int, b: Int) = a + b
// 기본 매개변수
fun greet(name: String = "Guest") {
    println("안녕하세요, $name님!")
}
greet()           // 안녕하세요, Guest님!
greet("홍길동")   // 안녕하세요, 홍길동님!

기본 매개변수는 Java에서 흔히 쓰던 오버로드 여러 개(greet(), greet(String))를 하나로 줄여 줍니다. 호출할 때 greet(name = "홍길동")처럼 이름을 붙이는 명명 인자와 함께 쓰면, 인자가 많은 함수에서 순서를 헷갈리는 실수도 줄어듭니다. 다만 Java 코드에서 Kotlin 함수를 호출하면 기본값이 보이지 않아 모든 인자를 넘겨야 하므로, Java에서도 쓸 함수라면 @JvmOverloads를 붙여 오버로드를 생성하게 합니다.


Null 안정성

Nullable 타입

Kotlin의 가장 강력한 기능 중 하나는 Null 안정성입니다. Java의 NullPointerException을 컴파일 타임에 방지합니다:

// Non-null (기본) - null을 허용하지 않음
var name: String = "홍길동"
// name = null  // 컴파일 에러! "Null can not be a value of a non-null type String"
// Kotlin은 기본적으로 모든 타입이 non-null
// Nullable - null을 허용하는 타입
// 타입 뒤에 ? 를 붙여서 nullable 타입으로 만듦
var name: String? = "홍길동"
name = null  // ✅ OK - nullable 타입이므로 null 할당 가능
// Safe Call (?.) - null이면 null 반환, 아니면 프로퍼티/메서드 호출
val length = name?.length  
// name이 null이면 length도 null
// name이 "홍길동"이면 length는 3
// Java의 if (name != null) name.length() 를 간결하게 표현
// Elvis 연산자 (?:) - null일 때 기본값 제공
val length = name?.length ?: 0  
// name이 null이면 0 반환
// name이 "홍길동"이면 3 반환
// Java의 name != null ? name.length() : 0 과 동일
// Not-null 단언 (!!) - "이 값은 절대 null이 아니다"라고 단언
val length = name!!.length  
// name이 null이면 NullPointerException 발생 (Kotlin 1.4 이전에는 KotlinNullPointerException)
// name이 "홍길동"이면 3 반환
// 주의: !!는 가능한 피하고 ?. 또는 ?: 사용 권장

Null 안정성의 장점:

  • Java의 NullPointerException을 컴파일 타임에 방지
  • 코드가 더 안전하고 예측 가능
  • null 체크 코드가 간결해짐

여기에는 중요한 예외가 하나 있습니다. Java 메서드가 돌려준 값은 Kotlin 입장에서 null이 될 수 있는지 알 수 없어서 String!로 표시되는 플랫폼 타입이 됩니다. 컴파일러는 플랫폼 타입에 대해 null 검사를 강제하지 않으므로, val n: String = javaObj.getName()처럼 non-null 타입에 넣었는데 실제로 null이 오면 그 대입 지점에서 NullPointerException: getName(...) must not be null 같은 예외가 납니다. Android에서 오래된 Java 라이브러리를 쓸 때 “Kotlin인데 왜 NPE가 나지?”라는 상황은 대부분 이 경계에서 생깁니다. Java에서 온 값이 null일 수 있다면 String?로 받아서 처리하는 것이 안전하고, Java 라이브러리 쪽에 @Nullable/@NonNull 애노테이션이 붙어 있으면 Kotlin 컴파일러가 이를 읽어 정확한 타입으로 바꿔 줍니다. 실전 예시:

// Java 스타일 (장황함)
String name = user.getName();
if (name != null) {
    int length = name.length();
    System.out.println(length);
}
// Kotlin 스타일 (간결함)
val length = user.name?.length
println(length)

문자열 템플릿

val name = "홍길동"
val age = 25
// 문자열 템플릿
println("이름: $name, 나이: $age")
// 표현식
println("내년 나이: ${age + 1}")
// 여러 줄 문자열
val text = """
    안녕하세요!
    Kotlin 입니다.
    여러 줄 작성 가능
""".trimIndent()

$name은 변수 하나, ${...}는 임의의 표현식을 넣을 때 씁니다. "$user.name"처럼 쓰면 user 뒤에 문자열 “.name”이 붙어 출력되므로 프로퍼티 접근에는 반드시 "${user.name}"을 써야 합니다. 처음 Kotlin을 쓸 때 자주 하는 실수입니다. 여러 줄 문자열(""")은 이스케이프를 처리하지 않으므로 정규식이나 JSON을 적을 때 편하고, trimIndent()는 공통 들여쓰기를 제거해 코드 들여쓰기에 맞춰 문자열을 써도 결과에는 들여쓰기가 남지 않게 해 줍니다.


if와 when 표현식

if 표현식

// if-else
val max = if (a > b) a else b
// 블록
val max = if (a > b) {
    println("a가 더 큼")
    a
} else {
    println("b가 더 크거나 같음")
    b
}

when 표현식

val grade = when (score) {
    in 90..100 -> "A"
    in 80..89 -> "B"
    in 70..79 -> "C"
    else -> "F"
}
// 조건 없는 when
when {
    score >= 90 -> println("A")
    score >= 80 -> println("B")
    else -> println("C")
}

Kotlin에는 삼항 연산자(a > b ? a : b)가 없는데, if 자체가 값을 돌려주는 표현식이라 필요가 없기 때문입니다. 블록 형태에서는 마지막 줄의 값이 결과가 됩니다. when을 값으로 쓸 때(val grade = when ...)는 모든 경우를 다뤄야 해서 else가 없으면 'when' expression must be exhaustive 컴파일 에러가 납니다. 이 규칙은 enum이나 sealed class와 함께 쓸 때 특히 유용한데, 새 값을 추가하면 처리하지 않은 모든 when에서 컴파일 에러가 나서 빠뜨린 곳을 컴파일러가 찾아 줍니다. 위 성적 예제에서 점수가 100을 넘거나 음수인 경우도 else로 떨어져 “F”가 되므로, 입력 범위를 검증하지 않으면 잘못된 입력이 조용히 F로 처리된다는 점도 알아 둘 만합니다.


for와 while 반복문

for 루프

// 범위
for (i in 1..5) {
    println(i)  // 1, 2, 3, 4, 5
}
// until (끝 제외)
for (i in 1 until 5) {
    println(i)  // 1, 2, 3, 4
}
// step
for (i in 1..10 step 2) {
    println(i)  // 1, 3, 5, 7, 9
}
// 역순
for (i in 5 downTo 1) {
    println(i)  // 5, 4, 3, 2, 1
}
// 배열/리스트
val fruits = listOf("사과", "바나나", "오렌지")
for (fruit in fruits) {
    println(fruit)
}
// 인덱스와 함께
for ((index, fruit) in fruits.withIndex()) {
    println("$index: $fruit")
}

1..5는 끝을 포함하는 닫힌 범위이고, until은 끝을 제외합니다. 인덱스를 돌 때 for (i in 0..list.size)로 쓰면 마지막에 IndexOutOfBoundsException이 나므로 0 until list.size나, 더 간단하게 list.indices를 씁니다. Kotlin 1.9부터는 0..<list.size라는 연산자 표기도 쓸 수 있습니다. Java의 for (int i = 0; i < n; i++) 형태의 C 스타일 for문은 Kotlin에 없습니다.

while 루프

var count = 0
while (count < 5) {
    println(count)
    count++
}
// do-while
do {
    println(count)
    count++
} while (count < 5)

List, Set, Map

List

// 읽기 전용
val fruits = listOf("사과", "바나나", "오렌지")
// 변경 가능
val mutableFruits = mutableListOf("사과", "바나나")
mutableFruits.add("오렌지")
// 접근
println(fruits[0])  // 사과
println(fruits.first())
println(fruits.last())

Kotlin 컬렉션은 읽기 전용 인터페이스(List)와 변경 가능 인터페이스(MutableList)가 타입으로 나뉩니다. listOf가 돌려준 List에는 add 메서드 자체가 없어서 컴파일 단계에서 수정이 막힙니다. 다만 “읽기 전용”은 “불변”과 다릅니다. 실제 객체는 Java의 ArrayList일 수 있어서, 같은 객체를 MutableList로 참조하는 다른 코드가 있으면 내용이 바뀔 수 있습니다. 또 first()는 빈 리스트에서 NoSuchElementException을 던지므로, 비어 있을 수 있다면 firstOrNull()을 씁니다.

Set

val numbers = setOf(1, 2, 3, 2, 1)  // 중복 제거
println(numbers)  // [1, 2, 3]
val mutableNumbers = mutableSetOf(1, 2, 3)
mutableNumbers.add(4)

Map

val ages = mapOf(
    "홍길동" to 25,
    "김철수" to 30,
    "이영희" to 28
)
println(ages["홍길동"])  // 25
println(ages["없는 이름"])  // null (Map 조회 결과는 Int?)
val mutableAges = mutableMapOf("홍길동" to 25)
mutableAges["김철수"] = 30

계산기, 성적 처리, 사용자 정보 예제

예제 1: 간단한 계산기

fun main() {
    println("=== 계산기 ===")
    
    val a = 10
    val b = 5
    
    println("$a + $b = ${add(a, b)}")
    println("$a - $b = ${subtract(a, b)}")
    println("$a * $b = ${multiply(a, b)}")
    println("$a / $b = ${divide(a, b)}")
}
fun add(a: Int, b: Int) = a + b
fun subtract(a: Int, b: Int) = a - b
fun multiply(a: Int, b: Int) = a * b
fun divide(a: Int, b: Int) = if (b != 0) a / b else 0

divide가 0으로 나누는 경우 0을 돌려주는 것은 예제를 단순하게 하려는 처리일 뿐, 실제 코드라면 좋지 않은 선택입니다. “결과가 0”과 “계산할 수 없음”이 구분되지 않기 때문입니다. Kotlin다운 방법은 반환 타입을 Int?로 두고 null을 돌려주거나, require(b != 0) { "b는 0일 수 없습니다" }로 예외를 던지는 것입니다. 정수 나눗셈이라 7 / 2가 3이 된다는 점도 Java와 같습니다.

예제 2: 성적 처리

fun main() {
    val scores = listOf(85, 92, 78, 95, 88)
    
    println("점수: $scores")
    println("평균: ${scores.average()}")
    println("최고점: ${scores.maxOrNull()}")
    println("최저점: ${scores.minOrNull()}")
    
    val passed = scores.filter { it >= 80 }
    println("80점 이상: $passed")
}

예제 3: 사용자 정보

data class User(
    val name: String,
    val age: Int,
    val email: String
)
fun main() {
    val users = listOf(
        User("홍길동", 25, "[email protected]"),
        User("김철수", 30, "[email protected]"),
        User("이영희", 28, "[email protected]")
    )
    
    // 이름 출력
    users.forEach { println(it.name) }
    
    // 30세 이상 필터링
    val adults = users.filter { it.age >= 30 }
    println("30세 이상: $adults")
    
    // 이름 리스트
    val names = users.map { it.name }
    println("이름: $names")
}

데이터 클래스와 Null 처리: Kotlin vs Java

데이터 클래스

Java:

public class User {
    private String name;
    private int age;
    
    public User(String name, int age) {
        this.name = name;
        this.age = age;
    }
    
    // getter, setter, equals, hashCode, toString...
}

Kotlin:

data class User(val name: String, val age: Int)

data class 한 줄은 생성자, 프로퍼티 getter, equals/hashCode, toString, copy, 구조 분해용 componentN 함수를 컴파일러가 만들어 줍니다. Java 16 이상의 record와 비슷하지만, var 프로퍼티를 둘 수 있고 copy(age = 31)처럼 일부만 바꾼 복사본을 쉽게 만들 수 있다는 차이가 있습니다. 주의할 점은 equals와 hashCode가 주 생성자의 프로퍼티만 사용한다는 것이라, 클래스 본문에 선언한 프로퍼티는 비교에 포함되지 않습니다.

Null 처리

Java:

String name = getName();
if (name != null) {
    int length = name.length();
}

Kotlin:

val length = name?.length ?: 0

Kotlin 기본 문법 요약

  1. Kotlin: JVM 기반, Android 공식 언어
  2. 간결함: Java보다 코드가 짧음
  3. Null 안정성: ?, ?., ?:, !!
  4. 함수: fun, 표현식 함수, 기본 매개변수
  5. 컬렉션: List, Set, Map
  6. 상호운용성: 기존 Java 코드와 함께 사용 가능

다음 단계


다른 언어와 비교


자주 묻는 질문 (FAQ)

Q. !! 연산자를 쓰면 안 되나요?

A. !!는 값이 null이 아니라고 컴파일러에 단언하는 연산자라, 실제로 null이면 그 자리에서 NullPointerException이 납니다. Kotlin의 null 안전성을 스스로 끄는 셈이므로, 보통은 ?.로 안전하게 호출하고 ?:로 기본값을 주는 방식이 더 낫습니다. 초기화 순서상 null일 수 없다는 것이 확실한 좁은 경우에만 쓰고, 그런 곳이 많다면 lateinit이나 타입 설계를 다시 보는 편이 좋습니다.


같이 보면 좋은 글