코틀린 중급 투어 : Kotlin Documentation
코틀린 공식 문서를 기반으로 작성
자바언어와의 비교를 할 것
Kotlin 중급
코드를 더욱 간결하고 읽기 쉽게 만들어주는 코틀린의 특수 함수들에 대해 설명
이러한 함수들을 활용하여 더욱 효율적인 디자인 패턴을 적용하고 프로젝트를 한 단계 더 발전시키는 방법에 대해 설명
Extension functions
원본 소스 코드를 변경하지 않고 프로그램의 동작을 수정해야 하는 경우가 있을 수 있다.
- ex) 타사 라이브러리의 클래스에 추가 기능을 추가하고 싶은 경우
클래스에 확장 함수를 추가하여 수행가능하다.
확장 함수는 클래스의 멤버 함수(메소드)를 호출하는 것과 같은 방식으로 마침표를 사용해서 호출함.
여기선 수신자라는 말을 쓰게 되는데 .의 앞에 해당하는 부분으로 정보가 공유되는 곳 또는 대상을 의미함.
"hello"라는 String을 수신자로 사용한 예시
fun String.bold(): String = "<b>$this</b>"
fun main() {
println("hello".bold()) // hello라는 부분이 수신자에 해당함.
}
- String은 확장된 클래스
- bold는 확장함수의 이름
- 반환값은 String
- 수신자는 함수내부에서 사용시 this를 통해 접근한다.
확장 지향적 설계
확장 함수는 어디에서든 정의할 수 있으므로 확장 지향적인 설계를 구현할 수 있다.
이러한 설계는 핵심 기능과 유용하지만 필수적이지 않은 기능을 분리하여 코드를 더 쉽고 읽고 유지 관리 할 수 있도록 함.
좋은 예로 아래의 코드를 보자
class HttpClient { //예시용 클래스로 http 통신을 담당하는 클래스
fun request(method: String, url: String, headers: Map<String, String>): HttpResponse {
// Network code
}
}
해당 클래스는 Ktor 라이브러리의 네트워크 요청 처리 클래스가 존재함
이 클래스의 핵심 기능은 request로 HTTP요청에 필요한 정보를 매개변수로 받는 함수임
가장 많이 사용되는 HTTP요청은 GET과 POST로 GET과 POST의 메소드가 별개로 존재하는것이 합리적으로 보일 수 있지만 다음과 같이 확장함수로 만들어 사용이 가능하다.
class HttpClient {
fun request(method: String, url: String, headers: Map<String, String>): HttpResponse {
println("Requesting $method to $url with headers: $headers")
return HttpResponse("Response from $url")
}
}
fun HttpClient.get(url: String): HttpResponse = request("GET", url, emptyMap()) //확장 함수
fun main() {
val client = HttpClient()
//일반적으로 사용하는 방법
val getResponseWithMember = client.request("GET", "https://example.com", emptyMap())
//확장함수를 사용한 방법
val getResponseWithExtension = client.get("https://example.com")
}
이러한 확장 지향적 접근 방식은 Kotlin의 표준 라이브러리 및 기타 라이브러리에서 널리 사용된다.
Scope Functions
범위 함수를 뜻하는 말고 프로그래밍에서 스코프(Scope)는 변수나 객체가 인식되는 영역을 말함.
일반적으로 언급되는 스코프
- 전역 범위 ( Global Scope )
- 프로그램 내 어디에서든 접근 가능한 변수 또는 객체
- 지역 범위 ( Local Scope )
- 정의된 블록이나 함수 내에서만 접근 가능한 변수 또는 객체
Kotlin에는 객체 주위에 임시 스코프를 생성하고 코드를 실행할 수 있는 범위 함수도 존재
즉, 객체의 이름을 반복해서 쓰지 않고 임시 스코프 안에서 간결하게 처리할 수 있도록 해주는 함수
객체에 접근하는 방식
- this
- 객체 자신을 참조 (수신자로 참조)
- it
- 객체를 람다의 인수로 접근
kotlin의 스코프 함수 종류
- let
- apply
- run
- also
- with
let
코드에서 null검사를 수행하고 나중에 반환된 객체를 사용하여 추가적인 작업을 수행하려는 경우 let 스코프 함수를 사용
fun sendNotification(recipientAddress: String): String {
println("Yo $recipientAddress!")
return "Notification sent!"
}
fun getNextAddress(): String {
return "sebastian@jetbrains.com"
}
fun main() {
val address: String? = getNextAddress()
sendNotification(address)
}
해당 코드의 경우 sendNotification 함수의 매개변수는 Null이 불가능하지만 address는 nullable이기 때문에 컴파일러는 오류를 불러 일으킨다. 하지만 address를 Null이 불가능한 타입을 바꾸자니 다른 문제가 발생 할 수도 있으며, sendNotification의 매개변수를 Nullable하게 만들면 추후 메일을 보내는 로직을 만들어야 하지만 null값이 온다면 문제가 생긴다.
따라서 if문을 사용해서 막을 수 있을 것이다.
fun main() {
val address: String? = getNextAddress()
val confirm = if(address != null) {
sendNotification(address)
} else { null }
}
하지만 이보다 더 간결하게 해결하기 위한 것이 let으로 다음과 같이 작성이 가능하다.
fun main() {
val address: String? = getNextAddress()
val confirm = address?.let{
sendNotification(it) // it은 address을 의미한다.
}
}
이 방법을 사용해서 address변수가 null값을 가질 가능성을 처리하고, 나중에 confirm변수를 사용 가능하다.
또한 it 대신 내가 원하는 키워드를 사용할수 있다.
fun main() {
val address: String? = getNextAddress()
val confirm = address?.let{ addr ->
sendNotification(addr)
}
}
람다 매개변수 이름 지정하는 방식과 동일한 방식으로 설정 가능
apply
객체를 만들자 마자 바로 초기화를 시켜줌으로써 나중에 따로 초기화하는 것보다 코드를 읽기쉽게 만드는 목적을 가짐.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
val client = Client() //객체 생성
fun main() {
client.token = "asdf" //객체의 필드 초기화
client.connect()
client.authenticate()
client.getData()
}
main 함수 내부에서 token을 할당하고, connect()을 실행하는 등 여러가지 일을 따로 진행해야하지만 apply는 이런 작업을 다음과 같이 진행 시켜준다.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
val client = Client().apply { //생성
token = "asdf" //즉시 필드 할당
connect()
authenticate()
}
fun main() {
client.getData()
}
필요한 기능을 한곳에 모아서 진행시키면 한번에 보기 편한 코드가 된다.
해당 전략은 대규모의 코드에서 사용하기 편리하다.
run
apply는 객체 자신을 반환하지만 run의 경우 블록의 마지막 표현식 결과를 반환하는 특징이 존재하므로 초기화 후 어떤 결과값을 반환 받고 싶을땐 run을 사용하는 것이 좋다.
class Client() {
var token: String? = null
fun connect() = println("connected!")
fun authenticate() = println("authenticated!")
fun getData() : String {
println("getting data!")
return "Mock data"
}
}
val client: Client = Client().apply {
token = "asdf"
}
fun main() {
val result: String = client.run {
connect()
authenticate()
getData()
}
}
해당 로직은 getData()의 결과가 반환된다.
also
객체에 대한 추가 작업을 완료한 다음 객체를 반환하여 코드에서 계속 사용 가능하다.
예를 들어 다음과 같은 작업을 예로 들어보자
fun main() {
val medals: List<String> = listOf("Gold", "Silver", "Bronze")
val reversedLongUppercaseMedals: List<String> =
medals
.map { it.uppercase() }
.filter { it.length > 4 }
.reversed()
println(reversedLongUppercaseMedals)
}
해당 작업을 진행하면 medals의 리스트에서 length가 4보다 큰 값들의 대해 순서를 반전해서 대문자로 반환하는 값이다.
따라서 결과는 ["BRONZE","SILVER"]가 된다.
하지만 내가 원하는 값이 잘 나오는지 도중에 궁금해진다면 중간중간 print를 찍어 확인해보면 좋을테니 also를 사용해 데이터가 변경되는것을 보자
fun main() {
val medals: List<String> = listOf("Gold", "Silver", "Bronze")
val reversedLongUppercaseMedals: List<String> =
medals
.map { it.uppercase() }
.also { println(it) }
.filter { it.length > 4 }
.also { println(it) }
.reversed()
println(reversedLongUppercaseMedals)
}
/* 출력값
[GOLD, SILVER, BRONZE]
[SILVER, BRONZE]
[BRONZE, SILVER]
*/
해당 로직이 진행되면서 데이터가 변경되는 것을 볼 수 있다.
also 함수는 객체를 반환하므로 로깅 작업이외에도 디버깅, 여러작업을 연결하거나, 코드의 주요 흐름에 영향을 주지 않는 다른 작업을 수행하는데 유용하다.
with
with는 다른 스코프 함수들과 다르게 확장 함수가 아니다. 따라서 객체.함수()인 방식과 다르게 with(객체)로 사용된다.
수신자 객체를 with의 인수로 전달하게 된다.
다음과 같은 예시를 보자
class Canvas {
fun rect(x: Int, y: Int, w: Int, h: Int): Unit = println("$x, $y, $w, $h")
fun circ(x: Int, y: Int, rad: Int): Unit = println("$x, $y, $rad")
fun text(x: Int, y: Int, str: String): Unit = println("$x, $y, $str")
}
fun main() {
val mainMonitorPrimaryBufferBackedCanvas = Canvas()
mainMonitorPrimaryBufferBackedCanvas.text(10, 10, "Foo")
mainMonitorPrimaryBufferBackedCanvas.rect(20, 30, 100, 50)
mainMonitorPrimaryBufferBackedCanvas.circ(40, 60, 25)
mainMonitorPrimaryBufferBackedCanvas.text(15, 45, "Hello")
mainMonitorPrimaryBufferBackedCanvas.rect(70, 80, 150, 100)
mainMonitorPrimaryBufferBackedCanvas.circ(90, 110, 40)
mainMonitorPrimaryBufferBackedCanvas.text(35, 55, "World")
mainMonitorPrimaryBufferBackedCanvas.rect(120, 140, 200, 75)
mainMonitorPrimaryBufferBackedCanvas.circ(160, 180, 55)
mainMonitorPrimaryBufferBackedCanvas.text(50, 70, "Kotlin")
}
사용시 매번 객체와 함수를 꺼내 사용하는것이 불편하다. 따라서 다음과 같이 with를 사용한다.
class Canvas {
fun rect(x: Int, y: Int, w: Int, h: Int): Unit = println("$x, $y, $w, $h")
fun circ(x: Int, y: Int, rad: Int): Unit = println("$x, $y, $rad")
fun text(x: Int, y: Int, str: String): Unit = println("$x, $y, $str")
}
fun main() {
val mainMonitorSecondaryBufferBackedCanvas = Canvas()
with(mainMonitorSecondaryBufferBackedCanvas) { //with(객체) 방식
text(10, 10, "Foo")
rect(20, 30, 100, 50)
circ(40, 60, 25)
text(15, 45, "Hello")
rect(70, 80, 150, 100)
circ(90, 110, 40)
text(35, 55, "World")
rect(120, 140, 200, 75)
circ(160, 180, 55)
text(50, 70, "Kotlin")
}
}
객체 매번 넣는것이 아닌 람다표현식을 사용해 중괄호 내에서 함수와 매개변수들을 넣어 사용이 가능하다.
읽기 쉬워졌으므로 실수를 일으킬 가능성이 줄어든다.
사용 사례 요약
| 함수 | 접근 방식 | 반환 값 | 사용사례 |
| let | it | 람다결과 | 코드에서 null 검사를 수행하고, 반환된 객체를 사용하여 추가적인 작업 수행 |
| apply | this | 객체 자신 | 객체 생성 시점에 초기화할 때 |
| run | this | 람다 결과 | 객체 생성시 객체를 초기화하고 로직의 결과가 반환값이 필요할때 |
| also | it | 객체 자신 | 주요 흐름에 영향을 주지 않고 로깅, 디버깅등 부가작업이 필요할때 |
| with | this | 람다 결과 | 객체에 대해 여러 함수를 호출 할때 |
수신자가 있는 람다
람다표현식은 수신자를 가질 수도 있다. 이런 수신자가 있는 람다 표현식은 수신자가 있는 함수 리터럴이라고도 한다.
수신자를 가질 경우 람다는 수신자를 매번 명시적으로 지정하지 않고도 수신자의 모든 맴버 함수나 속성에 접근 가능
이런 추가적인 참조가 없기 때문에 코드를 더 쉽게 읽고 유지 관리 가능하다.
MutableList<Int>.() -> Unit
수신자를 포함 하는 람다 표현식의 구문은 함수 유형을 정의할때 위와 같이 진행한다.
- MutableList<Int>
- 수신자
- ()
- 매개변수 즉, 없다.
- Unit
- 반환값 없다. void와 같은 개념
실제 사용 예시를 보자
class Canvas {
fun drawCircle() = println("🟠 Drawing a circle")
fun drawSquare() = println("🟥 Drawing a square")
}
fun render(block: Canvas.() -> Unit): Canvas {
val canvas = Canvas()
canvas.block()
return canvas
}
fun main() {
render {
drawCircle()
drawSquare()
}
}
- render()
- 매개변수 block는 수신자를 포함하는 람다식이며, 여기서 Canvas 클래스는 수신자가 된다.
- 반환값은 Canvas가 된다.
- main()
- render안에 값들이 매개변수로 넘어간다.
- 즉, drawCircle()가 매개변수 block으로 넘어감에 따라 Canvas.drawCircle()이 된다.
수신자를 사용하는 람다 표현식은 도메인 특화 언어(DSL)를 만들때 유용하다.
수신자를 명시적으로 참조하지 않고도 수신자의 멤버 함수와 속성에 접근 가능하므로 코드가 더 간결해진다.
예를 들어 이와 같은 클래스가 있다고 예를 들면
class MenuItem(val name: String) // 메뉴 아이템 하나를 나타내는 클래스
class Menu(val name: String) {
val items = mutableListOf<MenuItem>() // 메뉴 아이템 목록
fun item(name: String) { // 메뉴 아이템 추가 함수
items.add(MenuItem(name))
}
}
fun menu(name: String, init: Menu.() -> Unit): Menu {
val menu = Menu(name) // Menu 인스턴스 생성
menu.init() // 전달받은 람다를 menu를 수신자로 실행
return menu // 완성된 menu 반환
}
fun main() {
val mainMenu = menu("Main Menu") { // "Main Menu" 이름으로 Menu 생성
item("Home") // Menu.item("Home") 과 동일
item("Settings") // Menu.item("Settings") 과 동일
item("Exit") // Menu.item("Exit") 과 동일
}
}
init()이 실행될 때 Menu가 수신자가 되므로 menu.item()대신 item() 으로 바로 호출 가능하다.
즉 수신자를 매번 명시하지 않아도 되므로 코드가 간결해진다.
람다 표현식은 설정 및 생성뿐만 아니라 구성에서도 유용하다. API, UI프레임워크 및 구성빌더용 DSL을 구축할때 일반적으로 사용되어 코드를 간소화하고 기본 코드 구조와 논리에 더 쉽게 집중할 수 있도록 도와준다.
클래스 상속
코틀린에서는 기본적으로 클래스의 상속이 불가능하다.
이는 의도치 않은 상속을 방지하고, 클래스 유지 관리를 용이하게 하기 위해 코틀린이 설계된 방식이다.
만일 상속이 필요하다면 open 키워드를 사용해서 상속한다.
또한 모든 클래스의 최상위 부모 클래스는 Any다.
추상 클래스
추상 클래스는 기본적으로 상속이 가능하다.
추상 클래스의 목적은 다른 클래스가 상속하거나 구현 할 수 있는 멤버를 제공하는 것.
따라서 추상 클래스는 생성자를 가지지만, 인스턴스를 직접 생성할 순 없다.
자식 클래스에서는 override키워드를 사용해서 부모 클래스의 속성과 함수의 동작을 정의 할 수 있다. 즉, 자식클래스는 부모 클래스의 멤버를 재정의가 가능하다.
추상 클래스는 구현이 있는 함수와 속성을 포함할 수 있을 뿐 아니라, 구현이 없는 함수와 속성도 포함될 수 있다.
abstract class Product(val name: String, var price: Double) {
abstract val category: String // 추상적으로 선언한 변수
fun productInfo(): String { // 명시적으로 선언된 함수
return "Product: $name, Category: $category, Price: $price"
}
}
class Electronic(name: String, price: Double, val warranty: Int) : Product(name, price) {
override val category: String = "Electronics" // 필수로 override 해야함
}
fun main(){
val laptop = Electronic(name = "Laptop", price = 1000.0, warranty = 2)
println(laptop.productInfo())
}
추상적으로 선언된 변수나 함수는 override를 해주지 않으면 컴파일 오류가 발생된다.
추상클래스는 이런식으로 코드를 공유하는데 유용하지만, 코틀린의 클래스는 단일 상속만 지원하기 때문에 여러 클래스를 상속 해야하는 경우 인터페이스를 사용함
인터페이스
- 인터페이스의 인스턴스는 생성할 수 없다. 인터페이스에는 생성자나 헤더 파일이 없기 때문
- 이러한 함수와 속성은 기본적으로 암묵적으로 상속이 된다.
- 인터페이스의 함수와 속성은 기본적으로 open임을 의미한다.
- 구현체를 제공하지 않는 경우 해당 함수에 abstract 키워드를 사용할 필요가 없다.
추상 클래스와 유사하게, 인터페이스는 클래스가 상속받고 구현할 수 있는 함수와 속성 집합을 정의하는데 사용된다.
이 접근 방식을 통해 구체적인 구현 세부 사항보다는 인터페이스가 설명하는 추상화에 집중할 수 있다.
인터페이스 사용시 장점
- 모듈식 구조처럼 좋은 객체지향적 디자인을 가질 수 있으며, 각 부분을 분리하여 독립적으로 만들 수있다.
- 관련 기능들을 하나의 응집력 있는 집합으로 묶으면 코드의 이해가 더 쉽다.
- 테스트를 위해 구현체를 모의 객체로 빠르게 교체가 가능해서 테스트 하기 용이하다.
인터페이스를 선언하려면 interface 키워드를 사용한다.
인터페이스 구현
인터페이스는 다중 상속을 지원하므로 클래스는 동시에 여러 인터페이스를 구현 가능하다.
interface PaymentMethod { //필수적으로 구현해야하는 구조를 만드는 인터페이스
fun initiatePayment(amount: Double): String
}
class CreditCardPayment(val cardNumber: String, val cardHolderName: String, val expiryDate: String) : PaymentMethod {
override fun initiatePayment(amount: Double): String { // 재정의를 통해 구현
return "Payment of $$amount initiated using Credit Card ending in ${cardNumber.takeLast(4)}."
}
}
fun main() {
val paymentMethod = CreditCardPayment("1234 5678 9012 3456", "John Doe", "12/25")
//CreditCardPayment는 PaymentMethod의 구현체기 때문에 PaymentMethod에 담을 수 있다.
println(paymentMethod.initiatePayment(100.0))
}
여러 인터페이스를 구현하는 경우 아래와 같이 가능하다.
interface PaymentMethod {
fun initiatePayment(amount: Double): String
}
interface PaymentType {
val paymentType: String
}
class CreditCardPayment(
val cardNumber: String,
val cardHolderName: String,
val expiryDate: String
) : PaymentMethod, PaymentType {
override fun initiatePayment(amount: Double): String {
return "Payment of $$amount initiated using Credit Card ending in ${cardNumber.takeLast(4)}."
}
override val paymentType: String = "Credit Card"
}
fun main() {
val paymentMethod = CreditCardPayment("1234 5678 9012 3456", "John Doe", "12/25")
println(paymentMethod.initiatePayment(100.0))
println("Payment is by ${paymentMethod.paymentType}")
}
Delegation
위임 패턴이라고 하며, 인터페이스에 많은 함수가 포함되면 자식 클래스에서 반복적인 코드가 많이 생길 수 있으며, 클래스의 동작 중 일부만 재정의하려는 경우에도 코드를 많이 반복해야할때 사용하는 패턴이다.
interface DrawingTool {
val color: String
fun draw(shape: String)
fun erase(area: String)
fun getToolInfo(): String
}
class PenTool : DrawingTool {
override val color: String = "black"
override fun draw(shape: String) {
println("Drawing $shape using a pen in $color")
}
override fun erase(area: String) {
println("Erasing $area with pen tool")
}
override fun getToolInfo(): String {
return "PenTool(color=$color)"
}
}
이 상황에 CanvasSession으로 color만을 구현하고 싶을 때, 다시 모든 함수까지도 구현을 해야하지만 위임 패턴을 사용하게 되면 필요한 부분만 정의하고 사용하는 것이 가능하다.
class CanvasSession(val tool: DrawingTool) : DrawingTool by tool {
override val color: String = "blue"
}
이와 같이 불필요한 부분을 제외하고 필요한 것만 구현해서 사용이 가능하다.
객체 선언
코틀린에서는 object 선언을 사용해 단 하나의 인스턴스만 가지는 클래스를 선언 할 수 있다.
클래스 선언과 인스턴스 생성이 동시에 이루어진다.
하나의 인스턴스만 존재하여 쉽게 접근 가능한 클래스를 싱글톤이라고 한다.
코틀린의 object는 지연 초기화(lazy)로 동작하며, 처음 접근할 때만 생성된다.
또한 코틀린이 스레드를 안전하게 생성을 보장한다. object는 생성자를 가질 수 없으므로 클래스처럼 헤더가 없다.
object DoAuth {
fun takeParams(username: String, password: String) {
println("input Auth parameters = $username:$password")
}
}
fun main(){
DoAuth.takeParams("coding_ninja", "N1njaC0ding!")
}
object 또한 클래스와 인터페이스를 상속 받을 수 있다.
data object
Kotlin은 객체 선언의 내용을 더 쉽게 출력할 수 있도록 데이터 객체를 제공한다.
자동으로 toString()와 equals()를 제공한다.
data object AppConfig {
var appName: String = "My Application"
var version: String = "1.0.0"
}
fun main() {
println(AppConfig)
println(AppConfig.appName)
}
data class와 data object
- data class
- 여러 인스턴스 생성가능
- equals(), copy(), toString() 자동 제공
- 데이터를 담는 용도
- data object
- 싱글톤 보장
- toString 자동 제공, equals()는 자동 제공되도 의미가 없다.
- 상태 없이 고정된 값을 나타내는 용도
companion object
클래스는 클래스당 하나씩 컴패니언 객체를 가질 수있다.
컴패니언 객체는 해당 클래스가 처음 참조될 때만 생성된다.
컴패니언 객체 내부에 선언된 속성이나 함수는 모든 클래스 인스턴스에서 공유된다.
자바의 static과 동일한 역할이다.
class BigBen{
companion object Bonger {
fun getBongs(nTimes: Int) {
repeat(nTimes) { print("BONG ") }
}
}
}
fun main() {
BigBen.getBongs(12) // 클래스 참조될때 캠패니언 객체가 생성
}
Open class
인터페이스나 추상 클래스를 사용할 수 없는 경우, 클래스를 명시적으로 상속 가능하도록 선언할 수 있다.
open 키워드를 사용한다.
open class Vehicle(val make: String, val model: String)
class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model)
fun main() {
val car = Car("Toyota", "Corolla", 4)
println("Car Info: Make - ${car.make}, Model - ${car.model}, Number of doors - ${car.numberOfDoors}")
}
일반 클래스 인스턴스를 생성할 때와 마찬가지로, 클래스가 부모 클래스를 상속받는 경우 부모 클래스의 헤더에 선언된 모든 매개변수를 초기화해야 한다.
상속된 함수 재정의
open class Vehicle(val make: String, val model: String){
open fun displayInfo(){
println("Vehicle Info: Make - $make, Model - $model")
}
}
class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model) {
override fun displayInfo() {
println("Car Info: Make - $make, Model - $model, Number of Doors - $numberOfDoors")
}
}
fun main() {
val car1 = Car("Toyota", "Corolla", 4)
val car2 = Car("Honda", "Civic", 2)
car1.displayInfo()
car2.displayInfo()
}
상속될 함수 또한 open 키워드를 사용하고, 상속받은 클래스에서 재정의시 override를 사용한다.
상속된 속성
open 키워드로 속성을 override하는 방식은 권장하지 않는다.
코틀린에서 open 키워드로 속성을 상속 가능하게 만들고 자식 클래스에서 override하는 방식은 일반적인 관행이 아니다.
대부분 경우 속성이 기본적으로 상속 가능한 추상 클래스나 인터페이스를 사용한다.
만약 open 클래스에서 속성을 변경하고 싶다면 부모 클래스 생성자에 속성을 선언하고 자식 클래스에서 값을 전달하는 방식이 더 좋다.
// 권장하지 않는 방식
open class Vehicle(val make: String, val model: String) {
open val transmissionType: String = "Manual"
}
class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model) {
override val transmissionType: String = "Automatic"
}
// 권장하는 방식
open class Vehicle(val make: String, val model: String, val transmissionType: String = "Manual")
class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model, "Automatic")
해당 방식을 권장하는 이유
- 속성을 부모 클래스에 한번만 선언하면 됨
- 자식 클래스에서 불필요한 override가 사라짐
- 코드가 더 단순하고 읽기 쉬워짐
즉, 속성은 override 하지말고 생성자로 값을 전달하는 게 코틀린다운 방식임.
클래스 상속과 인터페이스 동시구현
클래스를 상속 받으면서 동시에 여러 인터페이스를 구현하는 것이 가능하다.
즉, 단일 상속 + 인터페이스 다중 구현이 동시에 가능하다는 것.
이 경우 콜론 뒤에 부모 클래스 먼저 쓰고, 그다음에 인터페이스들을 나열해야 한다.
interface EcoFriendly {
val emissionLevel: String
}
interface ElectricVehicle {
val batteryCapacity: Double
}
open class Vehicle(val make: String, val model: String)
open class Car(make: String, model: String, val numberOfDoors: Int) : Vehicle(make, model)
class ElectricCar(
make: String,
model: String,
numberOfDoors: Int,
val capacity: Double,
val emission: String
) : Car(make, model, numberOfDoors), EcoFriendly, ElectricVehicle {
override val batteryCapacity: Double = capacity
override val emissionLevel: String = emission
}
Special class
Kotlin은 추상 클래스, open 클래스, 데이터 클래스 이외에도 특정 동작을 제한하거나 작은 객체 생성으로 인한 성능 저하를 줄이는 등 다양한 목적을 위해 설계가 된 특수 클래스 유형을 제공한다.
sealed class
상속을 제한하고 싶을때, 봉인 클래스를 사용 할 수 있다.
봉인 클래스는 추상 클래스의 특별한 유형임.
클래스를 봉인 클래스로 선언 하면, 같은 패키지 내에서만 해당 클래스의 자식 클래스를 생성할 수 있다.
즉, 패키지 범위를 벗어나서는 봉인 클래스를 상속 받을 수 없다.
sealed class Mammal(val name: String)
class Cat(val catName: String) : Mammal(catName)
class Human(val humanName: String, val job: String) : Mammal(humanName)
fun greetMammal(mammal: Mammal): String {
when (mammal) { //sealed class는 모든 자식 클래스가 컴파일러에게 알려져있어 when에서 모든 케이스 처리 시 else가 없어도 된다.
is Human -> return "Hello ${mammal.name}; You're working as a ${mammal.job}"
is Cat -> return "Hello ${mammal.name}"
}
}
fun main() {
println(greetMammal(Cat("Snowy")))
}
봉인 클래스는 when 표현식과 사용할때 유용하다.
Enum class
열거형 클래스라고 부르며 클래스 내에서 유한한 수의 서로 다른 값을 표현하고자 할 때 유용하다.
열거형 클래스는 열거형 상수를 포함하며, 이 상수 들은 그 자체로 열거형 클래스의 인스턴스다.
enum class State {
IDLE, RUNNING, FINISHED
}
fun main() {
val state = State.RUNNING
val message = when (state) {
State.IDLE -> "It's idle"
State.RUNNING -> "It's running"
State.FINISHED -> "It's finished"
}
println(message)
}
enum 키워드를 사용함.
열거형 클래스는 일반 클래스 처럼 속성과 맴버함수를 가질 수 있다.
enum class Color(val rgb: Int) {
RED(0xFF0000),
GREEN(0x00FF00),
BLUE(0x0000FF),
YELLOW(0xFFFF00);
fun containsRed() = (this.rgb and 0xFF0000 != 0)
}
fun main() {
val red = Color.RED
// Calls containsRed() function on enum constant
println(red.containsRed())
// true
// Calls containsRed() function on enum constants via class names
println(Color.BLUE.containsRed())
// false
println(Color.YELLOW.containsRed())
// true
}
열거형 클래스에서 함수를 선언하려면 열거형 상수가 끝나고 세미클론으로 구분해줘야함.
Inline value class
클래스로부터 작은 객체를 생성하고 잠깐 동안만 사용하는 경우가 있을 수 있다. 하지만 이러한 방식은 성능에 영향을 미칠 수 있다. 인라인 값 클래스는 이러한 성능 저하를 방지하는 특별한 유형의 클래스.
단, 인라인 값 클래스는 값만 포함될 수 있다.
@JvmInline
value class Email(val address: String)
fun sendEmail(email: Email) {
println("Sending email to ${email.address}")
}
fun main() {
val myEmail = Email("example@example.com")
sendEmail(myEmail)
}
인라인 값 클래스를 사용하면 클래스 자체가 인라인화 되어 객체를 생성하지 않고도 코드에서 직접 사용할 수 있다.
이를 통해서 메모리 사용량을 크게 줄이고 코드의 런타임 성능을 향상 시킬 수 있다.
Backing fields
코틀린에서 속성을 선언하면 내부적으로 실제 값을 저장하는 backing field가 자동적으로 생성된다.
다음 두 가지 조건 중 하나라도 해당된다면 backing field가 존재한다.
- 기본 get() 또는 set() 함수를 사용할때
- field 키워드로 속성 값에 접근할때
class Contact(val id: Int, var email: String) {
var category: String = ""
}
해당 코드는 내부적으로 다음과 같이 동작함
var category: String = ""
get() = field // field에서 값을 가져옴
set(value) {
field = value // field에 값을 저장
}
field 키워드가 필요한 이유
set()안에서 속성을 직접 참조하면 무한 루프가 발생함
// 잘못된 방식 - StackOverflowError 발생
set(value) {
name = value.replaceFirstChar { it.uppercase() } // name = 을 호출하면 set()이 또 호출됨
}
// 올바른 방식 - field 키워드 사용
set(value) {
field = value.replaceFirstChar { it.uppercase() } // field에 직접 저장
}
name = 을 호출하면 set()이 또 호출되고, 그안에서 또 name = 을 호출하는 무한 루프가 생기게 된다.
field 키워드를 쓰면 set()을 거치지 않고 직접 저장하기 때문에 무한루프를 발생시키지 않는다.
backing field 활용 사례
- 로깅
- 속성 값 변경시 알림
- 이전 값과 새 값 비교 로직
자바는 setter/getter를 직접 만들지만 위와 같은 방식으로 코틀린은 자동적으로 만들어준다.
Extension Properties (확장 프로퍼티)
확장 함수처럼 확장 프로퍼티도 존재한다. 확장 프로퍼티는 소스 코드를 수정하지 않고 기존 클래스에 새로운 속성을 추가할 수 있게 해준다.
주의점
- 확장 프로퍼티는 backing field가 없다.
- 따라서 get()과 set()을 직접 작성해야한다.
- backing field가 없으므로 상태를 가질 수 없다.
- 기존 클래스의 속성을 override 할 수 없다.
확장 프로퍼티는 상속 없이 계산된 값을 속성처럼 접근하고 싶을 때 유용하다.
data class Person(val firstName: String, val lastName: String)
// 확장 프로퍼티
val Person.fullName: String
get() = "$firstName $lastName"
fun main() {
val person = Person(firstName = "John", lastName = "Doe")
println(person.fullName) // John Doe
}
확장 함수처럼 코틀린 표준 라이브러리에도 널리 사용된다.
대표적인 예로 CharSequence의 lastIndex 프로퍼티가 있다.
Delegated Properties (위임된 프로퍼티)
프로퍼티에서도 위임을 사용할 수 있다.
프로퍼티의 getter/setter를 다른 객체에 위임하는 것
다음과 같은 복잡한 요구사항이 있을 때 유용하다.
- 데이터 베이스에 값 저장
- 브라우저 세션에 값 저장
- 맵에 값 저장
또한 getter/setter로직이 위임 객체 안에만 있으므로 보일러플레이트 코드가 줄어든다.
val displayName: String by Delegate
문법은 위와 같다 by 키워드 뒤에 위임할 객체를 선언
위임 객체의 조건
- getValue() 함수 필수
- 프로퍼티가 mutable이면 setValue()함수도 필수
operator fun getValue(thisRef: Any?, property: KProperty<*>): String {}
operator fun setValue(thisRef: Any?, property: KProperty<*>, value: String) {}
각 매개변수 설명
- thisRef
- 위임된 프로퍼티를 포함하는 객체
- property
- 접근되거나 변경되는 프로퍼티 정보
- value (setValue 한정)
- 새로 할당될 값
실제 사용 예시
class CachedStringDelegate {
var cachedValue: String? = null
operator fun getValue(thisRef: User, property: Any?): String {
if (cachedValue == null) {
cachedValue = "${thisRef.firstName} ${thisRef.lastName}"
println("Computed and cached: $cachedValue")
} else {
println("Accessed from cache: $cachedValue")
}
return cachedValue ?: "Unknown"
}
}
class User(val firstName: String, val lastName: String) {
val displayName: String by CachedStringDelegate()
}
fun main() {
val user = User("John", "Doe")
println(user.displayName) // 처음 접근 시 계산 후 캐싱
// Computed and cached: John Doe
println(user.displayName) // 이후 접근은 캐시에서 반환
// Accessed from cache: John Doe
}
displayName에 처음 접근할 때만 계산하고, 이후에는 캐시된 값을 반환하므로 성능 저하 없이 언제든지 접근 가능하다.
표준 위임 (Standard Delegates)
코틀린 표준 라이브러리는 직접 위임 객체를 만들지 않아도 되는 유용한 위임들을 제공한다.
이 위임을 사용하면 getValue()와 setValue()를 직접 정의할 필요가 없다.
Lazy 프로퍼티
프로퍼티를 처음 접근할 때만 초기화하고 싶을 때 사용
lazy() 함수에 람다를 전달하면 되고, 처음 접근할 때 람다가 실행되고 이후에는 동일한 값을 반환한다.
val databaseConnection: Database by lazy {
val db = Database()
db.connect() // 처음 접근할 때만 실행
db // 반환값
}
fun fetchData() {
val data = databaseConnection.query("SELECT * FROM data")
println("Data: $data")
}
fun main() {
fetchData() // 처음 접근 - connect() 실행됨
// Connecting to the database...
// Data: [Data1, Data2, Data3]
fetchData() // 이후 접근 - 캐시된 값 반환
// Data: [Data1, Data2, Data3]
}
Lazy 프로퍼티가 유용한 경우
- 초기화 비용이 큰 경우
- 해당 프로퍼티가 실제로 사용되지 않을 수도 있는 경우
- 기본적으로 스레드 안전하게 동작하므로 동시성 환경에서도 안전하게 사용 가능
Observable 프로퍼티
프로퍼티 값이 변경되는 것을 감지하고 싶을때 사용한다.
값이 변경될 때마다 람다가 실행된다.
사용하려면 kotlin.properties.Delegates.observable를 import해야한다.
import kotlin.properties.Delegates.observable
class Thermostat {
var temperature: Double by observable(20.0) { _, old, new ->
if (new > 25) {
println("Warning: Temperature is too high! ($old°C -> $new°C)")
} else {
println("Temperature updated: $old°C -> $new°C")
}
}
}
fun main() {
val thermostat = Thermostat()
thermostat.temperature = 22.5
// Temperature updated: 20.0°C -> 22.5°C
thermostat.temperature = 27.0
// Warning: Temperature is too high! (22.5°C -> 27.0°C)
}
람다에 사용된 세가지 매개변수
- _ : 프로퍼티 자체 (보통 사용하지 않아서 _로 표기)
- old : 변경 전 값
- new : 변경 후 값
Observable 프로퍼티 활용 사례
- 로깅 및 디버깅
- UI 업데이트
- 데이터 유효성 검사
스마트 캐스팅과 세이프 캐스팅
스마트 캐스팅을 객체의 타입을 확인할 때 사용하며 is연산자를 사용하게 된다.
세이프 캐스팅을 위해선 as?연산자를 사용하게 된다.
is 와 !is 연산자
객체의 타입을 확인할 때 사용
- is : 해당 타입이면 true 반환
- !is : 해당 타입이 아니면 true 반환
fun printObjectType(obj: Any) {
when (obj) {
is Int -> println("It's an Integer with value $obj")
!is Double -> println("It's NOT a Double")
else -> println("Unknown type")
}
}
as 와 as? 연산자
타입을 명시적으로 변환할 때 사용된다.
- as : 강제 캐스트, 실패 시 런타임 에러 발생(unsafe cast)
- as? : 안전한 캐스트, 실패시 null 반환 (safe cast)
val a: String? = null
val b1 = a as String // 런타임 에러 발생!
val b2 = a as? String // null 반환
as? 와 엘비스 연산자 조합
// 일반적인 방식
fun calculateTotalStringLength(items: List<Any>): Int {
var totalLength = 0
for (item in items) {
totalLength += if (item is String) item.length else 0
}
return totalLength
}
// as?와 엘비스 연산자로 한 줄로 줄인 방식
fun calculateTotalStringLength(items: List<Any>): Int {
return items.sumOf { (it as? String)?.length ?: 0 }
}
각 요소를 String으로 안전하게 캐스트 하고, 실패시 0을 반환
Null values and collections
컬렉션에서 null 값을 처리하는 유용한 함수들이 있다.
filterNotNull()
null 값을 제거하고 non-null값만 반환
val emails: List<String?> = listOf("alice@example.com", null, "bob@example.com", null, "carol@example.com")
val validEmails = emails.filterNotNull()
println(validEmails)
// [alice@example.com, bob@example.com, carol@example.com]
listOfNotNull()
리스트 생성 시 null 값을 제거
val serverConfig = mapOf("appConfig.json" to "App Configuration", "dbConfig.json" to "Database Configuration")
val requestedFile = "appConfig.json"
val configFiles = listOfNotNull(serverConfig[requestedFile])
println(configFiles)
// [App Configuration]
filterNotNull()함수와 listOfNotNull() 함수는 모든 값이 null이면 빈 리스트를 반환
maxOrNull() / minOrNull()
최댓값/최솟값을 반환하고 없으면 null을 반환
val temperatures = listOf(15, 18, 21, 21, 19, 17, 16)
println(temperatures.maxOrNull() ?: "No data") // 21
println(temperatures.minOrNull() ?: "No data") // 15
singleOrNull()
조건에 맞는 단 하나의 값을 반환하고, 없거나 여러 개면 null을 반환
val singleHotDay = temperatures.singleOrNull { it == 30 }
println(singleHotDay ?: "None") // None
firstNotNullOfOrNull()
람다로 컬렉션을 변환해서 null이 아닌 첫 번째 값을 반환
data class User(val name: String?, val age: Int?)
val users = listOf(User(null, 25), User("Alice", null), User("Bob", 30))
println(users.firstNotNullOfOrNull { it.name }) // Alice
reduceOrNull()
컬렉션을 순차적으로 처리해서 누적 값을 반환하고, 컬렉션이 비어있으면 null을 반환
val itemPrices = listOf(20, 35, 15, 40, 10)
val totalPrice = itemPrices.reduceOrNull { runningTotal, price -> runningTotal + price }
println(totalPrice ?: "No items") // 120
maxOrNull(), minOrNull(), singleOrNull(), reduceOrNull() 는 null 값이 없는 컬렉션에서 사용하도록 설계됨
엘비스 연산자와 early return
엘비스 연산자 ?:를 early return과 함께 사용하면 함수에서 사전 조건을 간결하게 체크 할 수 있다.
중첩 조건문 없이 코드를 간결하게 유지할 수 있어서 유지 보수에 용이하다.
data class User(
val id: Int,
val name: String,
val friends: List<Int>
)
fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
val user = users[userId] ?: return -1 // null이면 즉시 -1 반환
return user.friends.size
}
해당 로직은 아래와 같이 더 줄일 수 있다.
fun getNumberOfFriends(users: Map<Int, User>, userId: Int): Int {
return users[userId]?.friends?.size ?: -1
}
조건을 여러개 추가해서 여러 에러 케이스를 처리하는 것도 가능하다. 엘비스 연산자와 early return을 활용하면 null값이나 잘못된 케이스를 즉시 감지해서 불필요한 작업 방지하고 코드를 더 안전하게 만들 수 있다.
표준 라이브러리 활용
코틀린을 최대한 활용하려면 기존 라이브러리와 API를 사용해서 코드 작성에 더 많은 시간을 투자하고 이미 있는 것을 다시 만드는데 시간을 낭비하지 않는 것이 좋다.
라이브러리란?
- 재사용 가능한 코드를 배포하여 일반적인 작업을 단순화
- 관련 클래스, 함수, 유틸리티를 패키지와 객체로 그룹화
- API(Application Programming Interface)형태로 개발자에게 제공
코틀린 표준 라이브러리
kotlin 패키지에 있는 대부분의 기능은 별도 import 없이 사용 가능
val text = "emosewa si niltoK"
val reversedText = text.reversed()
print(reversedText)
// Kotlin is awesome
단, 아래와 같은 일부 기능은 import가 필요하다.
import kotlin.time.Duration
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
fun main() {
val thirtyMinutes: Duration = 30.minutes
val halfHour: Duration = 0.5.hours
println(thirtyMinutes == halfHour) // true
}
*(와일드카드)로 패키지 전체를 import 할 수 있지만, companion object는 *사용이 불가하고 필요한 멤버를 명시적으로 import 해야한다.
제공되는 기능들
- 컬렉션
- 시퀀스
- 문자열 처리
- 시간 관리
코틀린 라이브러리
표준 라이브러리가 많은 케이스를 커버하지만 모든 것을 다루지 않는다. 코틀린 팀과 커뮤니티가 표준 라이브러리를 보완하는 다양한 라이브러리를 개발했다.
- ex) 여러 플랫폼에서 시간을 관리할 수 있게 해준 kotlinx-datetime
라이브러리를 사용하려면 의존성(dependency) 또는 플러그인 추가 등의 추가 작업이 필요하다.
각 라이브러리의 GitHub 저장소에 코틀린 프로젝트에 추가하는 방법이 설명되어 있다.
import kotlinx.datetime.*
fun main() {
val now = Clock.System.now() // 현재 시간 가져오기
println("Current instant: $now")
val zone = TimeZone.of("America/New_York")
val localDateTime = now.toLocalDateTime(zone)
println("Local date-time in NY: $localDateTime")
}
Opt in to APIs
라이브러리 개발자는 특정 API를 코드에서 사용하기 전에 선택 동의를 요구하도록 표시할 수 있다.
일반적으로 API가 아직 개발 중이고 향후 변경될 가능성이 있을 때 이러한 방식을 사용합니다.
옵트인하지 않으면 경고 또는 오류 메시지가 표시됩니다.
@OptIn뒤에 해당 API의 카테고리 클래스명을 붙이면된다.
@OptIn(ExperimentalUnsignedTypes::class)
fun main() {
val unsignedArray: UIntArray = uintArrayOf(1u, 2u, 3u, 4u, 5u)
unsignedArray[2] = 42u
println("Updated array: ${unsignedArray.joinToString()}")
// Updated array: 1, 2, 42, 4, 5
}
즉 아직 실험적인 API를 사용할때 알고 사용한다는 것을 명시적으로 선언하는 것
'BackEnd > 코틀린 스프링' 카테고리의 다른 글
| [Kotlin] 코틀린 투어 입문 (0) | 2026.03.14 |
|---|---|
| 코틀린 스프링 스터디 일지 6 : 서버 빌드 및 JAVA_HOME 오류 해결 (0) | 2025.05.12 |
| 코틀린 스프링 스터디 일지 5 : 비밀번호 암호화 및 과제 내용 (1) | 2025.05.03 |
| 코틀린 스프링 스터디 일지 4 : 게시글 조회 (0) | 2025.04.25 |
| 코틀린 스프링 스터디 일지 3 : 게시글 작성 (2) (0) | 2025.04.17 |