본문 바로가기
kotlin/Kotlin In Action

3. 클래스, 객체, 인터페이스

by 유저혀 2021. 6. 14.
반응형

Kotlin IN ACTION 책을 공부하면서 정리한 내용입니다.

 

http://www.acornpub.co.kr/book/kotlin-in-action

 

Kotlin in Action

이 책은 코틀린 언어를 개발한 젯브레인의 코틀린 컴파일러 개발자들이 직접 쓴 일종의 공식 서적이라 할 수 있다.

www.acornpub.co.kr

 

 

1.  클래스 계층 정의

 

1.1. 코틀린 인터페이스

코틀린 인터페이스 안에는 추상 메소드 뿐만 아니라 구현이 있는 메소드도 정의할 수 있다. 다만 인터페이스에는 어떠한 필드도 들어갈 수 없다. 

interface Clickable {
  fun click()
}
class Button: Clickable {
  override fun click() = println("I was clicked")
}

Button().click()  // I was clicked

 

인터페이스 메소드도 디폴트 구현을 제공할 수 있다. 이런 경우 메소드 앞에 default를 붙여야 하는 자바 8과는 달리 코틀린에서는 메소드 앞에 특별한 키워드를 붙일 필요가 없다.

 

interface Clickable {
  fun click()
  fun showOff() = println("I'm clickable!")  // 디폴트 구현이 있는 메소드
}
interface Focusable {
  fun setFocus(b: Boolean) = println("I ${if (b) "got" else  "lost"} focus.")
  fun showOff() = println("I'm focusable!")
}

 

 

1.2. open, final, abstract 변경자: 기본적으로 final

자바의 클래스와 메소드는 기본적으로 상속에 대해 열려있지만 코틀린의 클래스와 메소드는 기본적으로 final이다. 어떤 클래스의 상속을 허용하려면 클래스 앞에 open 키워드를 붙여야 한다.

open class RichButton: Clickable {
  fun disable() {}  // 이 함수는 final. 하위 클래스에서 오버라이드 할 수 없다
  open fun animate() {}   // 이 함수는 열려있다. 하위 클래스에서 오버라이드 해도 된다.
  override fun click() {}   //  오버라이드한 메소드는 기본적으로 열려있다.
}

오버라이드 한 메소드를 하위 클래스에서 다시 오버라이드 하지 못하게 하려면 오버라이드 한 메소드 앞에 final을 명시해야 한다.

open class RichButton: Clickable {
  final override fun click(){..}
}

 

자바처럼 코틀린도 클래스를 absctract로 선언할 수 있다. 추상 클래스는 구현이 없는 추상 메소드가 있으므로 하위 클래스에서 추상 멤버를 오버라이드 해야 한다. 추상 멤버는 항상 열려있다. 따라서 추상 멤버 앞에 open 변경자를 명시할 필요는 없다.

 

absctract class Animated {
  abstarct fun animate()
  open fun stopAnimating() {
  
  }
  fun animateTwice(){
  
  }
}

추상 클래스에 속해있더라도 비추상 함수는 기본적으로 final이지만 open을 붙여 오버라이드를 허용할 수 있다.

인터페이스 멤버의 경우 final, open, abstract를 사용하지 않는다. 인터페이스 멤버는 항상 열려있으며 final로 변경할 수 없다. 인터페이스 멤버에 본문이 없으면 자동으로 추상 멤버가 되며 따로 abstract 키워드를 붙일 필요는 없다.

 

 

클래스 내에서 상속 제어 변경자의 의미

변경자 오버라이드 설명
final 오버라이드 할 수 없음 클래스 멤버의 기본 변경자이다.
open 오버라이드 할 수 있음 반드시 open을 명시해야 오버라이드 할 수 있다.
abstact 반드시 오버라이드 해야 함 추상클래스의 멤버에만 이 변경자를 붙일 수 있다. 추상 멤버에는 구현이 있으면 안된다.
override 오버라이드 하는 중 오버라이드 하는 멤버는 기본적으로 열려있다. 하위 클래스의 오버라이드를 금지하려면 final을 명시해야 한다.

 

 

1.3. 가시성 변경자: 기본적으로 공개

가시성 변경자는 클래스 외부 접근을 제어한다. 코틀린도 자바와 같은 public, protected, private 변경자가 있다. 아무 변경자가 없는 경우 모두 public이 된다.

변경자 클래스 멤버 최상위 선언
public(기본 가시성임) 모든 곳에서 볼 수 있다 모든 곳에서 볼 수 있다
internal 같은 모듈 안에서만 볼 수 있다 같은 모듈 안에서만 볼 수 있다
protected 하위 클래스 안에서만 볼 수 있다 (최상위 선언에 적용할 수 없음)
private 같은 클래스 안에서만 볼 수 있다 같은 파일 안에서만 볼 수 있다.

 

internal open class TalkativeButton: Focusable {
  private fun yell() = println("Hey!")
  protected fun whisper() = println("Let's Talk!")
}

fun TalkativeButton.giveSpeech(){  // 오류: public 멤버가 internal 타입인 TalkativeButton을 노출함
  yell()   // 오류: yell에 접근 할 수 없음. yell은 private 멤버임
  whisper()  // 오류: whispser에 접근할 수 없음. whisper는 protected 멤버임
}

 

 

 

1.4. 내부 클래스와 중첩된 클래스: 기본적으로 중첩 클래스

코틀린의 중첩 클래스는 자바와는 달리 바깥쪽 클래스 인스턴스에 대한 접근 권한이 없다. 코틀린 중첩 클래스에 아무 변경자가 붙지 않으면 자바 static 중첩 클래스와 같다. 이는 내부 클래스가 바깥쪽 클래스의 참조에 접근하지 못한다는 의미이다. 만약 바깥쪽 클래스에 대한 참조를 포함하게 만들고 싶다면 inner 변경자를 붙이면 된다.

 

클래스 B 안에 정의된 클래스 A 자바에서는 코틀린에서는
중첩 클래스(바깥쪽 클래스에 대한 참조를 저장하지 않음) static class A class A
내부 클래스(바깥쪽 클래스에 대한 참조를 저장함) class A inner class A

코틀린에서 내부 클래스 Inner 안에서 바깥쪽 클래스 Outer의 참조에 접근하려면 this@Outer라고 써야 한다.

class Outer{
  inner class Inner {
    fun getOuterReference(): Outer = this@Outer
  }
}

 

1.5. 봉인된 클래스: 클래스 계층 정의 시 계층 확장 제한 sealed

코틀린 컴파일러는 when을 사용해 Expr 타입의 값을 검사할 때 꼭 디폴트 분기인 else 분기를 덧붙이게 강제한다. 이런 디폴트 분기가 있으면 실수로 새로운 클래스 처리를 잊어버렸다고하더라도 디폴트 분기가 선택되기 때문에 심각한 버그가 발생될 수 있다.

interface Expr
class Num(val value: Int): Expr
class Sum(val left: Expr, val right: Expr): Expr

fun eval(e: Expr): Int = 
  when(e) {
    is Num -> e.value
    is Sum -> eval(e.right) + eval(e.left)
    else -> throw IllegalArgumentException("Unknown expression")
  }

 

코틀린에서는 이런 문제에 대한 해법을 제공한다. sealed 클래스가 그 답이다.

sealed class Expr{
  class Num(val value: Int): Expr()
  class Sum(val left: Expr, val right: Expr): Expr()
}

fun eval(e: Expr): Int =
  when(e){
    is Expr.Num -> e.value
    is Expr.Sum -> eval(e.left) + eval(e.right)
  }
 

 

 

 


2. 뻔하지 않은 생성자와 프로퍼티를 갖는 클래스 선언

 

2.1. 클래스 초기화: 주 생성자와 초기화 블록

class User(val nickname: String)

보통 클래스의 모든 선언은 중괄호( { } ) 사이에 들어간다. 하지만 이 클래스 선언에는 중괄호는 없고 괄호 사이에 val 선언만 존재한다. 이렇게 클래스 이름 뒤에 오는 괄호로 둘러싸인 코드를 주 생성자(primary constructor)라고 부른다.

 

위 선언은 아래의 선언과도 같다.

class User constuctor(_nickname: String){
  val nickname: String
  
  init{
    nickname = _nickname
  }
}

constructor 키워드는 주 생성자나 부 생성자를 정의할 때 사용한다. 

init 키워드는 초기화 블록을 시작한다.

 

이 예제에서는 nickname 프로퍼티를 추가하는 코드를 nickname 프로퍼티 선언에 포함시킬 수 있어서 초기화 코드를 초기화 블록에 따로 넣을 필요는 없다. 또 주 생성자 앞에 별다른 어노테이션이나 가시성 변경자가 없다면 constructor를 생략해도 된다.

 

class User(val nickname: String,
 	  val isSubscribed: Boolean = true)

 

 클래스의 인스턴스를 만드려면 new 키워드 없이 직접 생성자를 호출하면 된다.

val jeonghyun = User("정현")
print(jeonghyun.isSubscribed)

 

 클래스를 정의할 때 별도로 생성자를 정의하지 않으면 컴파일러가 자동으로 인자가 없는 디폴트 생성자를 만들어준다.

open class Button

Button의 생성자는 아무 인자도 받지 않지만, Button 클래스를 상속한 하위 클래스는 반드시 Button 클래스의 생성자를 호출해야 한다.

class RadioButton: Button()

 

 

2.2. 부 생성자: 상위 클래스를 다른 방식으로 초기화

open class View{
  constuctor(ctx: Context){
  
  }
  
  constuctor(ctx: Context, attr:Attribute){
  
  }
}

이 클래스는 주 생성자를 선언하지 않고(클래스 헤더에 있는 클래스 이름 뒤에 괄호가 없다) 부 생성자만 2가지 선언한다. 부 생성자는 constructor 키워드로 시작한다.

 

class MyButton: View {
  constructor(ctx: Context): super(ctx){
 
  }
  
  constructor(ctx: Context, attr: AttributeSet): super(ctx, attr){
  
  }
}

 

또한 자바와 마찬가지로 생성자에서 this()를 통해 클래스 자신의 다른 생성자를 호출할 수 있다

class MyButton: View{
  constuctor(ctx: Context): this(ctx, MY_STYLE){
  
  }
  constuctor(ctx: Context, attr: AttributeSet): super(ctx, attr){
  
  }
}

 

 

 

2.3. 인터페이스에 선언된 프로퍼티 구현

코틀린에서는 인터페이스에 추상 프로퍼티 선언을 넣을 수 있다. 

interface User(
  val nickname: String
)

 

 

class PrivateUser(override val nickname: String): User  // 주 생성자에 있는 프로퍼티

class SubscribingUser(val email: String): User {
  override val nickname: String 
    get() = email.substringBefore('@')  // custom getter
}

class FacebookUser(val accountId: Int): User{
  override val nickname = getFacebookName(accountId)  // 프로퍼티 초기화 식
}

 

 

2.4. 게터와 세터에서 뒷받침하는 필드에 접근 backing field

코틀린에서 프로퍼티의 값을 바꿀 때는 user.address ="new value"처럼 필드 설정 구문을 사용한다. 이 예제에서는 custom setter를 정의하여 추가 로직을 실행한다. 접근자의 본문에서는 field라는 특별한 식별자를 통해 backing field에 접근할 수 있다. getter에서는 field의 값을 읽을 수만 있고 setter에서는 field의 값을 읽거나 쓸 수 있다.

class User(val name: String) {
  var address: String = "unspecified"
    set(value: String) {
      println("""
        Address was changed for $name:
        "$field" -> "$value".""".trimIndent())  // backing field 값 읽기
      field = value  // backing field 값 변경
    }
}

fun main() {
    val user = User("Alice")
    user.address = "Elsenheimerstrasse 47, 80687 Muenchen" 
    // "unspecified" -> "Elsenheimerstrasse 47, 80687 Muenchen".
}

 

2.5. 접근자의 가시성 변경

getter와 setter의 가시성은 기본적으로 프로퍼티의 가시성과 같다. 하지만 원한다면 get이나 set 앞에 가시성 변경자를 추가하여 접근자의 가시성을 변경할 수 있다.

class LengthCounter {
  var counter: Int = 0
    private set   // 이 클래스 밖에서 이 프로퍼티의 값을 바꿀 수 없다
    
  fun addWork(word: String) {
    counter += word.lenth
  }
}

 

 


3. 컴파일러가 생성한 메소드: 데이터 클래스와 클래스 위임

 

3.1. 모든 클래스가 정의해야 하는 메소드

자바와 마찬가지로 코틀린 클래스도 toString, equals, hashCode 등을 오버라이드 할 수 있다.

class Client(val name: String, val postalCode: Int)

 

문자열 표현: toString()

class Client(val name:String, val postalCode: Int){
  override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

 

객체의 동등성: equals()

서로 다른 두 객체가 내부에 동일한 데이터를 포함하는 경우 그 둘은 동등한 객체로 간주해야 할 수도 있다.

val client1 = Client("유정현", 5357)
val client2 = Client("유정현", 5357)
println(client1==client2)  // false

이는 equals를 오버라이드할 필요가 있다는 뜻이다.

 

class Client(val name: String, val postalCode: Int) {
  override fun equals(other: Any?): Boolean {
    if(other == null || other !is Client)
      return false
    return name == other.name && 
       postalCode == other.postalCode
  }
  
  override fun toString() = "Client(name=$name, postalCode=$postalCode)"
}

 

해시 컨테이너: hashCode()

자바에서는 equals를 오버라이드할 때 반드시 hashCode도 함께 오버라이드 해야 한다.

val processed = hashSetOf(client("유정현",5357))
println(processed.contain(hashSetOf(client("유정현",5357))) // false

HashSet은 원소를 비교할 때 비용을 줄이기 위해 먼저 객체의 해시 코드를 비교한 후 해시 코드가 같은 경우에만 실제 값을 비교한다.

방금 본 예제의 두 Client 인스턴스는 해시 코드가 다르기 때문에 두 번째 인스턴스가 집합 안에 들어있지 않다고 판단한다. 이 문제를 고치려면 Client가 hashCode를 구현해야 한다.

 

class Client(val name: String, val postalCode: Int) {
  ...
  override fun hashCode(): Int = name.hashCode() * 31 + postalCode
}

다행히 코틀린 컴파일러는 이 모든 메소드를 자동으로 생성할 수 있다.

 

 

3.2. 데이터 클래스: 모든 클래스가 정의해야 하는 메소드를 자동 생성

어떤 클래스가 데이터를 저장하는 역할만을 수행한다면 toString, equals, hashCode를 반드시 오버라이드해야 한다. 이제 이런 메소드를 IDE를 통해 생성할 필요도 없다. data라는 키워드를 클래스 앞에 붙이면 필요한 메소드를 컴파일러가 자동으로 만들어준다. 

data class Client(val name: String, val postalCode: Int)

이제 Client 클래스는 자바에서 요구하는 모든 메소드를 포함한다.

코틀린 컴파일러는 data 클래스에게 위의 세가지 메소드 뿐만 아니라 몇 가지 유용한 메소드를 더 생성해준다. 

 

 

데이터 클래스와 불변성: copy() 메소드

코틀린은 객체를 복사하면서 일부 프로퍼티를 바꿀 수 있게 해주는 copy 메소드를 제공한다. 객체를 메모리상에서 직접 바꾸는 대신 복사본을 만드는 편이 낫다.

 

val lee = Client("유정현", 4122)
println(lee.copy(postalCode = 4000) // Client(name=유정현, postalCode=4000)

 

3.3. 클래스 위임: by 키워드 사용

종종 상속을 허용하지 않는 클래스에 새로운 동작을 추가해야 할 때가 있다. 이럴 때 사용하는 일반적인 방법이 데코레이터 패턴이다.  예를 들어 Collection과 같이 비교적 단순한 인터페이스를 구현하면서 아무 동작도 변경하지 않는 데코레이터를 만들 때조차 다음과 같이 복잡한 코드를 작성해야 한다.

class DelegatingCollection<T>: Collection<T> {
  private val innerList = arrayListOf<T>{}
  
  override val size: Int get() = innerList.size
  override fun isEmpty(): Boolean = innerList.isEmpty()
  override fun contains(element: T): Boolean = innerList.contains(element)
  override fun iterator(): Iterator<T> = innerList.iterator()
  override fun containsAll(elements: Collection<T>): Boolean = 
    innerList.containsAll(elements)
}

 

이런 위임을 지원한다는 점이 코틀린의 장점이다. 인터페이스를 구현할 때 by 키워드를 통해 그 인터페이스에 대한 구현을 다른 객체에 위임 중이라는 사실을 명시할 수 있다.

class DelegatingCollection<T>(
  innerList: Collection<T> = ArrayList<T>()
): Collection<T> by innerList {

}

 

by 키워드 만으로 클래스 안에 있던 모든 메소드 정의가 없어졌다. 메소드 중 일부의 동작을 변경하고 싶은 경우 메소드를 오버라이드하면 컴파일러가 생성한 메소드 대신 오버라이드한 메소드가 쓰인다.

class CountingSet<T>(
  val innerSet: MutableCollection<T> = HashSet<T>()
): MutableCollection<T> by innerSet {
  var objectsAdded = 0
  
  override fun add(element: T): Boolean {
    objectsAdded++
    return innerSet.add(element)
  }
  
  override fun addAll(c: Collection<T>): Boolean {
    objectsAdded += c.size
    return innerSet.addAll(c)
  }
}

fun main() {
    val cset = CountingSet<Int>()
    cset.addAll(listOf(1,1,2))
    println("${cset.objectsAdded} objects were added, ${cset.size} remain")
    // 3 objects were added, 2 remain
}

 


4. object 키워드: 클래스 선언과 인스턴스 생성

코틀린에서 obejct 키워드를 다양한 상황에서 사용하지만 모든 경우 클래스를 정의하면서 동시에 인스턴스(객체)를 생성한다는 공통점이 있다.

 

4.1. 객체선언: 싱글턴을 쉽게 만들기

객체 선언은 클래스 선언과 그 클래스에 속한 단일 인스턴스의 선언을 합친 선언이다.

object Payroll {
  val allEmployees = arrayListOf<Person>()
  
  fun calculateSalary() {
    for(person in allEmployees){
      ...
    }
  }
}

객체 선언은 obejct 키워드로 시작한다. 객체 선언은 클래스를 정의하고 그 클래스의 인스턴스를 만들어서 변수에 저장하는 모든 작업을 단 한 문장으로 처리한다. 클래스와 마찬가지로 객체 선언 안에도 프로퍼티, 메소드, 초기화 블록 등이 들어갈 수 있다. 하지만 생성자(주 생성자, 부 생성자 모두)는 객체 선언에 쓸 수 없다. 일반 클래스 인스턴스와 달리 싱글턴 객체는 객체 선언문이 있는 위치에서 생성자 호출 없이 즉시 만들어진다. 따라서 객체 선언에는 생성자 정의가 필요 없다.

 

객체 선언도 클래스나 인터페이스를 상속할 수 있다.

object CaseInsensitiveFileComparator: Comparator<File> {
  override fun compare(file1: File, file2: File): Int {
    return file1.path.compareTo(file2.path, ignoreCase = true)
  }
}

println(CaseInsensitiveFileComparator.compare(File("/User"), File("/user")))

 

클래스 안에서 객체를 선언할 수도 있다. 이런 객체도 인스턴스는 단 하나뿐이다.

data class Person(val name: String) {
  object NameComparator: Comparator<Person> {
    override fun compare(p1: Person, p2: Person) Int = 
      p1.name.compareTo(p2.name)
  }
}

val persons = listOf(Person("Bob"), Person("Alice"))
println(persons.sortedWith(Person.NameComparator))

 

 

4.2. 동반 객체: 팩토리 메소드와 정적 멤버가 들어갈 장소 static

코틀린 클래스 안에는 정적인 멤버가 없다. 코틀린 언어는 자바 static 키워드를 지원하지 않는다. 그 대신 코틀린에서는 패키지 수준의 최상위 함수와 객체 선언을 활용한다. 

클래스 안에 정의된 객체 중 하나에 companion이라는 특별한 키워드를 붙이면 그 클래스의 동반 객체로 만들 수 있다.

class A {
  companion object {
    fun bar(){
      println("Companion object called")
    }
  }
}

A.bar()

동반 객체는 private 생성자를 호출하기 좋은 위치이다. 동반 객체는 자신을 둘러싼 클래스의 모든 private 멤버에 접근할 수 있다. 따라서 동반 객체는 바깥쪽 클래스의 private 생성자도 호출할 수 있다. 즉 동반 객체는 팩토리 패턴을 구현하기에 가장 적합하다.

 

// 부 생성자가 여럿 있는 클래스 정의하기
class User {
  val nickname: String
  
  constructor(email: String){
    nickname = email.substringBefor('@')
  }
  
  constuctor(facebookAccountId: int){
    nickname = getFacebookName(facebookAccountId)
  }
}

 

이런 로직을 표현하는 더 유용한 방법은 클래스의 인스턴스를 생성하는 팩토리 메소드가 있다. 아래 예제는 생성자를 통해 User 인스턴스를 만들 수 없고 팩토리 메소드를 통해야만 한다.

class User private constuctor(val nickname: String){  // 주 생성자를 비공개로 만든다
  companion object {   // companion object를 선언한다
    fun newSubcribingUser(email: String) = 
      User(email.subStringBefore('@'))
      
    fun newFacebookUser(accountId: Int) = 
      User(getFacebookName(accountId))
  }
}

val subscribingUser = User.newSubscribingUser("bob@gmail.com")
val facebookUser = User.newFacebookUser(4)
println(subscribingUser.nickname)  // bob

 

4.3. 동반 객체를 일반 객체처럼 사용

동반 객체는 클래스 안에 정의된 일반 객체다. 따라서 동반 객체에 이름을 붙이거나 동반 객체가 인터페이스를 상속하거나, 동반 객체 내에 확장 함수와 프로퍼티를 정의할 수 있다.

회사의 급여 명부를 제공하는 웹 서비스를 만든다고 하자. 서비스에서 사용하기 위해 객체를 JSON으로 직렬화하거나 역직렬화 해야 한다. 직렬화 로직을 동반 객체 안에 넣을 수 있다.

 

class Person(val name: String) {
  companion object Loader{
    fun fromJSON(jsonText: String): Person = ...
  }
}

동반 객체 이름을 Loader와 같이 지정할 수 있다.

 

 

동반 객체에서 인터페이스 구현

interface JSONFactory<T> {
  fun fromJSON(jsonText: String): T
}

class Person(val name: String) {
  // 동반 객체가 인터페이스를 구현한다.
  companion object: JSONFactory<Person>{
    override fun fromJSON(jsonText: String): Person = ...
  }
}

 

동반 객체 확장

// 비즈니스 로직 모듈
class Person(val firstName: String, val lastName: String) {
  // 비어있는 동반 객체를 선언한다.
  companion object {
  }
}

// 클라이언트/서버 통신 모듈
fun Person.Companion.fromJSON(json: String): Person {  // 확장 함수를 선언한다
  ... 
}

val p = Person.fromJSON(json)

마치 동반 객체 안에서 fromJSON 함수를 정의한 것처럼 fromJSON을 호출할 수 있다. 여기서 동반 객체에 대한 확장 함수를 지정하려면 원래 클래스에 동반 객체를 꼭 선언해야 한다. 빈 객체라도 동반 객체가 꼭 있어야 한다.

 

 

4.4. 객체 식: 무명 내부 클래스를 다른 방식으로 작성

무명 객체(anonymous object)를 정의할 때도 object 키워드를 쓴다. 무명 객체는 자바의 무명 내부 클래스를 대신한다.  객체 선언과 달리 무명 객체는 싱글턴이 아니다. 객체 식이 쓰일 때마다 새로운 인스턴스를 생성한다.

 

window.addMouseListener(
  object: MouseAdapter() { // MouseAdatper를 확장하는 무명 객체를 선언한다.
    override fun mouseClicked(e: MouseEvent){
    
    }
    
    override fun mouseEnetered(e: MouseEvent){
    
    }
  }
)

 

 

객체에 이름을 붙여야 한다면 변수에 무명 객체를 대입하면 된다. 

val listener = object: MouseAdapter(){
  override fun mouseClicked(e: MouseEvent){ }
  override fun mouseEntered(e: MouseEvent){ }
}

 

자바의 무명 클래스와 같이 객체 식 안의 코드는 그 식이 포함된 함수의 변수에 접근할 수 있다. 하지만 자바와 달리 final이 아닌 변수도 객체 식 안에서 사용할 수 있다. 

fun countClick(window: Window){
  var clickCount = 0
  
  window.addMouseListenr(object: MouseAdapter(){
    override fun mouseClicked(e: MouseEvent){
      clickCount++
    }
  })
}

 

728x90

'kotlin > Kotlin In Action' 카테고리의 다른 글

4. 람다로 프로그래밍  (0) 2021.06.17
2. 함수 정의와 호출  (0) 2021.06.11
1. 코틀린 기초  (0) 2021.06.10

댓글