티스토리 뷰

oop

[엘레강트 오브젝트] 1장 출생

hsm914 2024. 2. 25. 18:12
728x90

 

 

들어가며

엘레강트 오브젝트의 1장은 출생이다.

이 책에서는 객체를 살아있는 유기체라고 여기는데,

이러한 객체를 대상으로 출생, 학교생활, 회사생활, 은퇴의 챕터로 이루어져 있다.

1장의 제목이 출생인 만큼, 객체의 이름과 생성에 대해 다룬다.

 

 

이 책의 목표는 객체와 객체의 역할을 이해하여 유지보수성을 향상시키는 것이다.

그에 따르는 장점은 아래와 같다.

  • 유지보수성 향상
  • 코드 길이 감소
  • 이해하기 쉬움
  • 응집도 높아짐

 

if (price < 100) {
    val extra = Cash(5)
    price.add(extra)
}

먼저 객체는 자신의 가시성 범위 내에서 살아간다.

예를 들어 위 코드에서 price는 extra 객체의 외부에 존재하고, 숫자 5는 객체 내부에 존재한다.

 

 

 

 

1. -er로 끝나는 이름을 사용하지 말 것

 

클래스

객체를 보관하는 웨어하우스(warehouse)로 바라보야아 한다.

필요할 때 객체를 꺼내고 더이상 필요하지 않을 때 객체를 반환한다.

클래스를 객체의 능동적인 관리자로 생각하라.

 

클래스는 객체의 템플릿이 아니다.

즉, 단순히 필요할 때 어딘가에서 복사하는, 수동적인 코드 덩어리가 아니다.

 

 

클래스 이름이 잘못된 예제
class CashFormatter(private val dollars: Int) {
    fun format(): String {
        return "$ %d".format(dollars)
    }
}

클래스의 객체들이 무엇을 하고 있는지(doing)를 파악하고 기능(functionality)에 기반하여 이름을 지어야 한다.

클래스의 이름은 객체가 노출하고 있는 기능에 기반하면 안 된다.

즉, 무엇을 하는지(what he dose)가 아닌 무엇인지(what he is)에 기반해야 한다.

 

 

클래스 이름이 올바른 예제
class USDCash(private val dollars: Int) {
    fun usd(): String {
        return "$ %d".format(dollars)
    }
}

객체는 자신의 속성(attribute)이 아닌 역량(capability)으로 특징지어야 한다.

 

객체는 객체의 외부 세계와 내부 세계를 이어주는 연결장치(connector)가 아니다.

또한 내부에 캡슐화된 데이터를 다루기 위한 절차의 집합이 아니다.

객체는 캡슐화된 데이터의 대표자(representative)이다.

연결장치는 단순히 정보를 전달한 뿐이지만, 대표자는 자립적으로 스스로 결정을 내리고 행동할 수 있다.

 

 

접미사 -er로 끝나는 클래스 이름은 어떤 데이터를 다루는 절차들의 집합이라는 의미를 담고 있다.

Manager, Controller, Handler, Reader, Converter, Validator, Listener, Dispatcher, Observer, Decoder 등..

이런 방식의 이름들을 무수히 많이 봐왔지만, 이 이름들은 전부 다 틀렸다.

 

* 예외적으로 Computer와 User처럼 의미가 굳어진 경우도 있다.

(computer: 전자 기기, user: 소프트웨어와 상호 작용하는 사람)

 

 

그렇다면 올바른 클래스 이름은 어떻게 지어야 할까?

 

클래스의 객체들이 무엇을 캡슐화할 것인지를 관찰하고 이 요소들에 붙일 적합한 이름을 찾아라.

 

예를 들어 어떤 숫자 리스트가 존재하고, 이 리스트로부터 소수를 찾는 알고리즘을 만든다고 가정해보자.

오직 소수로만 구성된 리스트를 얻고자 한다면, 클래스 이름을 Primer, PrimeFinder, PrimeCooser 등으로 짓기 보다는, 소수들을 대표하는 이름인 PrimeNumbers라고 짓는다.

여기서 PrimeNumers 클래스가 숫자 리스트를 캡슐화하는 동안, 외부에서는 이 객체 내부의 리스트를 처리하거나 조회하도록 놔둬서는 안된다.

외부에서 어떤 일을 해야 한다면 객체에게 그 일을 하도록 요청하고, 수신한 요청을 처리하기 위해 객체 스스로 무엇을 할지 결정해야 한다.

즉, PrimeNumber는 리스트를 처리하기 위한 메서드의 집합이 아닌, 숫자들의 리스트 그 자체가 되는 것이다.

 

클래스는 객체의 내부와 외부의 연결장치가 아닌 캡슐화된 데이터의 대표자다.
클래스의 이름을 붙일 때는 무엇을 하는지(what he does)가 아니라 무엇인지(what he is)를 생각하자.

 

 

 

 

2. 생성자 하나를 주 생성자로 만들 것

 

생성자는 새로운 객체에 대한 진입점이다.

몇 개의 인자를 전달받아 이들을 이용해 어떤 일을 수행한 후, 객체가 자신의 의무를 수행할 수 있도록 준비한다.

 

Cash(30)
Cash("$29.95")
Cash(29.95f)
Cash(29.95, "USD")
많은 수의 생성자와 적은 수의 메서드

 

올바르게 클래스를 설계한다면, 클래스는 많은 수의 생성자와 적은 수의 메서드를 포함하여 응집도가 높고 견고해진다.

생성자가 많아질 수록 클라이언트가 클래스를 더 유연하게 사용할 수 있다.

이러한 유연성 덕분에 작성해야 하는 코드는 적어지고, 중복 코드의 양도 줄어든다.

하지만 메서드가 많아질 수록 클래스를 사용하기는 더 어려워지고, SRP를 위반한다.

 

 

하나의 주 생성자와 다수의 부 생성자

 

생성자의 주된 임무는 제공된 인자를 사용해서 캡슐화된 프로퍼티를 초기화하는 것이다.

초기화 로직을 오직 하나의 생성자(주 생성자)에만 위치시키고,

다른 생성자(부 생성자)는 이러한 주 생성자를 호출하도록 만들어라.

class Cash(private val dollars: Int) { // 주 생성자
    constructor(dlr: Double) : this(dlr.toInt()) // 부 생성자
    
    constructor(dlr: String) : this(Cash.parse(dlr)) // 부 생성자
}

위 예제에서 주 생성자는 인자로 전달된 정수를 이용해 프로퍼티를 초기화만 한다.

부 생성자에서 파싱이나 변환을 통해 주 생성자로 전달할 정수형 인자를 준비한다.

 

"하나의 주 생성자와 다수의 부 생성자" 원칙의 요점은,

중복 코드를 방지하고 설계를 더 간결하게 만들어 유지보수성이 향상된다는 것이다.

주 생성자에서만 프로퍼티를 초기화해야 한다는 원칙을 따르지 않고 구현한 코드를 보자.

 

class Cash {
    private int dollars;
    
    Cash(float dlr) {
        this.dollars = (int) dlr;
    }
    
    Cash(String dlr) {
        this.dollars = Cash.parse(dlr);
    }

    Cash(int dlr) {
        this.dollars = dlr;
    }
}

(코틀린에서는 부 생성자들이 주 생성자를 무조건 호출해야 하므로 이 예제에서는 자바 언어를 사용했다.)

여기에서 dollars의 값이 항상 양수여야 한다고 가정해보자.

이를 보장하기 위해서는 세 개의 생성자 안에 일일이 유효성 검사 로직을 작성해야 한다.

하지만 하나의 주 생성자와 두 개의 부 생성자를 구현한 이전 예제에서는 유효성 검사 로직을 주생성자 한 장소에만 추가하면 된다.

 

하나의 주 생성자와 여러 개의 부 생성자를 생성하라.
주 생성자에는 프로퍼티를 전달받은 인자로 초기화만 하고,
부 생성자에서는 전달받은 인자를 준비(포맷팅, 파싱, 변환)하여 주 생성자를 호출한다.

 

 

 

 

3. 생성자에 코드를 넣지 말 것

 

주 생성자는 프로퍼티를 초기화하는 유일한 장소이기 때문에, 제공되는 인자들이 완전해야 한다.

여기서 완전하다는 것은 어떤 것도 누락하지 않고 중복되는 정보도 없다는 것을 의미한다.

 

이때 이 인자들로 어떤 일을 할 수 있고 어떤 일을 할 수 없을까? 즉, 인자를 어떻게 다뤄야 할까?

class Cash {
    private val dollars: Int
    
    constructor(dlr: String) {
        this.dollars = dlr.toInt()
    }
}

이 예제에서 클래스가 캡슐화하고 있는 것은 정수 타입이지만, 생성자의 파라미터 타입은 문자열이다.

즉, 예제에서는 인자로 전달된 문자열을 정수로 변환하고 있다. 이 코드는 명확한 방법이 아니다.

 

객체 초기화에는 코드가 없어야하고, 인자를 건드려서는 안된다.

필요한 경우 인자를 다른 타입의 객체로 감싸거나 원시 형태로 캡슐화해야 한다.

 

 

코드가 없는(code-free) 생성자
class Cash(private val dollars: Number) {
    constructor(dlr: String) : this(StringAsInteger(dlr))
}

class StringAsInteger(private val source: String) : Number {
    fun intValue(): Int {
        return source.toInt()
    }
}

인자로 전달된 문자열을 건드리지 않고 동일한 작업을 수행하도록 수정한 예제다.

이전 예제와 달리, 객체를 실제로 사용할 때까지 문자열을 정수로 변환하는 작업을 연기한다.

 

val five = Cash("5")

겉으로는 두 예제에서 인스턴스를 생성하는 과정이 동일하게 보인다.

하지만 첫 번째 예제는 숫자 5를 캡슐화하지만, 두 번째 예제는 Number처럼 보이는 StringAsInteger를 캡슐화한다.

 

(진정한 객체지향에서 인스턴스화는 더 작은 객체들을 조합(compose)해서 더 큰 객체를 만들어낼 때 일어난다.

조합이 필요한 유일한 이유는 새로운 계약을 준수하는 새로운 객체가 필요하기 때문이다.)

 

five 객체를 생성했지만 아직 필요한 작업(문자열을 정수로 변환)을 수행하도록 요청하지는 않았다.

 

객체를 사용하기 위한 단계

 

1) 객체를 인스턴스화

2) 객체가 우리를 위해 작업하도록 요청

 

이 두 단계가 겹쳐서는 안되며,

생성자는 어떤 일을 수행하는 곳이 아니기 때문에 생성자 내부에서 어떤 작업을 하도록 요청해서는 안된다.

생성자에는 코드가 없이 오직 할당문만 존재해야 한다. 실행 속도를 더 빠르게 만들 수 있기 때문이다.

 

생성자에 코드가 없는, 두 번째 예제를 다시 보자.

class StringAsInteger(private val source: String) : Number {
    fun intValue(): Int {
        return source.toInt()
    }
}

이 코드는 intValue()를 호출할 때마다 매번 문자열을 정수로 변환한다.

 

val number: Number = StringAsInteger("123")
number.intValue()
number.intValue()

예를 들어 위와 같은 코드라면 변환 작업은 두 번 수행된다.

어떻게 이 방식이 생성자에서 변환 작업을 바로 수행하는 것보다 빠를 수 있을까?

 

class StringAsInteger : Number {
    private val num: Int
    
    constructor(txt: String) {
        this.num = txt.toInt()
    }
    
    fun intValue(): Int {
        return num
    }
}

생성자에서 바로 변환 작업을 수행하면, 변환을 초기화 동안만 단 한 번 수행하기 때문에 실제로 더 효율적일 수 있다.

하지만 생성자에서 직접 변환을 수행하는 이 예제는 최적화가 불가능하다.

객체를 만들 때마다 매번 변환이 수행되기 때문에 intValue()를 호출할 필요가 없는 경우에도 변환을 위한 시간이 소모된다.

 

즉, 우리가 요청하지 않더라도 변환은 항상 실행된다.

반대로 생성자에서는 인자를 그대로 캡슐화하고 요청 시에만 변환한다면, 객체는 자신의 변환 시점을 결정할 수 있다.

 

class StringAsInteger(private val source: String) : Number {
    private val cached = mutableListOf<Int>()
    
    fun intValue(): Int {
        if (cached.isEmpty()) {
            cached.add(source.toInt())
        }
        
        return cached.first()
    }
}

변환이 여러 번 수행되기를 원하지 않는다면 위 예제처럼 "캐싱 데코레이터"를 사용할 수 있다.

 

 

lazy한 객체

 

생성자에 코드를 넣지 않으면,

객체를 인스턴스화하는 동안은 객체를 만들기 위한 일 이외의 다른 어떠한 일을 수행하지 않는다.

그 외의 일들은 객체의 메서드가 수행한다.

 

즉, 사용자가 쉽게 제어할 수 있는 투명한 객체를 만들 수 있다. 

객체를 이해하고 재사용하기도 쉬우며,

객체는 요청을 받을 때만 행동하고 요청을 받기 전까지는 어떠한 일도 하지 않는다. (lazy하다!)

 

 

추상적이지만 생성자에는 코드가 없어야하는 이유가 명확히 드러나는 예제를 보자.

val app = App(Data(), Screen())
app.run()

 

app을 생성하는 동안 app은 어떠한 일도 처리하지 않고, 내부의 객체들을 생성하고 준비시킨다.

그 후 run() 메서드를 호출함으로서 객체들이 적절한 작업을 수행한다.

 

하지만 객체를 인스턴스화한 이후 단 한번만 수행된다는 사실이 아주 명확한 경우도 있다.

이 경우는 생성자에서 로직을 포함시켜도 될까? 

책에서는 일관성을 헤친다는 이유로 반대한다.

또한 생성자가 어떤 일을 처리하고 있다면 리팩터링하기가 어려울 것이다.

 

생성자에서는 코드가 없어야 한다.
객체를 만들기 위한 일 이외의 다른 어떠한 일을 수행하지 않으며,
그 외의 일들은 객체의 메서드가 수행한다.

 

 

 

 

마치며

엘레강트 오브젝트는 객사오를 쓰신 조영호님이 번역한 책이다.

객사오를 아직 제대로 읽기 시작하진 않았지만, 코드없이 추상적으로 쓰여진 느낌을 많이 받았다.

명확한 걸 좋아하는 나에게 객사오가 잘 맞을까 하는 걱정을 하곤 했는데,

엘레강트 오브젝트는 표현들이 아주 명확하게 적혀져있어 좋았다.

 

명확하다고 해서 이 책을 전적으로 신뢰하거나 내 코드에 100%로 적용할 수는 없다고 생각하지만,

우테코 미션을 하다가 확신이 안 서는 부분들을 어느정도 해결할 수 있었다.

사실 금요일에 남아서 공부하는데 ㅋㅋ 갑자기 제이슨이 나한테 스캔본을 주고가셨다...

지금은 절판이라 제이슨이 주신 1장만 읽을 수 있는데, 너무 재밌게 읽어서 뒷내용을 더 읽고 싶어졌다. 절판이라 아쉽..

 

728x90
250x250
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
TAG
more
«   2024/10   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30 31
글 보관함