코틀린 완벽 가이드 책과 코드스피츠 유튜브 스터디 영상 내용을 정리하거나 생각을 정리한 문서 (인용 태그를 제외한 모든 텍스트들은 스터디 내용을 필사하거나, 책에서 정리한 내용입니다.)

출쳐 : 코틀린완벽가이드, 코드스피츠 유튜브 영상

1~3장 - 코틀린 기본


식이 본문인 함수란 무엇인가? 블록이 본문인 함수 대신 식이 본문인 함수를 쓰면 어떤 경우가 좋은가?

간결하고, 명료할 수 있음 그러나 코틀린의 식은 문을 포함한 식이 될 수 있어 간단하게 이야기 하기는 어려움

(참고)함수의 타입추론 기능 사용의 장단점

타입추론을 사용하지 않는 경우 : 명시적인 반환타입을 가짐, 사고가 반환타입을 의도하고 정해두고 내가 몸체를 작성하는 흐름이 됨

타입추론을 사용하는 경우 : (ide의 도움을 받으면) 컴파일러가 내 함수 몸체를 어떻게 평가하는지를 중간중간 확인하면서 코딩 할 수 있음, 다만 반환타입은 any로 가며, 복잡성이 높아질수록 컴파일 속도가 느려짐

디폴트 파라미터와 함수 오버로딩 중 어느 쪽을 써야 할지 어떻게 결정할 수 있을까?

오버로딩을 하는 이유가 특정 파라미터의 값을 디폴트로 주고싶어서인 경우 디폴트 파라미터 사용

// overloading
fun readInt() = readLine()!!.toInt()
fun readInt(radix: Int) = readLine()!!.toInt(radix)


// default param
fun readInt(radix: Int = 10) = readLine()!!.toInt(radix)

(참고) 디폴트 파라미터가 오버로딩을 대체하지 못하는 케이스는 다른 형을 받을 때 이다.

(참고) 현대의 언어들은 함수 콜스택 런타임에서 좌에서 우로 해석되고, 인자를 해석하는 별도의 스택을 가지고 있다. 그래서 뒷부분의 인자들은 앞부분의 인자를 이미 알고있어 오버로딩의 역할을 많이 대체 할 수 있다.

(결론) 형이 변경되는게 아닌 인자를 줄이거나 하는 필요가 있을 때는 가급적 디폴트파라미터 사용

이름 붙은 인자를 사용할 경우의 장단점은 무엇인가?

장점 : 호출부에서, 어떤 오버로딩 함수가 호출되는지 힌트를 주는식으로 표현력을 높여줄 수 있을 것 같다. 단점 : 안쓰면 그만이라 잘 모르겠다.

장점 : 디폴트 인자가 많으면 순서 상관없이 인자를 넘길 수 있다, 인자가 너무 많거나, 디폴트 인자가 많으면 명시적으로 보일 수 있다.

Unit과 Nothing 타입을 어디에 사용하는가? 이들을 자바의 void와 비교해 설명하라. Nothing이나 Unit이 타입인 함수를 정의해 사용할 수 있는가?

Unit : unit값을 반환하지만 반환한 값이 쓸데없음, void : 값을 반환지 않고 타입에도 void는 없음, Nothing : 값이 반환 될 수 없음을 명시하는데 쓰이지만 모든 타입의 하위타입인 Nothing값을 반환하여 식으로 쓸 수 는 있음 (예외처리에 쓰이긴 함)

자바의 가시성은 패키지 가시성, 코틀린은 모듈가시성이 디폴트

그런데, 코틀린에 패키지가시성이 없어서 불편해서 파일가시성이 나왔다.

그래서 최상위 함수의 private은 파일 internal로 취급된다.

코틀린의 패키지와 자바의 패키지 차이와 관련해서

c언어 : 모듈에 대한 참조가 물리적인 파일의 위치로 결정 (include)

자바 : 어떤 물리적인 위치에 있어서 컴파일 시작 포인트로부터 계산되는 폴더의 경로만 일치하면 참조가능 (상대경로 완화)

코틀린 : 니 편한대로 쓰고 코드의 패키지 선언에 있는 문자열로 패키지를 인식한다.

while, do while을 언제 써야할까?

// 외부 변수를 미리 선언하고 정리한 후에 사용 = while이 일어나기전에 예상할 수 있다.
var i - 10

// 사실 그래서 이건 for문을 써야함
while (i-- > 0) {
  // ...
  // 만약 바디가 루프계획에 영향을 미치는 동적계획인겨우만 진짜 while
}

// 정말 얼마나 돌 지 모르겠는 경우
do {
  var j = 10
} while (j-- > 0)

4장 - 코틀린 클래스


코틀린 클래스의 기본적인 구조, 자바 클래스와의 차이는?

문법적인 차이 빼고는 별로 없다고 생각한다.

상속할 때 반드시 super를 호출해야하는점이 큰 차이점

open class Parent(val data: String) {
    init {
        println("Parent init: $data")
    }
}

class Child : Parent {
    private val processedData: String

    constructor(rawData: String) : super(rawData.trim()) {
        processedData = rawData.uppercase()
        println("Child init: $processedData")
    }
}

inner class의 의의는 뭘까?

// 1. 이걸 하고싶어서
class Possesion2(val person: Person, discription: String) {
    fun getOwner = person.familyName // Err
}

// 2. 이렇게 한다면
class Person(val firstName: String, private val familyName: String) {
    inner class Possesion(val discription: String) {
        fun getOwner() = this@Person.familyName
    }
}

// 3. 그냥 이렇게 하면 되잖아
class Person2(val firstName: String, private val familyName: String) {
    class Possesion(val person: Person2, val discription: String) {
        fun getOwner() = person.familyName // Ok!!
    }
}


// 내부 합성의 의의가 private property의 참조라면, 그건 그냥 static을 써도 되잖아
// 두번째로 환경을 캡쳐하면서 클로저와 동일하게 gc 이슈가 발생

지연초기화 매커니즘의 요지는? 실제 lateinit의 장점은?

지연 초기화 매커니즘의 요지는 지연 초기화 객체를 주는것, 지연 초기화 객체에 getX를 했을때 초기화 됐으면 내주고 아니면 초기화 로직 즉 초기화 한 값을 캐시로 잡는 상태를 내부에 내포한 객체로 제공해줘야함

즉 lateinit은 by lazy의 편의 문법

class Example {
    lateinit var lateinitProperty: String

    val lazyProperty: String by lazy {
        println("Lazy property initialized")
        "Lazy Value"
    }

    fun initLateinitProperty() {
        lateinitProperty = "Lateinit Value"
    }

    fun accessProperties() {
        if (::lateinitProperty.isInitialized) {
            println(lateinitProperty)
        } else {
            println("Lateinit property not initialized")
        }

        println(lazyProperty)
        println(lazyProperty)  // 두 번째 접근
    }
}

fun main() {
    val example = Example()

    println("====== first accessProperties() ======")
    example.accessProperties() // 그냥 첫 접근시 초기화 되어 나옴 내부적으로 참조가 일어날때까지 초기화를 안할 뿐
    println("====== initLateinitProperty() ======")
    example.initLateinitProperty() // lateinit 명시적 초기화 필요
    println("====== second accessProperties() ======")
    example.accessProperties()
}

// 결론적으로 lateInit인 경우 제어권을 좀 더 줬을 뿐 

추가적으로 delegate 관련 정리

class Person {

    // 1. lateinit - 나중에 초기화
    lateinit var name: String

    // 2. lazy - 최초 접근 시 초기화
    val lazyName: String by lazy {
        println("lazyName 초기화 중...")
        "John"
    }

    // 3. observable - 값 변경 감지
    var age: Int by Delegates.observable(20) { _, oldValue, newValue ->
        println("age 변경: $oldValue -> $newValue")
    }

    // 4. vetoable - 값 변경 검증
    var grade: Int by Delegates.vetoable(1) { _, _, newValue ->
        newValue in 1..6  // 1~6학년만 허용
    }

    // 5. Custom Lazy Delegate
    val customLazyName: String by CustomLazy {
        println("customLazyName 초기화 중...")
        "Jane"
    }

    // 6. Custom Delegate with Provider
    val delegateName by DelegateProvider("Lee")
}

// Custom Lazy Delegate
class CustomLazy<T>(private val initializer: () -> T) : ReadOnlyProperty<Any, T> {
    private var _value: T? = null
    override fun getValue(thisRef: Any, property: KProperty<*>): T {
        if (_value == null) {
            _value = initializer()
        }
        return _value!!
    }
}

// Custom Delegate Provider
class DelegateProvider(private val initValue: String) : PropertyDelegateProvider<Any, ReadOnlyProperty<Any, String>> {
    override fun provideDelegate(thisRef: Any, property: KProperty<*>): ReadOnlyProperty<Any, String> {
        if (property.name != "delegateName") {
            throw IllegalArgumentException("delegateName만 연결 가능")
        }
        return ReadOnlyProperty { _, _ -> initValue }
    }
}

// 사용 예
fun main() {
    val person = Person()

    // 1. lateinit
    person.name = "Kim"
    println("name: ${person.name}")

    // 2. lazy
    println("lazyName: ${person.lazyName}")

    // 3. observable
    person.age = 25  // 값 변경 로그 출력

    // 4. vetoable
    person.grade = 3  // 허용
    println("grade: ${person.grade}")
    person.grade = 7  // 변경되지 않음
    println("grade: ${person.grade}")

    // 5. Custom Lazy Delegate
    println("customLazyName: ${person.customLazyName}")

    // 6. Custom Delegate with Provider
    println("delegateName: ${person.delegateName}")
}

val을 사용하지 않고 읽기 전용 프로퍼티를 만들수있는법

var age:Int = 3
  private set

요지는 getter의 가시성이 setter의 가시성보다 커야한다.

lateinit vs by lazy

  1. lateinit -> var, by lazy -> val
  2. 초기화 이후로 값이 계속 바뀌는 경우에 한정해서 lateinit을 사용
  3. lazy는 값을 만드는 방법을 내포하고있지만, lateinit은 외부에서 주입되어야함
  4. framework의 di를 이용 할 때 프레임워크가 꼭 넣어준다는 확신이 있을때만 쓰는게 낫다
  5. 기본형에는 lateinit 못씀

5장 고급 함수와 함수형 프로그래밍 활용하기


함수관련

슈가신택스가 가장 많은데, 실제로 많이들 써서..

// 기본 시그니처
fun max(a:Int, b:Int): Int {
    if (a > b) {
        return a
    } else {
        return b
    }
}

// 표현식 return 가능
fun max2(a:Int, b:Int): Int {
    return if (a > b) {
        a
    } else {
        b
    }
}

// = 으로 선언 가능, 식하나인경우 중괄호 생략 가능
fun max3(a:Int, b:Int): Int =
    if (a > b) {
        a
    } else {
        b
    }

// 반환값 추론 가능
fun max4(a:Int, b:Int) = if (a > b)  a  else  b

fun repeat(str: String, num: Int = 3, useNewLine: Boolean = true) {
    for (i in 1..num) {
        if (useNewLine) {
            println(str)
        } else {
            print(str)
        }
    }
}

// 확장함수(확장 프로퍼티도 동일)
// 1. 만약 수신객체에 시그니처가 같은 멤버함수가 있으면 멤버함수 호출
// 2. 해당 변수의 현재 타입, 즉 정적인 타입에 의해 어떤 확잠함수가 호출될지 결정됨 (상속관계 등 에서)
// 3. private, protected 접근 안됨
fun String.lastChar(): Char {
    return this[this.length -1]
}

// infix 함수
// 변수.함수이름(인자) 대신
// 변수 함수이름 인자 이렇게 호출 가능 (인자가 하나인 경우에)
infix fun Int.plus(other: Int): Int {
    return this + other
}

fun testInfix(): Int {
   return 3 plus 4
}

// 그 외에 지역함수, 인라인함수도 가능


// 함수형 기능

// 변수 할당가능, 익명함수
val isPositive = fun(num: Int): Boolean {
    return num > 0
}

// 람다
val isPositive2 = { num: Int -> num > 0 }

// 타입추론 가능하고 파라미터가 하나면 it 사용 가능
val isPositive3: (Int) -> Boolean = { it > 0 }

// 클로저 사용 가능
fun closureTest() {
    val num = 3
    val numList = mutableListOf(1, 2, 3, 4, 5, 6, 7)
    numList.asSequence().filter { it > num }
}
  1. 람다식 반환타입을 적을 수 없고, 익명함수는 적을 수 있다.
  2. 람다식은 return 불가, 익명함수는 가능

스코프 함수 : 어떤 식을 계산한 값을 문맥 내부에서 임시로 사용할 수 있도록 해주는 함수 인자로 제공한 람다를 간단하게 실행시켜주는 역할을 하지만 관점에 따라 용도가 나뉜다. 문맥 식을 계산한 값을 영역함수로 전달할 때 수신 객체로 전달하는가?, 일반적인 함수 인자로 전달하는가? 영역 함수의 람다 파라미터가 수신 객체 지정 람다인가 아닌가? 영역 함수의 반환값이 람다의 결과값인가, 컨텍스트 전체를 계산한 값인가?

data class Person(var name: String = "", var age: Int = 0)

fun main() {
    // let: public inline fun <T, R> T.let(block: (T) -> R): R
    // null 체크 이후 블록 실행
    // 전달된 람다 블록의 반환값을 반환
    val nullableString: String? = "Hello"
    nullableString?.let {
        // let: String length is 5
        println("let: String length is ${it.length}")
    }

    // with: public inline fun <T, R> with(receiver: T, block: T.() -> R): R
    // 수신 객체의 컨텍스트 확용 가능한 블럭 생성
    val person1 = Person("John", 30)
    with(person1) {
        // with: Name is John, Age is 30
        println("with: Name is $name, Age is $age")
    }

    val result = with(StringBuilder()) {
        append("Hello")
        append(", ")
        append("World!")
        toString()
    }
    println(result)  // "Hello, World!"

    // run: public inline fun <T, R> T.run(block: T.() -> R): R
    // with와 비슷, 그러나 확장함수로 호출됨
    val person2 = Person("Alice", 25)
    val runResult = person2.run {
        println("run: Name: $name, Age: $age")맥 식을 
        "Person info: $name, $age"
    }
    println("run result: $runResult")

    // apply: public inline fun <T> T.apply(block: T.() -> Unit): T
    // let과 비슷 this로 참조 가능
    val person3 = Person().apply {
        name = "Bob"
        age = 35
    }

    // apply: Created person - Name: Bob, Age: 35
    println("apply: Created person - Name: ${person3.name}, Age: ${person3.age}")


    // also: public inline fun <T> T.also(block: (T) -> Unit): T
    val numbers = mutableListOf(1, 2, 3)
    numbers.also {
        println("also: The list elements before adding: $it")
    }.add(4)
    println("also: The list elements after adding: $numbers")
}
data class Person(var name: String = "", var age: Int = 0)

fun main() {
    // 1. let 없이 null 체크 후 사용
    val nullableString: String? = "Hello"
    if (nullableString != null) {
        val length = nullableString.length
        println("String length is $length")
    }

    // 2. with 없이 객체 프로퍼티 사용 (반복되는 참조)
    val person1 = Person("John", 30)
    val name1 = person1.name
    val age1 = person1.age
    println("Name is $name1, Age is $age1")

    val sb = StringBuilder()
    sb.append("Hello")
    sb.append(", ")
    sb.append("World!")
    val result = sb.toString()
    println(result)  // "Hello, World!"

    // 3. run 없이 객체 프로퍼티 사용 및 반환값 활용
    val person2 = Person("Alice", 25)
    println("Name: ${person2.name}, Age: ${person2.age}")
    val runResult = "Person info: ${person2.name}, ${person2.age}"
    println("run result: $runResult")

    // 4. apply 없이 객체 생성 및 설정 (변수를 여러 번 참조)
    val person3 = Person()
    person3.name = "Bob"
    person3.age = 35
    println("Created person - Name: ${person3.name}, Age: ${person3.age}")

    // 5. also 없이 리스트 요소 출력 후 추가 (중간 상태를 저장해야 함)
    val numbers = mutableListOf(1, 2, 3)
    val beforeAdding = numbers.toList()  // 원본 보존
    println("The list elements before adding: $beforeAdding")
    numbers.add(4)
    println("The list elements after adding: $numbers")
}

람다, 함수타입 관련 자바와 코틀린의 차이

왜 코틀린은 auto SAM 인터페이스가 안될까?

  • 코틀린은 람다가 type임
  • 반대로 자바는 편의문법임 (인터페이스 혹은 클래스에 속함 - java lamda factory)
  • 입장이 정 반대임, 이미 코틀린은 람다 type을 가지고 있기 때문에, 굳이 특정 인터페이스를 지정해줘야 캐스팅이됨

수식 객체가 있는 함수타입과 수신 객체가 없는 함수타입을 비교해 설명하라.

변수 범위 내에서의 쉐도잉 문제가 코틀린에서도 엄청 자주 발생함.

쉐도우의 유일한 장점은 안의 스코프에서 밖의 스코프를 참조 못하게 할 수있다는 것.

심하게는 쉐도잉과 this 사용 자체를 금지하는 경우도 있다고 한다.

확장함수를 한계는 명확하게 존재하면서(public만 참조가능), 실익은 크지 않고 나중에 혼란을 가중시킨다고 보는 것 같다. (this를 필두로한 섀도잉 등)

  • 추가로 공부하다가 본 내용
    • 업캐스팅시에 타입에 맞는 확장함수를 쓸 수 있게 해주긴한다.
    • 확장함수 실제 컴파일시 첫 번째 인수로 확장함수의 리시버를 받기 때문
    • 대표적으로 널러블한 타입이나, 제네릭에도 확장함수를 구현 할 수있다.
    • 어노테이션 처리기의 관심 대상은 아니다.
    • 인터페이스를 깔끔하게 유지 할 수 있다. (이터러블 인터페이스는 iterator하나만 두고 나머지는 표준 라이브러리에 확장 함수 형태로 정의해뒀음)

람다를 쓰는 것보다 익명 함수를 쓰는 것이 더 좋은때는 언제인가?

익명함수 -> return으로 제어흐름 바꿀 수 있으니까 필요 할 때? 말고 잘 모르겠음

아래 참고 단락은 인라인 함수의 경우 익명함수와 람다의 차이를 이야기하다가 나온 이야기이다.

정확히는 람다가 조금 더 유연한 점을 표현한 이야기.

이러한 이야기가 나온 것 자체가 뭔가 이야기 하다 보니 아래와 같은 엣지케이스를 제외하고는,

람다보다는 익명함수를 쓰는게 좋을 때 가 많다는 식으로 조금 결론이 지어지는 것 같다.

참고 Inline함수와 람다

inline fun testInlint(block: () -> Unit) {
	block()
}

fun wrapper(): Int {
	val a: () -> Unit = {
		return 3 // 불가!
	}
	testInline(a)

	testInline {
		return 3; // 가능..
	}
	return 3
}

컴파일러는 직접 생성된 람다에 하에서만 리턴을 허용한다..?

이부분이 이해가 안가고 헷갈려서 찾아봤다. 컴파일은 아래와 같은 로직으로 이루어 진다고 한다.

fun wrapper(): Int {
    val a: () -> Unit = {
        return 3 // 불가 (독립적인 함수 객체)
    }
    testInline(a) // 일반적인 함수 호출

    // `testInline { return 3 }`이 아래처럼 인라인됨!
    run {
        return 3 // 가능 (wrapper()의 return으로 변환됨)
    }

    return 3
}

호출 가능 참조란 무엇인가? 호출 가능 참조의 여러 가지 형태를 설명하라. 각각의 형태를 자바의 메서드 참조와 비교하라.

어떠한 값이나 메서드가 리플렉션으로 봤을 때, KFunction이라는 뜻. 즉 KFunction을 얻기 위해 ::를 쓰는것.

참고로 코틀린 리플렉션의 장점은 런타임 리플렉션이 아니라 컴파일 타임 리플렉션을 하는 것 이라고 함.

성능상 장점 (런타임에 클래서 정의영억에서 참조하는 메모리 공간을 찾아서 정의를 찾아서 리플렉션함)

class Test {
    val f: ()-> Unit = {}
    fun action() {}
}

fun main() {
    // KProperty.value:(()->Unit).invoke() 즉 KProperty의 참조값이 콜러블이라 invoke
    Test()::f.invoke()
    // KFunction.invoke()
    Test()::action.invoke()
}

인라인 함수 관련

인라인 연쇄의 예시

// foreach의 시그니처, 불필요한 인스턴스 생성 등을 막기 위해 기본적으로 inline으로 최적화를 해뒀다.
public inline fun <T> Iterable<T>.forEach(action: (T) -> Unit): Unit {}


fun inlineTest(block: () -> Unit): Map<String, () -> Unit> {
    val f = block
    return hashMapOf("a" to f)
}


fun main() {
    val list = listOf(1, 2, 3, 4)
    val map = inlineTest {
        list.forEach { // 그러면 여기는 inline 화 가 될까? -> 인라인 연쇄 문제
            print(it)
        }
    }

}

컴파일 디컴파일하면 아래와 같이 나온다.

public final class MainKT {
   @NotNull
   public static final Map a(@NotNull Function0 block) {
      Intrinsics.checkNotNullParameter(block, "block");
      return (Map) MapsKt.hashMapOf(new Pair(TuplesKt.to("a", block)));
   }

   public static final void wrapper() {
      final List list = CollectionsKt.listOf(new Integer[]{1, 2, 3, 4});
      Map map = a((Function0) (new Function0() { // 감싼 람다는 함수객체를 만들어야함
         public Object invoke() {
            this.invoke();
            return Unit.INSTANCE;
         }

         public final void invoke() {
            Iterable $this$forEach$iv = (Iterable) list; // 이부분은 (forEach) 인라인화 성공!
            int $i$f$forEach = false;
            Iterator var3 = $this$forEach$iv.iterator();
            while (var3.hasNext()) {
               Object element$iv = var3.next();
               int it = ((Number) element$iv).intValue();
               int var6 = false;
               System.out.println(it);
            }
         }
      }));
   }
}

이런 상황도 있고 약간의 억지스러운 상황을 더 가정하면, inline 키워드가 있다고 무조건적으로 inline이 되지는 않는다.

성능에 예민한 로직 혹은 프로젝트라면, inline을 더 조심해서 사용해야함.

6장 특별한 클래스 사용하기


Enum

모든 열거형 클래스는 다음 컴패니언 객체 함수를 가진다.

  • entries: 열거형 클래스의 모든 값을 리스트로 가지는 프로퍼티
  • valueOf: 입력받은 문자열과 이름이 일치하는 열거형 원소를 반환

도은 enum의 공통 프로퍼티, 메서드들

  • ordinal : 인덱스
  • name : 이름

열거형 값에는 상태가 있을 수 있음

“절대 열거형 값의 상태는 변하지 않도록 만들 것”

Sealed Class

서브클래싱은 자유롭게 허용하지만, 정해진 타입 집합만 상속 가능하게 제한

  • 같은 파일 내에서만 서브클래스를 정의할 수 있음
  • 컴파일러가 타입이 열거형처럼 “완전”하다고 가정할 수 있게 도와줌
  • 주로 when 표현식과 함께 사용되어, 모든 하위 타입을 다룰 수 있음을 보장

sealed class는 런타임에 타입을 제한하는 것이 아니라, 컴파일 시점에 타입을 제한합니다.

sealed class Response
  
data class Success(val data: String) : Response()
data class Error(val message: String) : Response()
object Loading : Response()

fun handle(response: Response) {
    when (response) {
        is Success -> println("Success: ${response.data}")
        is Error -> println("Error: ${response.message}")
        Loading -> println("Loading...")
    }

}
구분 enum sealed class
목적 고정된 “값” 집합 고정된 “타입” 집합
상속 불가 (모든 값 타입 동일) 가능 (각 값이 다른 타입일 수 있음)
추가 데이터 보유 필드로 제한 다양한 프로퍼티 및 메서드 가질 수 있음
패턴 매칭 when에서 이름 비교 when에서 타입 매칭

Data Class

동등성 비교나 간단한 보일러 플레이트를 컴파일러가 작성해줌

data class Human(
    val firstName: String,
    val familyName: String,
    val age: Int
)


data class MailBox(
    val address: String,
    val person: Human
)

fun main() {
    val box1 = MailBox("Unknown", Human("John", "Doe", 25))
    val box2 = MailBox("Unknown", Human("John", "Doe", 25))

    println(box1 == box2) // true, 그러나 Human이 data클래스가 아니면 falsee
}

주생성자의 파라미터에서 선언한 프로퍼티만 equals()/hashCode()/toString() 구현의 대상임.

copy로 복사기능도 구현됨 (shallow copy)

fun main() {
    val jane = Human("Jane", "Doe", 32)
    jane.copy().show()
    jane.copy(familyName = "Smith").show()
}

데이터 클래스는 필수 프로퍼티들이 모두 주 생성자에 담겨 있따고 가정합니다. 본문 코드에는 필수 프로퍼티들에 기반한 불변 프로퍼티만 담겨 있어야 합니다.