목차
이번 게시글은 Effective Kotlin — Chapter 6. Class design 1부에 대한 내용입니다.
클래스는 OOP 프로그래밍에서 가장 중요한 추상화입니다. 그리고 Kotlin에서 이 클래스에 대한 추상화를 어떻게 다루는지 알아보도록 하겠습니다.
1. 상속보다는 컴포지션을 사용하라
상속은 굉장히 강력한 기능으로, is-a
관계의 객체 계층 구조를 만들기 위해 설계 되었습니다. 하지만 상속은 관계가 명확하지 않을 때 사용하면, 여러가지 문제가 발생할 수 있습니다.
따라서 단순하게 코드 추출 또는 재 사용을 목적으로 상속하려고 한다면, 조금 더 신중하게 생각해야 합니다.
일반적으로 이러한 경우에는 상속보다 컴포지션을 사용하는 것이 좋습니다.
간단한 행위 재사용
간단한 예제를 들어보겠습니다. 프로그래스 바를 어떤 로직 처리 전에 출력하고, 처리 후에 숨기는 유사 동작을 하는 두 개의 클래스가 있다고 가정하겠습니다.
필자의 경험에 따르면, 많은 개발자가 이러한 경우에 슈퍼클래스로 만들어서 공통되는 행위를 추출합니다.
이러한 코드는 간단한 경우에는 문제 없이 동작하지만, 몇 가지 단점이 존재합니다.
- 상속은 하나의 부모 클래스만 가능하기 때문에, 상속을 사용해서 행위를 추출하다 보면, 거대한 BaseXXXX 클래스가 만들게 되고, 굉장히 깊고 복잡한 계층 구조를 만들게 됩니다.
- 상속은 부모 클래스의 모든 것을 가져오게 됩니다. 따라서 불필요한 함수를 상속하는 자식 클래스가 만들어 지게 됩니다. (SOLID — Interface 의존 법칙 위반)
- 상속은 이해하기 어렵습니다. 특히나 계층이 깊어지면, 개발자는 슈퍼의 슈퍼의 슈퍼의 그 슈퍼의 클래스를 찾아다니게 됩니다.
이러한 이유 때문에 다른 대안을 사용하는 것이 좋습니다. 대표적인 대안은 바로 컴포지션입니다. 컴포지션을 사용한다는 것은 객체를 필드로 갖고 있고, 해당 객체의 함수를 호출하는 형태로 사용하는 것을 뜻합니다.
그래서 위의 예시를 컴포지션으로 풀어낸다면 다음과 같습니다.
어떻게 보면 컴포지션으로 처리 했을 때, 코드량이 더 많아졌다고 느낄 수 있습니다. 하지만 위 처럼 했을 때, 무엇보다 관리하기 편리합니다. 더 이상 그레이트 클래스가 만들어질 위험이 매우 줄게됩니다. 또한 불 필요한 함수나 프로퍼티를 상속할 위험도 제거됩니다.
이 뿐만 아니라, 코드를 이해하기 매우 쉬워지므로 객체 관계가 is-a
가 아닌 has-a
라는 관계로 보인다면 컴포지션을 활용 하는 게 좋습니다.
모든 것을 가져올 수 밖에 없는 상속
상속은 슈퍼클래스의 메서드, 제약, 행위 등 모든 것을 가져옵니다. 따라서 상속은 객체의 계층 구조를 나타낼 때 매우 좋은 도구입니다. 하지만 일부분을 재 사용하기 위한 목적으로는 적합하지 않습니다.
간단한 예로, bark
(짖기)와 sniff
(냄새 맡기)라는 함수를 갖는 Dog
라는 클래스가 있다고 가정하겠습니다.
일반적인 경우엔 아주 잘 맞습니다. 그런데 여기에 로봇 강아지를 만들고 싶은데, 로봇 강아지는 bark
(짖기)만 가능하다면 어떻게 해야 할까요?
현재 구조에서 만든다면 sniff
(냄새 맡기)에 대해서 지원하지 않는다는 의미에서 예외를 throw하게 만들어야 할 것입니다.
하지만 이렇게 만든다면 다음과 같은 문제가 발생합니다.
- 불 필요한 인터페이스를 상속 했으므로, SOLID의 인터페이스 분리 원칙에 위배
- 부모 클래스의 행동을 자식 클래스에서 깨버렸으므로, SOLID의 리스코프 치환 원칙에 위배
그렇기에 이러한 경우에는 sniff()
에 대해서 이것이 진짜 Dog
의 공통된 행동이 맞는 것인지 의심 해볼 필요가 있습니다.
이에 따라 sniff()
만 별도의 인터페이스로 분리하거나, 컴포지션을 활용하여 처리 하면 적합한 로직이 될 것입니다.
캡슐화를 깨는 상속
상속을 활용할 때는 외부에서 이를 어떻게 활용하는지도 중요하지만, 내부적으로 이를 어떻게 활용하는지도 중요합니다. 내부적인 구현 방법 변경에 의해서 클래스의 캡슐화가 깨질 수 있기 때문입니다.
CountingSet
이라는 자신에게 추가된 요소의 갯수를 알기 위한 예제를 하나 들도록 하겠습니다.
이 클래스는 문제 없어 보이지만, 실제로 제대로 동작하지 않습니다.
이유는 addAll()
함수 내부에서 add()
를 호출하기 때문입니다.
그렇기에 addAll()
에서 count를 추가해주는 로직을 제거하면 정상작동 됩니다.
하지만 이는 안전하지 않습니다. 자바 라이브러리의 addAll()
이 어떠한 최적화에 의해 add()
를 사용하지 않게 변경 되리라고는 누구도 장담할 수 없습니다.
그렇기 때문에 이러한 경우엔 컴포지션을 사용하는 것이 좋습니다.
하지만 여기서 문제는 다형성이 사라진다는 점 입니다. 그리고 코틀린에서는 by
를 통해 다형성도 지켜내면서 컴포지션을 처리할 방법론을 제공합니다.
물론 다형성이 무조건 필요한 것은 아닙니다. 일반적인 케이스에서는 사실상 다형성이 크게 필요하지 않을 경우가 더 많을 것입니다. 그렇기 때문에 다형성까지 지켜내면서 컴포지션을 처리해야하냐는 상황에 맞게 개발자의 판단에 맡겨야할 문제입니다.
오버라이딩 제한하기
개발자가 상속용으로 설계되지 않은 클래스를 상속하지 못하게 하려면, final
을 사용하면 됩니다. (Kotlin은 default로 final
적용)
그런데 어떤 이유로 클래스 상속은 허용하지만, 특정 메서드를 오버라이드 하지 못하게 하고, 또 특정 메서드를 오버라이드 할 수 있게 만들고 싶을 수 있습니다.
이런 경우엔 오버라이드 할 수 있게 만들고 싶은 함수에 대해 open
키워드를 붙임으로써 오버라이드 하게 만들 수 있습니다. (Kotlin은 default가 final
)
참고로 메서드를 오버라이드 할 때, 자식 클래스에서 해당 메소드에 final
을 붙임으로써 그 자식 클래스의 자식클래스에서 오버라이드를 못하게 막을 수 있습니다.
정리
컴포지션과 상속은 다음과 같은 차이가 있습니다.
- 컴포지션은 더 안전합니다. 다른 클래스의 내부적인 구현에 의존하지 않고, 외부에 관찰되는 동작에만 의존하므로 안전합니다.
- 컴포지션은 더 유연합니다. 상속은 한 클래스만을 대상으로 할 수 있지만, 컴포지션은 여러 클래스를 대상으로 할 수 있습니다. 상속은 모든 것을 받지만, 컴포지션은 필요한 것만 받을 수 있습니다.
- 컴포지션은 생각보다 번거롭습니다. 컴포지션은 객체를 명시적으로 사용해야 하므로, 대상 클래스에 일부 기능을 추가할 때, 이를 포함하는 객체의 코드를 변경해야 합니다. 그래서 상속을 사용하는 경우보다 코드 수정이 더 많을 수 있습니다.
- 상속은 다형성을 활용할 수 있습니다. 이것은 양날의 검입니다. Animal을 상속해서 Dog를 만들었다면, 굉장히 편리하게 활용될 수 있지만, 이는 코드에 제한을 겁니다. Dog는 반드시 Animal로 동작해야 하는 경우에만 사용해야 합니다.
- 상속은
is-a
관계가 명확할 때만 사용해야 합니다. 앞서 이유들 처럼 코드에 제한을 걸기 때문에, 변경에 대응하기 어렵습니다.is-a
관계가 명확하지 않다면 컴포지션으로 처리해야합니다.
2. 데이터 집합 표현에 data 클래스를 사용하라
때로는 데이터들을 한꺼번에 전달해야 할 때가 있습니다. 일반적으로 이러한 상황에 다음과 같은 클래스를 사용합니다.
data class
를 사용하면 다음과 같은 함수가 자동으로 생성됩니다.
toString()
equals()
와hashCode()
copy()
component
N(component1, component2 등)
toString()은 클래스의 이름과 기본 생성자 형태로 모든 프로퍼티와 값을 출력해줍니다. 이는 로그를 출력할 때나, 디버그 할 때 유용합니다
println(player) // Player(id=0, name=Gecko, point=9999 )
equals()는 기본 생성자의 프로퍼티가 같은지를 확인 해줍니다. 그리고 hashCode()는 equals와 같은 결과를 냅니다.
player == Player(0, “Gecko”, 9999) // true
player == Player(0 “Ross”, 9999) // false
copy()는 Immutable한 데이터 클래스를 만들 때 편리합니다. copy()
는 기본 생성자 프로퍼티가 같은 새로운 객체로 복제합니다. 그리고 copy()
는 얕은 복사이므로, 사용 시에 이점을 기억하고 있어야 합니다.
val newObj = player.copy(name = “Thor”)
println(newObj) // Player(id=0, name=Thor, points=9999)
componentN( component1, component2 )은 위치를 기반으로 객체를 해제할 수 있게 해줍니다.
val (id, name, pts) = player//컴파일 후
val id : Int = player.component1()
val name : Int = player.component2()
val pts : Int = player.component3()
이렇게 위치를 기반으로 객체를 해제하는 것은 장점도 있고, 단점도 있습니다.
가장 큰 장점은 변수의 이름을 원하는 대로 지정할 수 있다는 것입니다. 또한 component
N 함수만 있다면, List
와 Map.Entry
등의 원하는 형태로도 객체를 해제 할 수 있습니다.
람다 파라미터에는 객체 해제를 하지 않는 것이 좋습니다. 읽는 사람에게 있어 혼란을 주게 될 여지가 매우 높습니다.
튜플 대신 데이터 클래스 사용하기
데이터 클래스는 튜플보다 많은 것을 제공합니다. 코틀린의 튜플은 Serializable
을 기반으로 만들어지며, toString()
을 사용할 수 있는 제네릭 데이터 클래스입니다.
튜플은 데이터 클래스와 같은 역할을 하지만, 훨씬 가독성이 나쁩니다. 튜플만 보고는 어떤 타입을 나타내는지 예측할 수 없습니다.
그래서 튜플을 사용할 땐 다음과 같은 상황에서만 사용하는 것이 좋습니다.
- 값에 간단하게 이름을 붙일 때
- 표준 라이브러리에서 볼 수 있는 것 처럼 미리 알 수 없는 집합을 표현할 때
이 경우를 제외하면 무조건 데이터 클래스를 사용하는 것이 좋습니다.
3. 태그 클래스보다는 sealed class를 사용하라
태그 클래스란 상수로 모드를 가진 클래스를 뜻합니다.
하지만 이러한 접근 방법에는 많은 단점이 있습니다.
- 한 클래스에서 여러 모드의 처리를 다 처리 하므로, 단일 책임 원칙에 위배됩니다.
- 여러 목적으로 사용해야 하므로, 프로퍼티가 일관 적이지 않게 사용될 수 있습니다. 예제만 보더라도 list 비교와 value 비교를 하기 위한 프로퍼티가 다름을 알 수 있습니다.
- 팩토리 메서드를 사용해야 하는 경우가 많습니다. 그렇지 않으면 개발자의 실수로 인해 잘못 생성될 여지가 높습니다.
그래서 코틀린은 일반적으로 태그 클래스보다 sealed
클래스를 더 많이 사용합니다. 한 클래스에 여러 모드를 만드는 방법 대신에, 각각의 모드를 여러 클래스로 만들고 타입 시스템과 다양성을 활용하는 것입니다.
sealed 클래스
반드시 sealed
클래스를 사용해야 하는 것은 아닙니다. 위의 예시는 abstract
클래스로 처리할 수 있지만 다른 모듈, 다른 파일에서 서브클래스를 만들 수 있기 때문에 타입 안정성이 보장되지 않습니다.
그러나 sealed
클래스는 외부 파일에서 서브 클래스를 생성할 수 없기 때문에 타입 안정성이 보장되어 when
을 사용할 때 else
를 정의할 필요 없습니다.
sealed class는 Kotlin 1.5 이전에는 같은 파일내에 서브 클래스들이 위치해야 했으나, 1.5 부터 같은 모듈, 같은 패키지에 위치하면 동작 되도록 변경 되었습니다.
그렇기에 타입에 따라 상태 혹은 행동을 변경 시키고자 할 때, sealed
클래스로 정의하는 것이 좋습니다. abstract
는 상속과 관련된 설계를 할 때 사용합니다 (Base 정의 — Template method pattern)
또한 이러한 특성 덕분에, State pattern을 구현 할 때 sealed
class를 활용하는 것이 좋습니다.
Effective Kotlin Chapter 6. Class design 1부에 대한 요약은 여기까지 입니다.
다음 게시글에서 Chapter 6. Class design 2부에 대한 요약으로 작성하도록 하겠습니다.