[Kotlin] Effective Kotlin [3장 재사용성]

WonseokOh·2022년 12월 12일
0

Kotlin

목록 보기
4/4
post-thumbnail

이펙티브 코틀린(Effective Kotlin Best Practices) 책을 통해 스터디 진행하면서 정리한 글입니다.


1. knowledge를 반복하여 사용하지 말라.

프로그래밍에서 knowledge는 프로젝트 진행 시에 정의한 모든 것을 나타낼 수 있습니다. 알고리즘 작동 방식부터 UI 형태, 결과 등 모두 knowledge로 지칭할 수 있으며 그 중 가장 중요한 knowledge는 아래로 볼 수 있습니다.

  • 로직(business logic) : 프로그래밍이 어떠한 방식으로 동작하는지를 나타내는 흐름
  • 공통 알고리즘(common algorithm) : 원하는 동작을 하기 위한 일련의 절차

로직과 공통 알고리즘을 구별하자면 시간에 따른 변화로 볼 수 있습니다. 비즈니스 로직은 시간이 지나면서 계속 변화하지만, 공통 알고리즘은 한 번 정의된 이후에 크게 변하지 않습니다. 여기서 만일 로직(knowledge) 스니펫을 다양한 곳에서 반복해서 사용한다면 어떤 문제가 발생할까요?

  • 변경이 필요 시 모든 변경점을 찾아 수정하는데 시간을 많이 소요됩니다.
  • 변경 이후에 정상적인 동작을 하는지 테스트를 하는 과정에서 불필요하게 많은 테스트를 진행해야 합니다.
  • 모든 곳을 동일하게 수정하지 않아 예기치 못한 예외가 발생할 수도 있습니다.

이처럼 반복은 프로젝트의 확장성을 막고, 예외를 만들 수 있기에 공통된 부분은 추상화를 사용하도록 해야 합니다.


반복된 코드 사용

무작정 추상화를 적용하는 것은 좋은 방향은 아닙니다. 얼핏보면 공통 부분을 추상화할 수 있겠지만, 더 세밀하게 확인했을 경우 공통으로 추출할 수 없는 경우도 있습니다. 추상화로 나타낼지 여부를 쉽게 알기는 어렵지만 책에서 소개해준 휴리스틱으로는 비즈니스 규칙이 다른곳에서 왔는지 확인하라고 권장하고 있습니다. 비즈니스 로직이 다른 곳에서부터 왔다면 독립적으로 변경될 가능성이 높습니다.


단일 책임 원칙(SRP)

단일 책임 원칙(Single Responsibility Principle, SRP)은 로버트 C.마틴의 클린 아키텍처 원칙인 SOLID 중 하나로 소개되고 있습니다. 단일 책임 원칙이란 클래스를 변경하는 이유는 단 한 가지여야 한다라는 의미입니다.

class Student{
	
    fun isPassing(): Boolean =
    	calculatePointsFromPassedCoures() > 15
        
    fun qualifiesForScholarship(): Boolean = 
    	calculatePointsFromPassedCourses() > 30
        
    private fun calculatePointsFromPassedCourses(): Int {
    	// ..
    }
}

Student 클래스에서는 2가지 메소드를 정의하였고 두 메소드 모두 calculatePointsFromPassedCourses 라는 메소드의 결과인 이전 학기 성적 기반으로 계산됩니다.

  • isPassing : 학생이 인증을 통과했는지 여부
  • qualifiesForScholarship : 학생이 장학금을 받을 수 있는지 여부

위 코드처럼 작성 시에는 문제가 발생하는데 장학금 받을 수 있는 로직을 더 어렵게 하기 위해 calculatePointsFromPassedCourses 메소드를 변경하였을 때 isPassing 메소드까지 영향을 받게 되는 것입니다.

	// accreditations module
    fun Student.qualifiesForScholarship(): Boolean{
    
    }
    
    // scholarship module
    fun Student.calculatePointsFromPassedCourses(): Boolean{
    
    }

만일 위 스니펫과 같이 isPassing, qualifiesForScholarship의 책임을 각각 확장함수를 정의하였다면 문제가 없었을 것입니다. 정리하자면 이번 아이템에서 재사용성을 높이기 위한 방법으로 공통된 부분은 추상화를 사용하고 그렇지 않은 부분에 있어서는 단일 책임 원칙(SRP)을 지켜가며 균형있게 사용하는 것을 권장하고 있습니다.


2. 일반적인 알고리즘을 반복해서 구현하지 말라.

  동일한 알고리즘을 여러 번 반복해서 구현하지 말고 표준 라이브러리 사용하거나 특정 알고리즘을 반복해서 사용해야 하는 경우에 직접 정의하여 사용하는 것을 권장하고 있습니다.


표준 라이브러리 사용

표준 라이브러리를 사용할 경우에 단순히 코드가 짧아지는 것 이외에도 다양한 장점이 있습니다.

  • 코드 작성 속도 증가
  • 함수의 이름으로 무엇을 하는지 확실하게 알 수 있음
  • 직접 구현 시 실수 가능성 존재
	override fun saveCallResult(item: SourceResponse){
    	var sourceList = ArrayList<SourceEntity>()
        item.sources.forEach{
        	var sourceEntity = SourceEntity()
            sourceEntity.id = it.id
            sourceEntity.category = it.category
            sourceEntity.country = it.country
            sourceEntity.description = it.description
            sourceList.add(sourceEntity)
        }
        db.insertSources(sourceList)
    }

위 스니펫은 다른 자료형으로 매핑하는 처리 후에 DB에 추가해주고 있습니다.

	override fun saveCallResult(item: SourceResponse){
    	val sourceEntries = item.sources.map(::sourceToEntry)
        db.insertSOurces(sourceEntries)
    }
    
    private fun sourceToEntry(source: Source) = SourceEntity()
    	.apply{
     		id = source.id
            category = source.category
            country = source.country
            description = source.description
    }

이를 표준라이브러리에 있는 map 함수를 사용하고 팩토리 메소드를 활용하거나 기본 생성자를 활용하면 더 편리하게 사용할 수 있습니다.


나만의 유틸리티 작성

상황에 따라 표준 라이브러리에 없는 알고리즘을 직접 구현해야할 필요가 있습니다.

	fun Iterable<Int>.product() = 
    		fold(1) { acc, i -> acc*i }

여러번 사용하지 않더라도 함수로 만드는 것을 권장하고 있으며 함수 네이밍으로도 어떤 동작을 할 것인지 대략적으로 유추가 가능합니다. 또한 위 코드처럼 확장함수를 사용하여 유틸리티를 작성하는데 top-level 함수로 정의하는 것과 다른 장점이 존재합니다.

  • 함수는 상태를 유지하지 않으므로 행위를 나타내기 좋음
  • 구체적인 타빙이 있는 객체에만 사용을 제한
  • 수정할 객체를 매개변수로 받는 것보다는 확장 리시버로 사용하는 것이 가독성 좋음
  • 함수의 시그니처를 찾기 편함

3. 일반적인 프로퍼티 패턴은 프로퍼티 위임으로 만들어라.

  Kotlin 개발하면서 lateinit, lazy 키워드를 이용한 지연초기화를 사용해본 경험이 있을 것입니다. 지연초기화도 프로퍼티 위임 방법 중 하나로 프로퍼티 초기화 시에 추가적인 행위까지 추출할 수 있어서 권장되는 방법입니다.

	val value by lazy { createValue() }
    
    var items: List<Item> by 
    	Delegates.observable(listOf()) { _, _, _ ->
        	notifyDataSetChanged()
        }
    
    var key: String? by
    	Delegates.observable(null) { _, old, new ->
        	Log.e("key changed from $old to $new")
        }	

위 스니펫에서는 프로퍼티 위임을 사용하는 기본적인 방법을 몇 가지 소개하고 있습니다.

  • lazy : 지연 초기화
  • Delegates.observable : 변화가 있을 때 감지하는 observable 패턴

일반적으로 프로퍼티 위임 메커니즘을 활용하면 다양한 패턴들을 만들 수 있습니다.

// 안드로이드에서의 뷰와 리소스 바인딩
private val button: Button by bindView(R.id.button)
private val textSize by bindDimension(R.dimen.font_size)
private val doctor: Doctor by argExtra(DOCTOR_ARG)

// Koin에서의 종속성 주입
private val presenter: MainPresenter by inject()
private val repository: NetworkRepository by inject()
private val vm: MainViewModel by viewModel()

// 데이터 바인딩
private val port by bindConfiguration("port")
private val token: String by preferences.bind(TOKEN_KEY)

위 예시로 뷰, 리소스 바인딩, 의존성 주입, 데이터 바인딩 등이 있습니다. 코틀린에서는 이처럼 프로퍼티 위임을 사용해서 간단하고 type-safe하게 구현할 수 있습니다.

간단한 프로퍼티 위임을 만들어서 동작 원리 및 활용 방법에 대해 알아보겠습니다. 프로퍼티 사용될 때 간단한 로그를 출력하고 싶다고 하면 getter / setter 를 통해 구현할 수 있습니다.

	var token: String? = null
    	get(){
        	print("token returned value $field")
            return field
        }
        set(value){
        	print("token changed from $field to $value")
            field = value
        }
        
    var attempts: Int = 0
    	get(){
        	print("attempts returned value $field")
            return field
        }
        set(value){
        	print("attempts changed from $field to $value")
            field = value
        }

프로퍼티 위임은 다른 객체의 메소드인 getValuesetValue를 활용해서 프로퍼티의 접근자를 만드는 방식으로 위 스니펫의 두 프로퍼티는 타입이 다르지만 내부적으로 같은 처리를 하기에 템플릿 형태로 추출할 수 있습니다.

	var token: String? by LoggingProperty(null)
    var attempts: Int by LoggingProperty(0)
    
    private class LoggingProperty<T>(var value: T){
    	opreator fun getValue(
        	thisRef: Any?,
            prop: KProperty<*>
        ): T {
        	print("${prop.name} returned value $value")
            return value
        }
        
        operator fun setValue(
        	thisRef: Any?,
            prop: KProperty<*>,
            newValue: T
        ){
        	val name = prop.name
            print("$name changed from $value to $newValue")
            value = newValue
        }
    }

다음 코드는 이전 코드를 프로퍼티 위임을 활용해 변경한 예입니다. 객체를 만든 뒤에 by 키워드를 사용해서 getValue와 setValue를 정의한 클래스와 연결해 주면 됩니다.

	@JvmField
    private val 'token$delegate' = 
    	LoggingProperty<String?>(null)
    
    var token: String?
    	get() = 'token$delegate'.getValue(this, ::token)
        set(Value){
        	`token$delegate'.setValue(this, ::token, value)
        }

프로퍼티 위임이 어떻게 동작하는지 이해하려면 by 키워드가 어떻게 컴파일 되는지를 알면 좋습니다. 컴파일된 결과를 보면 token 프로퍼티는 델리게이트의 getValue와 setValue로 구현됩니다.

코틀린에서는 stdlib에서 다음과 같은 프로퍼티 델리게이터들이 정의되어 있으며, 범용적으로 사용되는 패턴들에 대해서는 알아두면 좋습니다. 또한, 위 예시처럼 델리게이터를 직접 만들어서 사용할 수도 있습니다.

  • lazy
  • Delegates.observable
  • Delegates.vetoable
  • Delegates.notNull

4. 일반적인 알고리즘을 구현할 때 제네릭을 사용하라.

  일반적인 알고리즘을 직접 구현 시에 타입 파라미터를 사용한 Generic function을 구현하는 것이 좋습니다.

	inline fun <T> Iterable<T>.filter(
    	predicate: (T) -> Boolean
    ): List<T>{
    	val destination = ArrayList<T>()
        for(element in this){
        	if(predicate(element)){
            	destination.add(element)
            }
        }
    }

대표적인 Generic function으로 stdlib 라이브러리에 있는 filter 함수가 있습니다. 타입 파라미터는 컴파일러에 타입과 관련된 정보를 제공하여 컴파일러가 타입을 조금이라도 더 정확하게 추측할 수 있게 합니다.


제네릭 제한

타입 파라미터의 중요한 기능 중 하나는 구체적인 타입의 서브타입 또는 슈퍼타입을 사용하도록 타입을 제한할 수 있습니다.

	fun <T: Comparable<T>> Iterable<T>.sorted(): List<T>{
    
    }
    
    fun <T, C: MutableCollection<in T>>
    Iterable<T>.toCollection(destination: C): C{
    
    }
    
    class ListAdapter<T: ItemAdapter>(){}
    
    inline fun <T, R: Any> Iterable<T>.mapNotNull(
    	transform: (T) -> R?
    ): List<R>{
    	return mapNotNullTo(ArrayList<R>(), transform)
    }

타입에 제한으로 인해 해당 타입이 제공하는 메소드를 사용할 수 있습니다. 위 예시에서 T를 Iterable< Int >로 제한을 할 경우 반복구문을 사용할 수 있으며 Comparable< T >로 제한 시에는 타입끼리 비교도 가능합니다. 많이 사용하는 패턴으로 Any로 제한을 하여 타입을 notnull하도록 만들 수 있습니다.


5. 타입 파라미터의 섀도잉을 피하라

	class Forest(val name: String){
    	fun addTree(name: String){
        	// ...
        }
    }

위 스니펫처럼 클래스의 프로퍼티와 메소드의 매개변수의 이름이 name으로 같은 것을 확인할 수 있습니다. 이럴 경우에는 메소드 내에서 지역변수가 아닌 클래스의 프로퍼티로 인식을 하게 되는데 이를 섀도잉(shadowing)이라고 부릅니다.

	interface Tree
    class Birch: Tree
    class Spruce: Tree
    
    class Forest<T: Tree>{
    	fun <T: Tree> addTree(tree: T){
        	// ..
        }
    }
    
    // main 사용
    val forest = Forest<Birch>()
    forest.addTree(Birch())
    forest.addTree(Spruce())

이런 섀도잉 현상은 제너릭 클래스의 타입 파라미터와 제너릭 함수의 타입 파라미터 사이에서도 발생합니다. 섀도잉으로 인해 다양한 문제들이 발생하고 심각한 문제로 야기될 수 있습니다.
아래 사용 코드를 봐서는 Forest의 클래스의 타입과 addTree의 타입이 다를 것이라고 유추하기는 어렵습니다. 또한 다른 클래스들을 더하는 것을 의도하지도 않았을 것입니다.

    class Forest<T: Tree>{
    	fun addTree(tree: T){
        	// ..
        }
    }
    
    // main 사용
    val forest = Forest<Birch>()
    forest.addTree(Birch())
    forest.addTree(Spruce())	// ERROR, type mismatch

따라서 addTree도 동일한 타입을 사용할 수 있도록 맞추어야 합니다. 만약 독립적인 매개변수 타입이 필요할 경우에는 필히 다른 타입 파라미터를 사용하는 것을 권장하고 있습니다.


6. 제네릭 타입과 variance 한정자를 활용하라.

Java 공부를 했던 분이라면 Generic 사용 시 공변성, 반변성, 반공변성에 대해 들어본적이 있을 것입니다. Kotlin에서도 마찬가지로 Generic을 활용 시에 variance 한정자(out, in)을 권장하고 있습니다.

invariant(불공변성)

// invariant
class Cup<T>

fun main(){
	val anys: Cup<Any> = Cup<Int>()	// Type mismatch
    val nothings: Cup<Nothing> = Cup<Int>()	// Type mismatch
}

위 스니펫에서는 타입 파라미터 T에 variance 한정자(out, int) 모두 사용되지 않았으므로 기본적으로 invariant 합니다. invariant는 타입끼리 서로 연관성이 없다는 의미로 Cup< Int >, Cup< Number > 등등 어떠한 관련성도 갖지 않는다는 것입니다.

covariant(공변성)

// covariant
class Cup<out T>
open class Dog
class Puppy: Dog()

fun main(args: Array<String>){
	val b: Cup<Dog> = Cup<Puppy>()	// OK
    val a: Cup<Puppy> = Cup<Dog>()	// Error
}	

타입 파라미터와 함께 out 한정자를 사용하여 타입 파라미터를 covairant하게 만들 수 있습니다. 이는 A가 B의 서브타입이라고 했을 때, Cup< A >가 Cup< B >의 서브타입이라는 의미입니다.

contravariant(반변성)

// contravariant
class Cup<in T>
open class Dog
class Puppy(): Dog()

fun main(args: Array<String>){
	val b: Cup<Dog> = Cup<Puppy>()	// Error
    val a: Cup<Puppy> = Cup<Dog>()	// OK
    
    val anys: Cup<Any> = Cup<Int>()	// Error
    val nothings: Cup<Nothing> = Cup<Int>()	// OK
}

in 한정자는 반대로 타입 파라미터를 contravariant하게 만들고 A가 B의 서브타입일 때 Cup< B >가 Cup< A > 의 슈퍼타입이라는 것을 의미합니다.

함수타입

fun printProcessedNumber(transition: (Int) -> Any){
	print(transition(42))
}

함수 파라미터로 Int를 받고 Any를 리턴하는 함수를 받고 있습니다. 여기서 (Int) -> Any는 (Int) -> Number, (Number) -> Any, (Number) -> Number, (Number) -> Int 를 대입하더라도 문제가 없습니다.

val intToDouble: (Int) -> Number = { it.toDouble() }
val numberAsText: (Number) -> Any = { it.toShort() }
val identity: (Number) -> Number = { it }
val numberToInt: (Number) -> Int = { it.toInt() }
val numberHash: (Any) -> Number = { it.hashCode() }
printProcessedNumber(intToDouble)
printProcessedNumber(numberAsText)
printProcessedNumber(identity)
printProcessedNumber(numberToInt)
printProcessedNumber(numberHash)

위 예시에서 알 수 있듯이 코틀린 함수 타입의 모든 파라미터 타입은 contravariant 입니다. 즉, 더 상위 계층의 타입을 대입할 수 있습니다. 또한 리턴 타입은 covariant로 원래의 계층보다 낮은 계층의 타입으로 대입 가능합니다.


variance 한정자의 안정성

variance 한정자가 많이 사용되는 것 중 하나가 List 입니다. 여기서 MutableList는 variance 한정자가 붙지 않는데 어떠한 이유로 List를 더 많이 사용하게 되고 다른 지를 variance 한정자의 안정성 측면에서 확인하면 이해하기 수월해질 수 있습니다.

Java Array 문제점

// java
Integer[] numbers = { 1,4,2,1 };
Object[] objects = numbers;
object[2] = "B";	// RuntimeException : ArrayStoreException

자바의 배열은 기본적으로 제네릭으로 정의되어 있으며 covariant(공변성) 입니다. 그래서 objects에 numbers를 할당할 수 있으나 내부구조에서 사용되는 있는 실질적인 타입이 바뀌는 것은 아닙니다. 따라서 위 스니펫은 Integer 배열에 String 타입의 값을 할당하려고 하니 오류가 발생하게 되는 것입니다. Kotlin에서는 이러한 결함을 해결하기 위해 InArray, CharArray들을 invariant하게 만들었습니다.

covariant 한정자 주의사항

Kotlin에서 public한 in , out 한정자 위치가 존재합니다.
in : 함수의 매개변수
out : 함수의 리턴 타입

open class Dog
class Puppy: Dog()
class Hound: Dog()

class Box<out T>{
	private var value: T? = null
    
    // 실제로는 Compile Error
    fun set(value: T){
    	this.value = value
    }
    
    fun get(): T = value ?: error("Value not set")
}

val puppyBox = Box<Puppy>()
val dogBox: Box<Dog> = puppyBox
dugBox.set(Hound())		// Error

val dogHouse = Box<Dog>()
val box: Box<Any> = dogHouse
box.set("Some string")		// Error
box.set(42)		// Error

여전히 Kotlin에서 직접 제네릭 클래스를 생성 시에 여러가지 안전하지 않는 상황이 있습니다. 위 스니펫에서는 Java Array와 동일하게 캐스팅 이후에는 실질적인 객체가 그대로 유지되기 때문에 다른 서브타입을 대입하려고 하면 오류가 발생합니다. Kotlin에서는 이러한 상황을 막기 위해 public in 한정자 위치에 covariant 타입 파라미터가 오는 것을 금지합니다.

class Box<out T>{
	private var value: T? = null
    // var value: T? = null		// 컴파일 오류
    
    private set(value: T){
    	this.value = value
    }
    
    // fun set(Value:T){	// 컴파일 오류
    // 		this.value = value
	// }    
    
    fun get(): T = value ?: error("Value not set")
}

컴파일이 정상적으로 되기 위해서는 private로 가시성을 제한하면 오류가 발생하지 않습니다. 객체 내부에서는 업캐스트 객체에 out 한정자가 적용되지 않기 때문입니다. 추가로 get 메소드의 리턴 타입은 public out 한정자 위치로 안전하기 때문에 따로 제한은 없습니다.

contravariant 한정자 주의사항

covariant와 public in 위치로 인한 문제와 동일하게 contravariant과 public out 위치에서도 문제라 발생합니다.

open class Car
interface Boat
class Amphibious: Car(), Boat

class Box<in T>(
	// compile error
	val value: T
)

val garage: Box<Car> = Box(Car())
val amphibiousSpot: Box<Amphibious> = garage
val boat: Boat = amphibiousSpot.value    // Car

val noSpot: Box<Nothing> = Box<Car>(Car())
val not: Nothing = noSpot.value

위 스니펫도 사실상 컴파일 오류가 나타나지만 왜 Kotlin에서 금지해놨는지 이해할 필요가 있습니다. amphibiousSpot에는 contravarient 로 인해 Car 타입의 Box 객체가 저장되고 있습니다. 이후에 value를 통해서 값을 가져오게 되는데 사실상 Car 타입은 Boat와 연관성이 없기 때문에 문제가 됩니다.
따라서 코틀린에서는 contravarient 를 public out 한정자 위치에서 사용하는 것을 금지하고 있습니다.

class Box<in T>{
	// var value: T? = null
    private var value: T? = null
    
    fun set(value: T){
    	this.value = value
    }
    
    // fun get(): T = value ?: error("Value not set")
    private fun get(): T = value ?: error("Value not set")
}

동일하게 private 가시성을 추가하면 문제가 없도록 만들 수도 있습니다.


variance 한정자의 위치

variance 한정자는 크게 2가지 위치에서 사용할 수 있습니다.

  1. 선언 부분
class Box<out T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<Any> = boxStr

선언 부분에 한정자를 적용하면 클래스와 인터페이스 사용되는 모든 곳에 영향을 줍니다.

  1. 클래스와 인터페이스 활용하는 위치
class Box<T>(val value: T)
val boxStr: Box<String> = Box("Str")
val boxAny: Box<out Any> = boxStr

모든 인스턴스에 variance 한정자를 적용하지 않고 특정 인스턴스에만 적용해야 할 때 사용합니다.

MutableList에 in 한정자를 포함하면 값을 리턴할 수 없지만 단일 파라미터 타입에는 in 한정자를 붙여서 contravariant를 가지게 하는 것은 가능합니다.

interface Dog
interface Cutie
data class Puppy(val name: String): Dog, Cutie
data class Hound(val name: String): Dog
data class Cat(val name: String): Cutie

fun fillWithPuppies(list: MutableList<in Puppy>{
	list.add(Puppy("Jim"))
    list.add(Puppy("Beam"))
}

val dogs = mutableListOf<Dog>(Hound("Pluto"))
fillWithPuppies(dogs)
println(dogs)
// [Hound[name=Pluto), Puppy(name=Jim), Puppy(name=Beam)]

val animals = mutableListOf<Cutie>(Cat("Felix"))
fillWIthPuppies(animals)
println(animals)
// [Cat(name=Felix), Puppy(name=Jim), Puppy(name=Beam)]

7. 공통 모듈을 추출해서 여러 플랫폼에서 재사용하라.

코틀린을 사용하여 서버부터 클라이언트(웹/모바일) 개발 모두 가능합니다. 여러 플랫폼에 동작하는 서비스 개발 시에 공통 모듈을 추출하여 재사용한다면 큰 이득이 발생할 것입니다. 아래는 코틀린을 사용하여 개발할 수 있는 예를 간단하게 작성하였습니다.

  • 코틀린/JVM 사용한 백엔드 개발 - 스프링, Ktor 등
  • 코틀린/JS 사용한 웹사이트 개발 - 리액트 등
  • 코틀린/JVM 사용한 안드로이드 개발 - 안드로이드 SDK 등
  • 코틀린/네이티브를 통해 Object-C/스위프트로 ios 프레임워크 개발
  • 코틀린/JVM 사용한 데스크톱 개발 - TornadoFX
  • 코틀린/네이티브를 사용한 라즈베리파이, 리눅스, macOS 프로그램 개발

정리

3장에서는 재사용성을 높이기 위한 방법 또는 휴리스틱에 대해 알게 되었고 특히, Generic을 활용한 개발 시에 도움이 되는 팁들을 많이 배웠던 것 같습니다. 정리된 글에 대해 궁금한 점이나 다른 이견 있으시면 댓글로 남겨주세요~ 😘

profile
"Effort never betrays"

0개의 댓글