[Kotlin] 생성자 변수가 없는 경우, class와 object 중 어떤 것을 활용할까?

Kame·2024년 2월 18일
0

Kotlin

목록 보기
2/9

2월 중반부터 우아한테크코스 6기 안드로이드 과정에 참여하며, 첫 미션으로 프리코스 과제였던 ‘자동차 경주’를 다시 구현해보게 되었습니다. 우테코에서는 수료 후 현업에 바로 투입될 수 있는 개발자를 양성하고 있는 만큼, 크루들의 하드스킬과 소프트스킬을 효과적으로 향상시켜주고자 페어 프로그래밍 방식으로 과제를 부여하고 있습니다. 따라서 프리코스 때와는 달리, 작성하는 코드는 동작이 잘 이뤄져야 할 뿐만 아니라 페어의 마음에도 들어야 했기에 같은 미션임에도 불구하고 훨씬 많은 시간을 들이고 나서야 미션을 완수할 수 있었습니다. 하지만 페어와의 협업을 통해 평소 제대로 알지 못하고 작성하였던 코드가 정말 많았음을 깨닫게 되었고, 잘못 알고 있었던 지식을 바로잡을 수 있었을 뿐만 아니라 생소했던 테스팅 관련 메소드들을 페어의 도움으로 코드에 적용해보는 경험도 할 수 있었습니다. 그 덕에 첫 미션부터 개발자로서 많은 내, 외적 성장을 경험할 수 있었던 것 같습니다.

미션을 진행하는 과정에서, 페어와 몇몇 쟁점에 대해 논의를 거쳐 코드를 완성하였습니다. 하지만 한 가지 사항에 대해서는 결론에 이르지 못하고 미션을 제출하게 되었는데, 바로 객체 선언 시, 생성자 변수가 없는 경우에 class와 object 중 어떤 것을 선택할지에 관한 것이었습니다. 이번 기회에 이것에 대한 판단의 기준을 잡고 싶었고, 그 고민의 결과인 저의 생각을 추후 미션 및 안드로이드 개발을 위한 코드 작성 시에 적용해보고자 합니다. 이 사항은 어디까지나 저만의 기준일 뿐이며, 유용한 정보 공유와 오류 지적은 언제든 환영입니다!


object?

사실 Kotlin을 활용하여 개발을 했던 지난 날들 동안, 단순히 주 생성자로 넣는 필드 혹은 프로퍼티가 없는 상황이라면 깊은 생각 없이 object 키워드를 사용해왔습니다. 또한 object를 사용하면 여러 곳에서 객체를 활용해도 하나의 인스턴스만을 활용하기 때문에 막연하게 효율적일 것이라는 생각을 가지고 개발을 진행했습니다. 하지만 이 궁금증들을 해결해 나가는 과정 속에서, 객체 생성 시 class 혹은 object 중 어떤 것을 선택할지에 대한 더욱 이유있는 잣대를 세울 수 있었습니다.

Kotlin에서 객체를 선언할 때 class 키워드가 아닌 object 키워드를 활용하면 얻게 되는 효과를 하나의 키워드로 요약하면, 유명한 디자인 패턴 중 하나인 Singleton이라고 할 수 있습니다(declaration 한정. expression으로 선언한 객체는 Singleton이 아닙니다). Singleton의 가장 기본적인 정의는 특정 객체를 하나의 인스턴스로만 활용할 수 있도록 한다는 것입니다. 메모리의 활용 측면에서 서술한 이 정의는 이전부터 알고 있던 내용이었습니다. 하지만 class와 object 간 선택의 면밀한 기준을 세우기 위해 고민을 하던 와중에 이 정의를 다시 접하면서, 당연하게 받아들였던 이 정의에 대해 아래와 같은 의문점들을 갖게 되었습니다.

의문점들

  • Singleton을 사용해야만 하는 상황들로 어떤 것들이 있는가?
  • class가 아닌 object를 사용해서 객체를 Singleton으로 선언하면 좋은 상황들로 어떤 것들이 있는가?
    • class와 비교하여, object 키워드를 활용해 Singleton으로 구현하면 어떤 이점을 얻을 수 있는가?
    • 반대로 주 생성자가 없다고 하더라도 class를 사용해야 하는/사용하면 더 좋은 상황은 없는 것인가?

Q1. 객체를 선언할 때, 어떤 경우에서 반드시 object declaration을 활용해 Singleton으로 구현할 것인가?

적어도 애플리케이션 전역에서 데이터가 공유되어야 하는 경우만큼은, object를 사용하여 Singleton으로 구현할 것이다. 이와 동시에 리소스를 정말 많이 활용해야 한다거나, 애플리케이션 전반을 관장한다는 느낌까지 풍기는 객체라면 고민하지 않고 object를 활용할 것이다.

object 키워드를 사용하여 선언한 객체를 다른 곳에서 호출하는 방식은 인스턴스를 생성하지 않고 곧바로 객체명을 활용하는 것입니다.

object MyObject {
	fun start() {
		// ...
	}
}

fun main() {
	// 객체 활용 방식 : 객체 명을 직접 명시하여 접근
	MyObject.start()
}

object를 활용해 선언된 객체는 여러 곳으로부터 호출된다면, 내부적으로 가장 첫 번째로 접근되는 시점(lazily-created)에 한번만 초기화되도록 동작합니다. 이는 실수로 새로운 인스턴스를 초기화하여 전역적 관리/공유를 깨뜨리는 상황을 방지할 수 있도록 합니다. 또한, 전역적으로 공유되어야 하기 때문에 하나의 인스턴스만 생성되면 더 이상 초기화가 필요없어 이로 인한 개발 상의 효율은 부수적으로 따라올 수밖에 없다는 것으로 보아도 될 것입니다. 만약 class를 활용한다면, 이러한 효율성을 십분 활용하지 못할 것입니다.

그리고 애플리케이션 전반을 관장하는 기능이 무엇인지는 사람의 관점에 따라 다르다고 생각합니다. 사실 짧은 지식을 가진 입장에서, 이것에 대한 답은 없다고 생각합니다. 하지만 여러 자료들을 찾아보고 직접 구현을 해보면서, 이것에 대해 세우게 된 저만의 기준은 바로,

인터페이스로 추상화가 될 여지가 전혀 없는가?

였습니다. 애플리케이션의 전반을 관장한다는 것은 뼈대와도 같은 느낌일 것입니다. 이 뼈대는 다양한 양상으로 확장될 여지가 적을 것이며, 변경 가능성도 낮아야 할 것입니다. 만약 그렇지 않다면 이것은 뼈대라고 볼 수 없을 것입니다. 해당 요건을 만족한다면, 하나의 인스턴스만으로 생성되어 전역적으로 공유되고 관리될 여지는 충분하다고 생각합니다.

자동차 경주를 구현하는 과정에서, 이러한 기준들을 근거로 object declaration을 활용해 구현한 객체들은 다음과 같습니다.

  • Controller : 애플리케이션의 전체적인 진행을 담당하는 객체. 누가 봐도 싱글톤 객체로 구현할 필요성이 명확한 케이스라고 볼 수 있다.
  • Constants : 애플리케이션에서 상태 변화의 여지가 없으며 전역적으로 활용될 수 있는 상수이다. 단 특정 클래스 내부에서만 활용될 수 있는 상수의 경우 companion object에 정의한다.

Q2. 객체를 선언할 때, class와 object declaration 중 어느 것이라도 활용해서 구현해도 무방하다면 어떤 기준으로 선택할 것인가?

  • thread-safe가 필요한 경우라면, 되도록 object를 활용할 것이다.
  • 애플리케이션이 실행되는 동안 일시적이 아닌 지속적으로 필요할 기능을 담당하는 객체를 구현하는 경우라면, object 활용을 지향할 것이다.
  • 그 이외에 전역적으로 사용되지 않으면서 애플리케이션에서 온종일 실행될 필요가 없는 기능을 가진 객체인 경우, class를 활용하여 객체를 선언할 것이다.
  • 다만 메모리 사용량에 대한 부담이 적은 상황이라면, 앞의 조건에 부합하지 않더라도 생성자가 없는 경우 필요에 따라 object 키워드를 사용하게 될 것 같다.

Kotlin의 object에 대한 공식문서를 읽던 도중 마주친 아래의 문구에 대해 알아보면서 두 번째 의문에 대한 답을 찾을 수 있었습니다.

The initialization of an object declaration is thread-safe and done on first access.

여기서 주목할 만한 부분은, 선언식 object(object + 객체명 형식으로, 내부에는 일반적인 class처럼 프로퍼티 또는 메소드 등을 추가하는 식으로 객체를 정의)의 초기화는 thread-safe하게 이뤄진다는 것입니다. 사실 이 내용은 이번 미션의 범위를 넘어서는 내용이긴 하지만, 평소 무심코 넘겼던 용어에 대해 알아보고 싶었습니다. 위키백과에 따르면, thread-safe의 정의는 다음과 같습니다.

멀티 스레드 프로그래밍에서 일반적으로 어떤 함수나 변수, 혹은 객체가 여러 스레드로부터 동시에 접근이 이루어져도 프로그램의 실행에 문제가 없음을 뜻한다. 보다 엄밀하게는 하나의 함수가 한 스레드로부터 호출되어 실행 중일 때, 다른 스레드가 그 함수를 호출하여 동시에 함께 실행되더라도 각 스레드에서의 함수의 수행 결과가 올바로 나오는 것으로 정의한다.

공식문서에서의 정의대로라면, Thread Safe가 필요한 객체는 일반적인 class가 아닌 object를 활용한 Singleton으로 구현하는 것이 좋을 것입니다(일반 class 내부에서 companion object로 정의한 객체도 여기에 포함).

그렇다고 object가 아닌 class로는 Thread-Safe한 객체를 만들 수 없는 것인가 하면, 그것은 아닙니다. 하지만, 공식문서에서 이러한 내용을 명시한 이유는, 추측하건대 object를 사용하여 thread-safe를 달성하기 위해 들여야 하는 노력은 class를 활용할 때보다 훨씬 적기 때문일 것입니다. 아래의 두 코드 스니펫은 동일한 기능을 수행하지만, object는 그 자체로 thread-safe가 내부적으로 구현이 되어있기 때문에 class보다 훨씬 간결한 코드로 목적을 달성할 수 있습니다.

  • class 활용 : 별도로 companion object와 synchronized() 메소드를 활용하여 thread-safe 달성

    class Singleton private constructor() {
        private var count = 0
        fun count(): Int {
            return count++
        }
    
        companion object {
            private var INSTANCE: Singleton? = null// Double checked
    
            // Single Checked
            val instance: Singleton?
                get() {
                    if (INSTANCE == null) { // Single Checked
                        synchronized(Singleton::class.java) {
                            if (INSTANCE == null) { // Double checked
                                INSTANCE =
                                    Singleton()
                            }
                        }
                    }
                    return INSTANCE
                }
        }
    }
  • object 활용

    object Singleton {
        private var count: Int = 0
    
        fun count() {
            count++
        }
    }

그 이외의 경우에서는, 메모리 활용을 고려할 수 있을 것입니다. 다른 일들과 마찬가지로, 프로그램을 작성할 때 역시 자원을 효율적으로 사용하는 것이 중요합니다. 여기서 언급하고자 하는 효율이라는 것은 객체가 정말 필요한 시점에만 메모리에 올라왔다가 더 이상 필요가 없어질 때 적절히 메모리에서 빠지게 되는 매커니즘이 최대한 지켜지는가에 관한 것이라고 할 수 있습니다. Kotlin에서, objectclass가 메모리를 활용하는 방식은 다릅니다. 그리고 이것이 두 키워드의 가장 큰 차이점이라고 볼 수 있습니다. 이러한 차이를 생각해보면, 둘 중 어느 것을 활용할지에 대해서 방향을 잡을 수 있을 것이라 생각합니다.

object는 Singleton 패턴으로 동작합니다. 이는 곧 어느 시점에서 객체명을 활용한 초기화가 이뤄진 이후에는 애플리케이션이 살아있는 한 계속 동작함을 의미합니다. 만약 애플리케이션에서 어느 순간부터 해당 객체의 기능이나 프로퍼티가 활용되지 않는데, 그 객체가 계속 메모리에 올라와있는다는 것은 비효율적이라고 볼 수 있을 것입니다.

아래의 코드를 통하여, object를 활용한 코드가 Java로 디컴파일되는 양상을 확인할 수 있습니다.

object Test {
    // 함수와 프로퍼티 정의
}
public final class Test {
    public static final Test instance;

   static {
      Test var0 = new Test();
      instance = var0;
   }
}

object로 정의한 Test 클래스의 초기화는 static 블록 내부에서 일어나고 있음을 알 수 있습니다. Java에서 static 키워드를 사용하여 정의된 데이터는 실행 시, 메모리의 static 영역에 적재되어, 프로그램이 종료될 때까지 메모리 상에 남아있게 됩니다.

여기서 생각해볼만 한 키워드는 바로 garbage collection입니다. JVM(Java Virtual Machine)은 메모리에 대한 검사를 주기적으로 시행하여 메모리 중 heap 영역에 존재하지만 더 이상 사용하지 않는 객체를 자동으로 메모리로부터 제거하는 기능을 수행하는데, 이것을 garbage collecting이라고 합니다.

이러한 특성을 바탕으로 생각해 보면, static 영역에 적재되는 object는 garbage collection의 대상이 될 수 없는 반면, 일반적인 class의 인스턴스는 일반적으로 heap 영역에 적재되기에 garbage collection의 대상이 될 수 있습니다.

따라서 메모리 사용의 측면에서, 애플리케이션의 수행이 종료될 때까지 지속적으로 필요한 기능을 가진 객체의 경우에는 object를 활용하여 불필요한 초기화를 막고, 그렇지 않고 일시적으로 필요한 기능의 경우 효율성을 위하여 class로 선언하는 기준을 세울 수 있을 것입니다.

No Silver Bullet

너무나도 식상한 이야기일 수 있습니다. 그렇지만, 절대적인 기준은 없다고 생각합니다. 프로그램을 작성할 때는 매우 많은 변수들과 배경들이 존재합니다.

object로 선언한다고 하더라도 성능에 크게 영향을 미치지 않을 만큼 작은 규모의 객체가 존재할 수도 있을 것이고, 누군가는 가독성과 코드 길이 등 다른 요소들을 고려하여 상술했던 기준과 상반되는 방식으로 구현을 할 수도 있을 것입니다. 반대로, 어떤 측면에서는 싱글톤은 SOLID 원칙을 위배할 여지가 큰 안티패턴이라는 시각도 존재하고 있습니다.

이 글을 작성한 저 역시 이번 미션을 구현하고 리팩토링을 하는 과정에서 이 기준을 완벽히 따르지는 않았고, 가독성과 코드 길이에 따라서 적절하다고 생각한 방식을 선택하였습니다. 현재는 규모가 크지 않은 프로그램을 구현하고 있기 때문에 이러한 기준을 느슨하게 지키고 있지만, 추후 복잡도가 높은 미션 혹은 큰 규모의 앱을 구현하게 될 때는 이 글이 나름의 기준이 될 수 있으면 좋겠습니다.

미션 깃허브 레포 링크

참고자료

https://kotlinlang.org/docs/object-declarations.html#object-declarations-overview
https://en.wikipedia.org/wiki/Singleton_pattern
https://en.wikipedia.org/wiki/Thread_safety
https://kt.academy/article/ek-unnecessary-objects
https://stackoverflow.com/questions/54052761/does-object-in-kotlin-get-garbage-collected
https://craftofcoding.wordpress.com/2015/12/07/memory-in-c-the-stack-the-heap-and-static/

profile
Software Engineer

4개의 댓글

comment-user-thumbnail
2024년 2월 19일

잘 읽었습니다! ^^ 애플리케이션의 시작부터 종료까지 지속적으로 필요한 로직의 경우 object를, 일시적으로 필요한 경우 class를 사용하는것이 메모리를 효율적으로 사용하는 방법이겠군요. 저 또한 그동안 막연하게 object를 사용해 왔는데 이제 나름의 기준을 갖고 사용할 수 있을 것 같습니다👍

1개의 답글
comment-user-thumbnail
2024년 2월 19일

글과 코드도 너무 잘 읽었습니다!
static, stack, static 영역을 그림으로 보니 더 이해하기 쉬웠던 것 같아요.
Controller 가 애플리케이션의 전체적인 진행을 담당하는 객체라는 이유로 Controller 를 object 로 선언하셨다고 했는데, DI 를 생각하면 컨트롤러는 class 로 선언하는 게 좋다고 생각해요.

저는 현 프로그램에서 가장 변경될 가능성이 높다고 본 부분은 '어떻게 전진의 조건을 결정하는지' 였어요.
그러한 부분은 컨트롤러의 생성자의 파라미터로 넣고 RacingCarApplication 에서 구현체를 주입해주도록 하면 이후에 요구사항 변경에 대해서 더 쉽게 대처할 수 있을 것 같아요.

1개의 답글