[Java와 비교하는 Kotlin] 기본 개념 #2
1. 패키지
패키지는 여러 클래스를 묶어서 네임스페이스를 제공하는 개념이다. 패키지를 사용하면 이름 충돌을 방지하고, 코드의 구조를 명확히 하며, 다른 프로그램에서 클래스들을 쉽게 가져다 쓸 수 있게된다.
- 네임스페이스 관리: 같은 이름을 가진 클래스를 여러 개 정의할 수 있게 해준다. 예를 들어, com.example.utils와 com.example.models 같은 서로 다른 패키지에 같은 이름을 가진 클래스가 있을 수 있다.
- 코드 관리: 프로젝트를 더 나은 구조로 나누어 관리할 수 있게 해준다.
- 접근 제어: 패키지 내에서 클래스를 어떻게 접근할 수 있는지 제어할 수 있다.
Kotlin에서의 패키지 사용법
Kotlin에서는 package 키워드를 사용하여 클래스를 패키지에 포함시킨다. 패키지는 코드 파일의 첫 번째 줄에 선언되며, 해당 파일 내 모든 클래스, 객체, 함수 등은 이 패키지에 속한다.
Kotlin은 기본적으로 디렉토리 구조에 맞춰 패키지를 사용한다. 즉, 파일 시스템에서 com/example/utils 폴더 구조에 해당하는 디렉토리 안에 MyClass.kt 파일을 두어야한다.
아래 예제를 보자.
- package com.example.utils는 MyClass 클래스를 com.example.utils 패키지에 포함시킨다.
- 패키지 선언은 파일 맨 앞에 위치해야 하며, 선언된 패키지 내에서만 클래스를 사용할 수 있다.
package com.example.utils;
public class MyClass {
public void doSomething() {
System.out.println("Hello, Java!");
}
}
package com.example.utils
class MyClass {
fun doSomething() {
println("Hello, Kotlin!")
}
}
Kotlin과 Java의 패키지 차이점
Kotlin과 Java의 패키지 사용법은 대부분 유사하지만, 두 언어는 몇 가지 차이점도 가지고 있다.
1. 파일 상단의 패키지 선언 위치
- Kotlin: 패키지 선언은 항상 파일의 첫 번째 줄에 위치해야 한다.
- Java: 패키지 선언도 파일의 첫 번째 줄에 위치하지만, import 구문이 그 전에 올 수 있다.
2. 기본 패키지 사용
- Kotlin: 기본 패키지 (기본적으로 아무 패키지에도 속하지 않는 클래스)를 사용하는 것이 일반적이지 않다. 하지만 만약 패키지를 선언하지 않으면, 클래스는 기본 패키지에 속한다.
- Java: Java에서도 패키지를 선언하지 않으면, 클래스는 기본 패키지에 속한다. 하지만 기본 패키지 사용을 지양하고 명시적으로 패키지를 선언하는 것이 일반적이다.
3. 접근 제어와 패키지 레벨 접근
- Kotlin: Kotlin에서는 internal 키워드를 사용하여 같은 모듈 내에서만 접근할 수 있도록 제한할 수 있다. public과 private 외에도 internal이 존재하는 점이 특징이다.
- Java: Java에서는 private, protected, public이 있지만, package-private (아무 접근 제어자가 없는 경우)도 존재하여 같은 패키지 내에서 접근할 수 있도록 한다.
2. 가시성(Visibility)
가시성은 클래스의 멤버(속성, 메소드 등)가 외부 코드로부터 접근할 수 있는 범위를 정의한다. 가시성을 적절히 설정함으로써 데이터와 메소드의 접근을 제어하고, 코드의 구조와 안정성을 향상시킬 수 있다.
가시성은 주로 다음과 같은 수준에서 설정된다.
- public: 기본적으로 모든 클래스, 메소드, 변수 등은 public입니다. 어디서든 접근 가능하다.
- private: 클래스 내부에서만 접근 가능하다. private으로 선언된 멤버는 같은 클래스 내에서만 사용할 수 있다.
- protected: protected는 private와 비슷하지만, 상속된 클래스에서는 접근할 수 있다. 하지만 외부에서는 접근할 수 없다.
- internal: Kotlin에서만 사용하는 키워드다. 같은 모듈 내에서만 접근할 수 있다. 즉, 모듈은 같은 프로젝트나 라이브러리로 이해할 수 있다. 외부 모듈에서는 접근할 수 없다.
Kotlin에서는 top-level 함수나 프로퍼티를 정의할 수 있다. 즉, 클래스 안에 속하지 않는 함수나 프로퍼티를 정의할 수 있다. 이 경우, internal, public, private 등의 가시성을 설정할 수 있다. Java에서는 클래스 내에서만 메소드를 정의할 수 있기 때문에 top-level 함수에 대한 개념은 존재하지 않다.
// top-level 함수 예시
internal fun doSomething() { // internal: 같은 모듈 내에서만 접근 가능
println("This is an internal function!")
}
Kotlin의 companion object는 Java의 static 멤버와 유사한 역할을 하지만, 가시성 제어가 Kotlin의 방식으로 적용된다. 이 객체는 클래스 내에서 public, private, internal, protected와 같은 가시성 제어를 할 수 있다. Java의 static 멤버는 private, protected, public 등을 사용할 수 있지만, companion object의 개념과는 차이가 있다.
class MyClass {
companion object {
private val secret = "This is a secret"
fun revealSecret() = secret
}
}
아래 예시를 보자.
public class MyClass {
public String publicVar = "I am public"; // public: 외부 접근 가능
private String privateVar = "I am private"; // private: 클래스 내부에서만 접근 가능
protected String protectedVar = "I am protected"; // protected: 자식 클래스에서 접근 가능
String defaultVar = "I am default"; // default (package-private): 같은 패키지 내에서만 접근 가능
}
// public은 기본 값, 모듈 외부에서도 접근 가능
class MyClass {
val publicVar = "I am public" // public 기본값
private val privateVar = "I am private" // private: 클래스 내부에서만 접근 가능
protected val protectedVar = "I am protected" // protected: 자식 클래스에서 접근 가능
internal val internalVar = "I am internal" // internal: 같은 모듈 내에서만 접근 가능
}
Kotlin과 Java의 가시성 차이점
1. internal 키워드 (Kotlin 전용)
Kotlin은 internal이라는 고유한 접근 제어자를 제공한다. internal은 같은 모듈 내에서만 접근할 수 있게 제한하는 키워드다. 이는 Kotlin에서 매우 유용한 기능으로, 라이브러리나 애플리케이션에서 다른 모듈 간의 접근을 제어하는 데 도움이 될 수 있다.
- Kotlin: internal은 모듈 내에서만 접근 가능하다.
- Java: Java에는 모듈 수준의 가시성 제어를 위한 키워드가 없다. 대신 패키지 내에서의 접근을 default(패키지 프라이빗)로 해결할 수 있다.
2. default 가시성
Java에서는 default 가시성, 즉 접근 제어자를 명시하지 않으면 기본적으로 같은 패키지 내에서만 접근 가능한 package-private가 적용된다. 그러나 Kotlin에서는 default가 따로 존재하지 않으며, 모든 멤버는 명시적으로 private, protected, public, internal 중 하나로 선언해야 한다.
3. protected의 동작
- Kotlin: protected는 상속받은 클래스에서만 접근할 수 있으며, 같은 패키지 내에서는 접근할 수 없다.
- Java: protected는 상속받은 클래스뿐만 아니라 같은 패키지 내에서도 접근할 수 있다.
4. public의 기본값
- Kotlin: Kotlin에서 public은 생략할 수 있으며, 기본적으로 모든 멤버는 public이다.
- Java: Java에서는 모든 멤버가 public으로 선언되려면 명시적으로 public을 지정해야한다.
3. 함수
Kotlin에서 함수를 선언하는 방식은 Java와 비교했을 때 매우 간단하고 직관적이다. Kotlin은 함수형 프로그래밍을 지원하기 위해 여러 가지 선언 방식을 제공하며, Java보다 훨씬 유연한 방법으로 함수를 다룰 수 있다.
Kotlin의 기본 함수 선언
Kotlin에서 함수는 fun 키워드를 사용하여 선언한다. 함수의 반환 타입은 함수명 뒤에 콜론(:)을 사용해 지정하며 함수의 매개변수는 (매개변수명: 타입) 형식으로 작성된다.
// 파라미터의 기본 값을 설정할 수 있다.
fun greet(name: String = "World"): String {
return "Hello, $name!"
}
// 반환 타입이 Unit(Java의 void)인 함수는 반환 타입을 생략할 수 있다.
fun printMessage(message: String) {
println(message)
}
로컬함수
Kotlin은 함수 내부에서 다른 함수를 선언할 수 있는 로컬 함수(local function) 기능을 지원한다. 이는 코드의 구조를 보다 깔끔하고 효율적으로 만들 수 있다. Java에서는 이러한 개념이 직접적으로 존재하지 않지만, Kotlin에서는 함수 내에서 함수를 정의할 수 있다. 로컬 함수는 해당 함수가 정의된 범위 내에서만 사용이 가능하며, 코드의 중복을 줄이고 가독성을 높이는 데 유용하다.
아래 예시를 보자.
fun calculate(x: Int, y: Int): Int {
fun add(a: Int, b: Int) = a + b
fun multiply(a: Int, b: Int) = a * b
return add(x, y) + multiply(x, y)
}
함수 Override
Kotlin에서 함수 오버라이드는 open 키워드를 사용하여 상속 가능한 함수와 override 키워드를 사용하여 부모 클래스의 함수를 재정의한다. Java와 비슷하지만, Kotlin은 기본적으로 클래스의 모든 함수가 final로 간주되므로, 함수 오버라이드를 허용하려면 open 키워드를 명시해야 한다.
class Animal {
public void speak() {
System.out.println("Animal speaks");
}
}
class Dog extends Animal {
@Override
public void speak() {
System.out.println("Dog barks");
}
}
open class Animal {
open fun speak() {
println("Animal speaks")
}
}
class Dog : Animal() {
override fun speak() {
println("Dog barks")
}
}
확장함수
Kotlin은 확장 함수(Extension Function)를 지원하여, 기존 클래스에 새로운 메소드를 추가할 수 있다. 확장 함수는 클래스의 정의를 수정하지 않고도 기능을 추가할 수 있는 매우 유용한 기능이다. Java에서는 확장 함수와 유사한 개념이 없지만, Kotlin에서는 이를 매우 쉽게 사용할 수 있다.
아래 예시를 보자.
fun String.reverse(): String {
return this.reversed()
}
val reversedString = "Hello".reverse() // "olleH"
람다함수
Kotlin은 람다 함수를 first-class citizen으로 지원한다. 즉, 람다는 일급 객체로서 변수에 할당하거나, 함수의 인자로 넘길 수 있다. Java에서도 람다 표현식을 사용할 수 있지만, Kotlin은 이를 더 직관적이고 간결하게 처리할 수 있다.
Java의 경우 Java 8 이후 Java에서도 람다 표현식을 지원한다. Java에서의 람다는 Function, Consumer, Supplier와 같은 인터페이스를 사용하여 표현된다.
아래 예시를 보자.
BiFunction<Integer, Integer, Integer> sum = (a, b) -> a + b;
System.out.println(sum.apply(2, 3)); // 5
val sum = { a: Int, b: Int -> a + b }
println(sum(2, 3)) // 5
클로저
Kotlin에서 클로저(Closure)는 함수가 실행될 때 외부 함수의 변수나 상태를 기억하는 기능을 의미한다. Kotlin에서의 클로저는 람다 함수가 외부 변수를 참조할 수 있기 때문에 매우 유용하게 사용된다.
Java에서도 람다 표현식이 외부 변수를 참조할 수 있으므로, 람다를 사용한 클로저 구현이 가능하다. 그러나 Java에서는 외부 변수는 final 또는 effectively final이어야만 참조할 수 있다는 제약이 있다.
아래 예시를 보자.
public void outer() {
int x = 10;
Runnable closure = () -> System.out.println(x);
closure.run(); // 10
}
fun outer() {
val x = 10
val closure = { println(x) }
closure() // 10
}
outer()
4. Null과 NullPointerException
- Null: 객체가 아무 값도 참조하지 않음을 나타내는 특별한 값이다.
- NullPointerException(NPE): 객체를 참조하려고 할 때, 해당 객체가 null인 경우 발생하는 예외다. Java에서는 이 예외가 매우 자주 발생하는 문제가 되기도 한다.
Java에서는 Null을 직접적으로 처리해야 한다. 객체가 null인지 확인하려면 조건문을 사용하거나 예외 처리를 통해 처리해야 하며, 이를 통해 NullPointerException을 피할 수 있다.
Kotlin은 널 안정성(null safety) 개념을 도입하여 NullPointerException을 컴파일 타임에 방지하려고 했다. Kotlin에서는 변수나 객체가 null을 가질 수 있는지 여부를 명시적으로 지정해야 한다. 이로 인해 코드에서 NullPointerException이 발생할 가능성이 크게 줄어들 수 있다. Kotlin에서 변수에 null을 할당할 수 있는지 여부는 타입을 선언할 때 결정된다. 기본적으로 Kotlin의 모든 타입은 non-null 타입이다. 즉, null을 할당할 수 없다. 하지만 nullable 타입을 사용하면 null을 할당할 수 있다.
- Non-null 타입: 기본적으로 null을 허용하지 않는 타입
- Nullable 타입: null을 할당할 수 있는 타입
아래 예제를 보자.
String?와 같은 nullable 타입은 null 값을 가질 수 있는 타입을 나타낸다. Kotlin에서는 이를 통해 null 처리의 안전성을 보장할 수 있다.
val name: String = "Kotlin" // Non-nullable
name = null // 오류: null을 할당할 수 없음
val name: String? = "Kotlin" // Nullable 타입
name = null // 허용됨
Kotlin에서는 null을 처리하기 위해 몇 가지 방법을 제공한다. 주요 방법은 안전 호출 연산자(?.), 엘비스 연산자(?:), 널 합병 연산자(!!)이 있다.
안전 호출 연산자
?. 연산자는 객체가 null인지 아닌지를 안전하게 확인하고, null이 아니면 해당 속성이나 메소드를 호출한다. 만약 객체가 null이라면 null을 반환하게 된다.
val name: String? = null
val length: Int? = name?.length // name이 null이라면 length는 null이 됨
엘비스 연산자
엘비스 연산자(?:)는 null일 때 기본값을 제공하는 기능을 한다. 만약 좌측 값이 null이라면 우측 값을 반환한다. 이를 통해 null 처리 시 대체 값을 쉽게 설정할 수 있다. 엘비스 연산자는 null이 될 수 있는 타입에 대해 매우 유용하며, 이를 통해 코드가 간결해지고 null을 다루는 안전성을 유지할 수 있다.
val name: String? = null
val length: Int = name?.length ?: 0 // name이 null이면 0을 반환
println(length) // 0
널 합병 연산자
널 합병 연산자(!!)는 null 값을 허용하지 않는 타입으로 강제 변환할 때 사용한다. 만약 객체가 null이라면 NullPointerException을 발생시킨다. 이 연산자는 강제 변환을 시도하므로, null일 경우 예외가 발생한다. 반드시 주의해서 사용해야 하며, 일반적으로는 안전 호출 연산자나 엘비스 연산자를 사용하는 것이 좋다!!!
val name: String? = null
val length: Int = name!!.length // NullPointerException 발생
5. 프로그램 흐름과 제어구조
조건문과 루프는 프로그램 흐름을 제어하는 핵심 요소다. 조건문은 특정 조건에 따라 프로그램의 실행 경로를 분기시키고, 루프는 반복적인 작업을 처리하는 데 사용된다. Kotlin과 Java는 비슷한 개념을 공유하지만, 문법과 기능에서 차이점이 존재한다.
조건문
조건문은 프로그램의 흐름을 제어하는 가장 기본적인 제어구조로, 특정 조건이 참인지 거짓인지를 확인하고 그에 따라 다른 경로를 선택한다. Kotlin에서 if는 조건문뿐만 아니라 값을 반환하는 표현식(expression)으로도 사용할 수 있다. 즉, if문은 하나의 값으로 평가될 수 있다. 이를 통해 코드가 더욱 간결하고 직관적이다.
int number = 10;
String result;
if (number > 0) {
result = "Positive";
} else {
result = "Negative";
}
System.out.println(result); // Positive
val number = 10
val result = if (number > 0) {
"Positive"
} else {
"Negative"
}
println(result) // Positive
Kotlin은 switch 문을 when 키워드로 대체할 수 있다. when은 값 비교뿐만 아니라 여러 조건을 처리하는 데 강력한 기능을 제공한다.
int number = 2;
String result;
switch (number) {
case 1:
result = "One";
break;
case 2:
result = "Two";
break;
case 3:
result = "Three";
break;
default:
result = "Unknown";
break;
}
System.out.println(result); // Two
val number = 2
val result = when (number) {
1 -> "One"
2 -> "Two"
3 -> "Three"
else -> "Unknown"
}
println(result) // Two
// ...
val number = 5
val result = when (number) {
in 1..10 -> "Between 1 and 10"
else -> "Out of range"
}
println(result) // Between 1 and 10
반복문
루프는 동일한 작업을 여러 번 반복해야 할 때 사용된다. Kotlin과 Java는 for, while, do-while 등의 반복문을 지원하지만, 문법과 기능에서 몇 가지 차이점이 있다.
for (int i = 1; i <= 5; i++) {
System.out.println(i); // 1, 2, 3, 4, 5
}
// ...
int i = 0;
while (i < 5) {
System.out.println(i);
i++;
}
// ...
int i = 0;
do {
System.out.println(i);
i++;
} while (i < 5);
for (i in 5 downTo 1 step 2) {
println(i) // 5, 3, 1
}
// ...
var i = 0
while (i < 5) {
println(i)
i++
}
// ...
var i = 0
do {
println(i)
i++
} while (i < 5)
Kotlin과 Java 모두 break와 continue를 지원하지만, Kotlin에서는 레이블(label) 을 활용하여 더 복잡한 제어 흐름을 다룰 수 있다. Kotlin에서는 break와 continue를 특정 반복문에 지정할 수 있도록 레이블을 붙일 수 있다. 이를 통해 중첩된 반복문에서 특정 반복문만 종료하거나 건너뛰는 것이 가능하다.
for (int i = 1; i <= 3; i++) {
for (int j = 1; j <= 3; j++) {
if (i == 2 && j == 2) break; // 한 번만 break됨
System.out.println("i=" + i + ", j=" + j);
}
}
outerLoop@ for (i in 1..3) {
for (j in 1..3) {
if (i == 2 && j == 2) break@outerLoop
println("i=$i, j=$j")
}
}
6. Checked Exception
일반적으로 예외(Exception)는 프로그램 실행 중 발생할 수 있는 다양한 오류 상황을 처리하기 위한 메커니즘이다. 예외는 크게 Checked Exception과 Unchecked Exception으로 나눌 수 있다.
자세한 내용은 [Error와 Exception의 차이를 아시나요?]를 참고하자.
- Checked Exception: 코드에서 발생할 가능성이 있는 예외를 명시적으로 처리해야 하는 예외다. 예외를 처리하지 않으면 컴파일 오류가 발생한다. 예를 들어, 파일을 열거나 네트워크 연결을 처리할 때 발생할 수 있는 예외들이 해당된다.
- Unchecked Exception: 런타임 예외로, 프로그램 실행 중에 예기치 않게 발생할 수 있으며, 개발자가 반드시 처리하지 않아도 되는 예외다. 대표적으로 NullPointerException, ArrayIndexOutOfBoundsException 등이 있다.
Kotlin에서는 Checked Exception 개념이 없다. 즉, Kotlin은 예외를 강제로 처리하도록 요구하지 않으며, throws를 사용하지 않아도 된다. 모든 예외는 Unchecked Exception처럼 취급되며, 예외가 발생할 가능성이 있는 코드도 명시적으로 예외를 처리할 필요가 없다. Kotlin은 예외를 처리하지 않으면 컴파일 타임 오류가 발생하지 않으므로, 개발자가 필요에 따라 예외를 처리하도록 한다.
import java.io.*;
public class Example {
public static void main(String[] args) {
try {
FileReader file = new FileReader("test.txt"); // IOException이 발생할 수 있음
BufferedReader reader = new BufferedReader(file);
String line = reader.readLine();
reader.close();
} catch (IOException e) {
System.out.println("IOException 처리됨: " + e.getMessage());
}
}
}
// ...
public class Example {
public static void main(String[] args) throws IOException {
FileReader file = new FileReader("test.txt"); // IOException을 던짐
BufferedReader reader = new BufferedReader(file);
String line = reader.readLine();
reader.close();
}
}
import java.io.*
fun main() {
try {
val file = FileReader("test.txt") // IOException 발생 가능
val reader = BufferedReader(file)
val line = reader.readLine()
reader.close()
} catch (e: IOException) {
println("IOException 처리됨: ${e.message}")
}
}
// ...
fun riskyOperation() {
throw IOException("파일을 읽을 수 없습니다.")
}
fun main() {
try {
riskyOperation()
} catch (e: IOException) {
println("IOException 처리됨: ${e.message}")
}
}