본문 바로가기
오늘은 뭘 배울까?/책

[이펙티브 코틀린] 5장. 객체 생성

by Kim Juhwan 2022. 12. 5.

1. 생성자 대신 팩토리 함수를 사용하라
2. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라
3. DSL

 

 

 


 

 

1. 생성자 대신 팩토리 함수를 사용하라 (206p ~ )

1-1. 팩토리 함수란?

객체를 만드는 방법에는 여러 가지가 있다.

다양한 생성 패턴이 있으므로 상황에 맞게 사용하면 된다.

그중 하나인 팩토리 함수에 대해 알아보자.

 

interface HelloMessage {
    fun hello(name: String): String
}

class HelloMessageKor : HelloMessage {
    override fun hello(name: String): String {
        return "안녕하세요 $name 님"
    }
}

환영 인사를 해주는 HelloMessage라는 interface가 있고

이를 상속받는 HelloMessageKor가 있다고 하자.

 

val helloMessage = HelloMessageKor() // 흔한 객체 생성 방법
val greeting = helloMessage.hello("아이유")
print(greeting) // 안녕하세요 아이유 님

우리는 객체를 만들어 사용하기 위해 위와 같이 코드를 작성할 수 있다.

생성자를 사용해서 객체를 만드는 방법이다.

그렇다면 팩토리를 사용한다는 것은 어떤 걸까. 다음 코드를 보자.

 

class HelloMessageFactory {
    fun getHelloMessage(): HelloMessage {
        return HelloMessageKor()
    }
}

객체를 생성해서 리턴하는 함수를 가지는 클래스를 생성하고

 

val helloMessageFactory = HelloMessageFactory()
val helloMessage = helloMessageFactory.getHelloMessage()
val greeting = helloMessage.hello("아이유")
print(greeting) // 안녕하세요 아이유 님

이렇게 사용하는 것이 팩토리 패턴이다.

HelloMessageKor 객체를 직접 생성하는 것이 아니라 팩토리 클래스한테 만들어 달라고 하는 것이다.

팩토리 클래스가 마치 물건을 찍어내는 공장처럼 동작하는 것이다.

"야 나 이 객체 좀 만들어줘" 하면 팩토리가 "ㅇㅋㅇㅋ 자 객체 여기 있어" 하면서 주는 느낌인 것이다.

 

1-2. 팩토리 함수의 장점

근데 단순히 생각해보면 코드 길이만 늘어나고 이렇게까지 굳이 해야 하나? 싶은데

팩토리 함수를 사용하면 따라오는 장점들이 있다.

 

  • 생성자와 다르게 함수에 이름을 붙일 수 있다.
    • ArrayList(3)이라는 코드가 있다고 해보자. 3은 대체 무엇을 의미하는 것일까?
      팩토리 함수를 사용하면 ArrayList.withSize(3)과 같이 이름을 지정해주어 명시적으로 표현할 수 있다.
    • 동일 파라미터 타입을 갖는 생성자의 충돌을 막을 수 있다.
  • 함수가 원하는 형태의 타입을 리턴할 수 있다.
    • 인터페이스 뒤에 실제 객체의 구현을 숨길 때 유용하게 쓰인다.
  • 호출될 때마다 새 객체를 만들 필요가 없다.
    • 함수를 사용해서 객체를 생성하면 싱글턴 패턴처럼 객체를 하나만 생성하게 강제하거나,
      최적화를 위해 캐싱 메커니즘을 사용할 수 있다.
    • 어떠한 이유로 객체를 만들 수 없는 경우 null을 리턴하게 만들 수도 있다.
  • 아직 존재하지 않는 객체를 리턴할 수 있다.
    • Dagger hilt와 같은 의존성 주입 라이브러리가 바로 Factory 패턴을 사용하기 때문에
      프로젝트를 빌드하지 않고도 앞으로 만들어질 객체를 사용할 수 있는 것이다.
  • 객체 외부에 팩토리 함수를 만들면, 그 가시성을 원하는 대로 제어할 수 있다.

 

1-3. 팩토리 함수의 종류

팩토리 함수에도 종류들이 있다.

하나씩 알아보도록 하자.

 

  • Companion 객체 팩토리 함수
class MyLinkedList<T>(
    val head: T,
    val tail: MyLinkedList<T>?
) {
    companion obejct {
    	fun <T> of(vararg elements: T): MyLinkedList<T>? {
        	...
        }
    }
}

이것이 팩토리 함수를 정의하는 가장 일반적인 방법이라고 한다.

자바로치면 정적 함수를 사용한 거라고 생각하면 된다.

 

  • 확장 팩토리 함수
interface Tool {
    companion object { ... }
}

Tool이라는 인터페이스가 있는데 수정할 수 없는 상황이라고 해보자.

 

// 확장함수 정의
fun Tool.Companion.createBigTool( ... ): BigTool {
    ...
}

// 사용할 때
Tool.createBigTool()

이럴 때 우리는 Companion 객체를 활용해 확장 함수를 정의할 수 있다. (호오...)

이 방법을 활용하면 외부 라이브러리도 확장할 수 있다.

다만 (적어도 비어 있는) Companion 객체가 필요하다는 점을 유의하자.

 

  • 톱레벨 팩토리 함수

우선 톱레벨(Top level) 함수는 클래스 안이 아니라 밖에 존재하는 함수를 말한다.

자바와 다르게 코틀린은 함수가 가장 바깥쪽, 즉 최상위에 존재할 수 있는데 이를 톱레벨 함수라고 부른다.

 

class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
    }

    companion object {
        fun getIntent(context: Context) =
            Intent(context, MainActivity::class.java)
    }
}

그러면서 책에서 예시로 들어주는 것이 바로 위 코드이다.

Intent 객체를 만드는 함수를 Companion을 이용해 이렇게 작성할 수도 있습니다~ 라는 것을 보여주고 있는데

한 가지 의문인 것은 MainActivity라는 class안에 있는데 이게 톱레벨 함수랑 무슨 연관이 있냐는 거... 🤔

-> companion object는 클래스가 적재되면서 함께 생성되는 동반객체이다.

class안에 속해있는 것처럼 보이지만(= static 클래스 변수처럼 느껴지지만) 실제로는 그렇지 않기 때문에
companion object안에 있는 함수도 톱레벨 함수라고 부르는 듯하다.

 

class SecondActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_second)

        startActivity(Intent(this, MainActivity::class.java)) // 원래 같았으면 이렇게 썼겠지만...
        startActivity(MainActivity.getIntent(this)) // 이렇게 사용이 가능하다.
    }
}

아무튼 저렇게 작성하면 Intent를 사용하고자 하는 곳에서 이렇게 쓸 수 있다.

예전에 어떤 회사의 과제 전형을 볼 때 주어진 베이스 코드에서 이 방법을 사용하길래

대체 왜 이렇게 쓰지? 했는데 그게 바로 이거였구나...

 

intentFor<MainActivity>()

하지만 Anko 라이브러리를 사용하면 이렇게 간단하게 사용할 수 있으니

이전 예시는 참고만 하고 실제로 필요하면 이걸 사용하라는 듯 책에서 권장하고 있다.

Anko 라이브러리가 대체 뭔데 책에서 소개해주는 걸까 궁금해서 찾아보니 jetBrain에서 만든 라이브러리였다.

음.. 그럼 사실상 공식 라이브러리(?)지.. 끄덕끄덕..

Anko 라이브러리는 이처럼 intent, toash, dialog 등을 사용할 때 좀 더 간단하고 편리하게 사용하도록 도와주는 라이브러리라고 한다.

 

List.of(1, 2, 3) // 이거보단
listOf(1, 2, 3) // 이게 읽기 쉽지 않겠는가?

톱레벨 함수를 사용하는 이유는 읽기 쉽기 때문이다.

하지만 톱레벨 함수는 모든 곳에서 사용할 수 있으므로 IDE가 제공하는 팁을 복잡하게 만드는 단점이 있다.

톱레벨 함수의 이름을 클래스 메서드 이름처럼 만들면 다양한 혼란을 일으킬 수 있으니 신중해서 네이밍 하라고 한다.

흠 🤔 클래스 메서드 이름처럼... 은 대체 어떤 느낌일까?

setXXX, getXXX 같은 걸까...? 책을 읽으면서 이건 감이 잘 오지 않았다.

 

  • 가짜 생성자
class User // 대문자로 시작

fun getName() // 소문자로 시작

흔히 클래스는 대문자로 시작하고 함수는 소문자로 시작한다는 암묵적인(?) 규칙이 있다.

그 말은 꼭 지키지 않아도 된다는 것을 의미하는 데...

 

public inline fun <T> List(
    size: Int,
    init: (index: Int) -> T
): List<T> = MutableList(size, init)

그래서 이렇게 함수 이름을 대문자로 시작할 수도 있다.

대체 굳이 왜 대문자로 시작하냐고?

바로 함수를 생성자처럼 보이게 하기 위함이다.

이러한 톱레벨 함수는 생성자처럼 보이고, 생성자 처럼 작동한다.

동시에 팩토리 함수가 가지는 모든 장점을 갖는다.

많은 개발자들이 이 함수가 톱레벨 함수인지 잘 모르기 때문에 이를 가짜 생성자라고 부르는 것이다. (네이밍 귀엽...)

 

List(4) { "User$it" } // [User0, User1, User2, User3]

List가 interface인데도 위 코드에서 생성자처럼 쓸 수 있는 이유가 바로 가짜 생성자 덕분이다.

좀 전에 예시로 보여준 코드는 실제로 코틀린 1.1부터 stdlib에 포함된 List 함수이다.

 

class Tree<T> {
    companion object {
    	operator fun <T> invoke(size: Int, generator: (Int)->T): Tree<T> {
            // ...
        }
    }
}

// 사용
Tree(10) { "$it" }

가짜 생성자는 companion object를 활용해서도 만들 수 있다.

하지만 이런 방식은 거의 사용되지 않고 추천하지 않는다고 한다.

겉보기에는 사용하는 입장에서 별 다를 게 없어 보이는데 왜 일까?

 

Tree.invoke(10) { "$it" }

(invoke에 대한 설명은 생략)

invoke는 생략하지 않고 위와 같이 작성할 수 도 있는데

invoke가 호출한다는 의미이므로 객체 생성과 의미가 차이가 있어 사용하기에 적절하지 않다.

 

val f: ()->Tree = ::Tree // 생성자
val f: ()->Tree = ::Tree // 가짜 생성자
val f: ()->Tree = Tree.Companion::invoke // invoke 함수를 갖는 Companion 객체

또, 리플렉션을 보면 invoke 함수가

생성자나 가짜 생성자에 비해 복잡성을 가진다는 걸 알 수 있다.

 

아무튼 정리해서!

가짜 생성자는 톱레벨 함수를 사용하는 것이 좋고

무조건 가짜 생성자를 만들어서 써라~! 가 아니라

기본 생성자를 만들 수 없는 상황 또는 생성자가 제공하지 않는 기능으로 생성자를 만들어야 하는 상황에만 사용하자.

 

  • 팩토리 클래스의 메서드
class StudentsFactory {
    var nextId = 0
    fun next(name: String, surname: String) =
    	Student(nextId++, name, surname)
}

팩토리 클래스는 클래스의 상태를 가질 수 있다는 특징 때문에 팩토리 함수보다 다양한 기능을 갖는다.

예를 들어서 id를 증가시키면서 학생을 생성하는 위와 같은 작업이 가능한 것이다.

그래서 이런 방법으로도 객체를 생성할 수 있다는 거~!

 

이렇게 팩토리 함수를 만드는 여러 가지 방법을 알아보았는데

가장 일반적인 방은 companion 객체를 사용하는 것이라고 한다.

대부분의 개발자에게 안전하고 익숙한 패턴이기 때문이다.

 

2. 기본 생성자에 이름 있는 옵션 아규먼트를 사용하라 (219p ~ )

// 이 코드와
class Pizza(
    val size: String,
    val cheese: Int = 0,
    val olives: Int = 0,
    val bacon: Int = 0
)

// 이 코드는 동일한 기능을 수행한다.
class Pizza {
    val size: String,
    val cheese: Int,
    val olives: Int,
    val bacon: Int
    
    constructor(size: String, cheese: Int, olives: Int, bacon: Int) {
    	this.size = size
        this.cheese = cheese
        this.olives = olives
        this.bacon = bacon
    }
    
    constructor(size: String, cheese: Int, olives: Int):
    	this(size, cheese, olives, 0)
    constructor(size: String, cheese: Int): this(size, cheese, 0)
    constructor(size: String): this(size, 0)
}

아래쪽에 있는 Pizza 클래스는 여러가지 종류의 생성자를 사용하고 있다.

우리는 이를 점층적 생성자 패턴이라고 불린다.

코틀린에서는 이러한 점층적 생성자 패턴을 사용하지 않아도 된다.

디폴트 아규먼트를 지원하기 때문이다!

 

val myFavorite = Pizza(
    size = "L",
    olives = "3"
) // cheese와 bacon은 기본 값인 0으로 들어간다.

이렇게 원하는 값만 집어넣으면 나머지는 기본 값으로 알아서 세팅이 된다.

(참고로 값 앞에 변수의 이름을 명시하는 것을 '이름 있는 파라미터'라고 책에서 소개하고 있다)

 

class Pizza private constructor(
    val size: String,
    val cheese: Int,
    val olives: Int,
    val bacon: Int
) {
    class Builder(private val size: String) {
    	private var cheese: Int = 0
        private var olives: Int = 0
        private var bacon: Int = 0
        
        fun setCheese(value: Int): Builder = apply {
            cheese = value
        }
        fun setOlives(value: Int): Builder = apply {
            olives = value
        }
        fun setBacon(value: Int): Builder = apply {
            bacon = value
        }
        
        fun build() = Pizza(size, cheese, olives, bacon)
    }
}

자바에서는 디폴트 아규먼트가 없기 때문에 이런 문제를 해결하기 위해 나온 것이 빌더 패턴이다.

 

val myFavorite = Pizza.Builder("L").setOlives(3).build()

val villagePizza = Pizza.Builder("L")
        .setCheese(1)
        .setOlives(2)
        .setBacon(3)
        .build()

빌더 패턴을 사용하면 이렇게 사용할 수 있다.

근데 딱 봐도 불편해 보이지 않는가...?

빌더 패턴보다 디폴트 아규먼트와 이름 있는 아규먼트를 사용하는 것이 많은 장점을 가진다.

 

  • 더 짧다.
    • 앞서 코드만 봐도 훨씬 더 짧은 것을 알 수 있다.
  • 더 명확하다.
    • 객체가 어떻게 생성되는지 확인하려면 빌더 패턴은 여러 메서드들을 확인해야 한다.
      하지만 디폴트 아규먼트를 사용할 땐 생성자 주변만 보면 된다.
  • 더 사용하기 쉽다.
    • 기본 생성자는 언어에 내장된 개념이지만
      빌더 패턴은 잘 모르는 개발자들에겐 생소한 개념일 수 있다.
      빌더 패턴을 알아도 시간이 지나면 빌터 패턴으로 객체를 만들어야 한다는 사실을 잊기 쉽다.
  • 동시성 관련 문제가 없다.
    • 빌터 패턴에서 프로퍼티는 대부분 mutable이기 때문에
      빌더 함수를 thread safe 하게 구현하기 쉽지 않다.
      하지만 코틀린의 함수 파라미터는 항상 immutable이기 때문에 동시성 문제가 없다.

 

그렇다면 빌더 패턴은 눈 씻고 찾아봐도 장점이 없는 쓰레기일까? 🤔

책에서 "아닙니다! 그래도 장점이 있습니다!"라고 소개하길래 열심히 읽었는데

그 뒤에 "근데 이런 장점도 빌더 패턴을 사용할 이유가 되지 못합니다"라고 적혀있어서 따로 장점은 언급하지 않으려 한다.

다만, 그럼에도 불구하고 빌더 패턴이 사용되는 경우는 다음과 같다.

 

  • 빌더 패턴을 사용하는 다른 언어로 작성된 라이브러리를 그대로 옮길 때
  • 디폴트 아규먼트와 DSL을 지원하지 않는 다른 언어에서 쉽게 사용할 수 있게 API를 설계할 때

즉, 약간 어쩔 수 없는 상황에서나 쓰이는 것 같다.

이미 빌더 패턴을 쓰고 있는 라이브러리를 옮길 때나...

빌더 패턴보다 더 좋은 방법을 지원하지 않는 언어에서 쓴다고 하니 말이다.

 

어라? 근데 방금 DSL이라고 했는데 DSL이 대체 뭐지?

다음 목차에서 알아보자.

 

3. DSL

개발자 🧑‍💻: 아 그게 서버에서 URL을 보내줘야 하는데, API가 아직 미완성인가 봐요. JSON에 빠져있네요.

의사👩‍⚕️: 김 간호사, 이분 벤틸레이터 달고 있는 환자니까 렁 사운드 주기적으로 체크해줘야 해.

변호사 👨‍⚖️: 형사소송법 307조 사실의 인정은 증거에 의하여한다 바로 증거재판주의입니다.

 

직업별로 알아들을 수 있는 말들을 한 번 모아보았다.

(내가 아무렇게나 적은 거라 찐 현직자가 보면 이게 무슨 개소리인가 싶을지도...)

아무튼, 이렇게 직업군 내에서 사용되는 전문용어들이 있다.

전문용어는 타 직군 사람들이 이해하기 어렵겠지만 같은 직군 사람들 내에서는 소통을 더 효율적으로 하도록 도와준다.

우리는 이를 Domain Specific Language라고 부를 수 있다.

도메인에 특화된 언어라는 것이다.

 

컴퓨터 언어도 마찬가지다.

각 언어마다 해당 언어에서만 사용되는 DSL이 있으며 Kotlin에도 존재한다.

DSL은 복잡한 객체, 계층 구조를 가지고 있는 객체들을 정의할 때 굉장히 유용하다고 한다.

그리고 이에 대한 설명이 책에 적혀있는데, 이해하기가 어려웠다 🤔

이번 장은 다음에 다시 마스터하는 걸로...

 


💡 느낀 점

  • 빌더 패턴 목차를 읽으면서 새삼... 코틀린이 정말 편하긴 하구나라는 게 느껴졌다.
    Java로 만들 생각 하니까 끔찍하다.
  • 책에 이름을 가진 생성자에 사용할 수 있는 네이밍에 대한 언급이 있는데
    코딩을 하면서 자주 접했던 네이밍이지만 지니고 있는 의미를 정확히 알지 못했는데 이에 대해 알게 되어 좋았다.

📘 참고한 자료

  • Effective Kotlin - 마르친 모스칼라 지음, 윤인성 옮김

 

 

반응형

댓글