엘라강트 오브젝트

review, share · 2024-8-12

← 리스트로

엘레강트 오브젝트

순혈 객체지향개발자가 알려주는 우아하게 객체지향 코딩하는 방법

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

클래스는 객체의 템플릿이 아닌 객체의능동적인 관리자로 생각해야한다.

클래스는 객체의 웨어하우스의 개념으로 바라봐야 한다. 클래스는 객체의 생명 주기를 관리한다.

객체 웨어하우스의 관점에서 생성자 오버로딩은 바람직한 설계다.

class Shapes {
    public Shape make(String name) {
        if(name.equals("Circle")) {
            return new Circle();
        }
        if(name.equals("rectangle")) {
            return new Rectangle();
        }
        throw new IllegalArgumentException('not found');
    }
}

클래스 이름에 -er을 붙이면 안된다

객체는 행위가 중요한게 아니라 그 객체가 무엇인지가 중요하다. 내가 무엇을 하는지와 내가 무엇인지는 다르다.

주 생성자와 부 생성자를 나누어 생성하고 주 생성자에는 코드를 넣지 마라

주 생성자에 코드를 넣지 말고, 필요한 경우에는 생성자 오버로딩을 사용해라. 오버로딩 기능이 없는 언어는 차라리 팩토리 패턴을 사용해라.

객체를 생성할때마다 실행되는 로직이 줄어든다. 그 밖에 특별한 이유는 없다, 그냥 그러면 유연하게 최적화할 수 있다. 프로그래머라면 직감적으로 동의할거라고 믿는다.

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

적게 캡슐화하라, 객체의 상태는 4개 이하여야 한다

객체의 유일성을 판단할때 판단 지표가 4개 이상이라면 너무 혼잡해진다.

예를 들어 사람의 고유한 객체는 이름과 생년월일만 알면 유일성을 판단할 수 있다. 4개 이상의 유일성 판단 지표를 가진 객체는 세상에 없다.

만약 4개 이상의 속성을 가졌다면 그룹화하여 묶어서 새로운 하위 객체로 표현하라. 한 객체는 다른 특정 객체를 포함하게 되며 객체의 트리 형태로 표현 된다.

하지만 최소한 하나는 캡슐화 하라. 아무런 속성도 가지지 않은 객체는 없다.

인터페이스는 일종의 계약서다

특정 객체가 여러 다른 객체에서 부작용없이 효과적으로 재사용되려면 계약서가 있어야 한다. 내가 사용하고 있던 객체의 메서드가 어느날 갑자기 말도없이 사라진다고 생각해봐라. 객체의 사용자에게 특정 객체의 기능 제공을 미리 알리고 함부로 변경하지 않을것이라고 약속받아야 한다. 그 약속이 바로 인터페이스다.

물론 한군데서뿐이 안쓰이는 객체에 인터페이스는 필요없다. 하지만 여러 군대서 쓰이는 중요한 객체라면 계약서가 반드시 필요하다(객체 지향이라는 개념 자체가 객체간의 관계에 의해 동작하므로 복잡해질수 뿐이 없다, 인터페이스는 복잡성을 줄이기위한 최소한의 방어장치다).

메서드의 네이밍시 빌더이름은 명사로 조정자 이름은 동사로 지어라

그리고 빌더 중에서도 boolean을 반환하는건 형용사로 이름지어라

빌더는 값을 반환하고 조정자는 값을 조작한다. OOP는 개념을 고립시켜서 복잡성을 낮추는 방법이다. 이름이 명확하면 코드의 복잡성이 낮아진다.

퍼블릭 상수를 추가하지 말고 그 기능일 제공하는 새로운 클래스를 만들어라.

상수 대신에 작은 클래스를 많이 만드는게 더 우아하게 객체지향 코딩을 할수 있는 방법이다. 퍼블릭 상수는 코드의 동작을 모호하고 혼란스럽게 한다.

여러 객체가 하나의 상수를 사용하면 값을 공유하므로 코드의 결합도가 늘어난다. 하나의 상수를 고치게 되면 그 상수를 사용하는 모든 객체를 다 수정해야 될지도 모른다. 객체지향 코딩의 개념과는 맞지 않는 방법이다.

차라리 그 상수 값을 표현할수 있는 새로운 클래스를 만들어라. 문장 끝에개행을 붙여주기 위해 개행문자를 상수로 만들지 말고, 문자 끝에 개행을 붙이는 객체를 만들어서 사용하자.

// 클래스로 만들어 놓으면 사용하는 객체에서는 인자로 주입받아서 사용하면 의존성 역전을 이용하여 결합도를 낮출 수 있다.
class EOLString {
    private final String origin;
    EOLString(String src) {
        this.origin = src;
    }
    toString() {
        return String.format("%s\r\n", origin);
    }
}

불변객체를 만들어라

세상의 객체중에 가변객체는 없다. 전부다 불변이다. 예를 들어 만약 내가 사는집 주소가 바뀐다고 해도 내가 바뀌는게 아니다. 나는 변하지 않는다. 이렇게 생각한다면 모든 객체를 불변으로 만드는게 이상하지 않다.

가변객체를 사용하면 코드가 번잡해진다. 차라리 뭔가 변하면 새로운 객체를 새로 만들어라. 사용할수 있는 모든 경우에 불변 객체를 사용해라.

  1. 불변객체를 사용하면 가변객체보다 실패 원자성을 쉽게 얻을 수 있다(스레드에 안전해진다)
  2. 불변객체는 널 참조를 만들 가능성을 없애버린다
  3. 원자성이 강해지기 때문에 스레드에 안전해진다.
  4. 클래스를 작게 유지 가능해진다. (불변객체를 크게 만드는건 쉬운일이 아니다. 불가능하다.)

좋은 아키텍트가 따로 있는게 아니다. 모든 클래스의 라인을 300자 내외로 유지 한다면 좋은 아키텍터라고 말 할 수 있다. 아마 정상적인 사람이라면 인자를 10개씩이나 받는 불변 객체를 만들지는 않을것이다.

문서를 작성하는대신 테스트를 만들어라

테스트를 마치 사용 설명서인듯 만들어라.

Mocking 도구를 사용하지 말아라 Fake 객체를 사용하라

클래스 자체에 페이크 객체를 만들수 있도록 제공하라.

객체를 테스트 하다보면 Mocking이 불가피할때가 많다. 어떤 객체를 테스트 하기 위해 그 객체가 가진 하위 객체들을 전부 만들어 주기는 너무 힘들다. 복잡한 프로그램일수록 더 그렇다. 하지만 하위 객체를 Mocking 하려면 하위객체가 가지는 모든 기능을 전부 알아내서 처리해줘야 하는데 객체 지향의 은닉화에 위배되며 객체 지향의 이점을 활용하지도 못하게 된다.

하지만 하위 객체를 개발할때 부터 해당 클래스 개발자가 Fake 기능을 미리 만들어 준다면, 이로 인한 모든 부작용이 해소될 것이다.

예를 들어 Exchange를 사용하는 어떤 객체를 테스트 하기위해 아래처럼 Exchange의 모든 입출력을 다시 구현해줘야 하는 불상사가 나타난다.

const exchange = Mocking().call('rate').return(() => 1.2345) // 모킹객체를 만들어줌

const testTargetObj = new SomeTargetClass(const);
testTargetObj.someTestMethod();

하지만 Exchange 제작 시점에서 Fake 기능을 미리 만들어 놨다면 그럴 필요없이 Fake 객체를 바로 만들어서 사용해주면 된다.

const testTargetObj = new SomeTargetClass(new Exchange.Fake());
testTargetObj.someTestMethod();

interface Exchange {
    float rate(String target);
    float rate(String origin, String target);
    final class Fake implements Exchange {
        @Override
        float rate(String target) {
            return this.rate("USD", target);
        }
        float rate(String origin, String target) {
            return 1.2345;
        }
    }
}

인터페이스를 작게 유지하라

객체에 많은 기능이 필요하다면 스마트 클래스를 구현하라.

스마트 클래스란 인터페이스안에 이중 클래스를 작성하는 것이다.

스마트클래스는 인터페이스를 작게 유지하면서, 기능을 재활용하여 확장할수 있다.

5개 이하의 퍼블릭 메서드만 구현하라

작은 클래스가 더 우아하고 유지보수 하기 쉽다.

interface Exchange {
    float rate(String origin, String target);
    final class Smart {
        private final Exchange origin;
        public float toUsd(String source) {
            return this.origin.rate(source, "USD");
        }
    }
}
// Exchange를 구현할 구현자는 계약서에 있는 rate 기능만 구현하면된다. 부가적인 기능은 이미 Smart클래스에 구현되어 있다.
float rate = new Exchange.Smart(new NYSE()).toUsd("EUR");

정적메서드는 사용하지 마라 유지보수성을 낮춘다

정적메서드는 다형성의 이득이 제한된다. 오버로딩도 안되고 오버라이딩도 안되며. 객체에서 사용되는 하위 객체를 변경하여 사용하는 일도 불가능해진다.

정적메서드는 단지 특정 기능의 모음에 가깝기 때문에 객체지향적 맥락에서의 벗어나는 명령적 코드가 된다.

정작메서드는 상태가 없기 때문에 싱글톤과는 틀리게 인스턴스를 교체할수도 없다

정적메서드보다 싱글톤이 조금이라도 나은점은 싱글톤은 인스턴스를 교체할수 있다는 것이다.

하지만 실글톤 또한 객체지향의 세계에서는 안티패턴이다. 전역변수나 마찬가지다.

이상적인 객체지향은 함수형과 비슷하다

객체 지향의 가장 이상적인 사용형태는 어쩌면 함수형과 가장 비슷하다. 하지만 함수형은 함수만 조합하지만 객체지향은 객체와 메서드도 조합가능하다. 객체를 포함하는 함수형은 사실 함수를 포함하는 객체지향언어에 가깝다.

데코레이터는 객체를 감싸는 객체일 뿐이다. 조합가능한 데코레이터를 사용하는건 바람직하며 객체지향적인 행태이다.

미래의 언어에서 of for while switch 등의 명령형 지시어들은 사라질 것이다. 지시어는 조합이 불가능하다.

정적 메서드 또한 조합이 불가능 하다. 데코리이터나 메서드 체이닝 같은 합성이 불가능하게 된다.

Null을 사용하지마라

null을 예외처리하는것은 객체를 신뢰하지 않고 무시하게 되므로 예의없는 행위다. null 대신 null object를 사용하며뉴된다. 만약 User를 반환하는 빌더 메서드에 반환할 User가 없다면 User 인터페이스를 상속받은 NullUser 객체를 리턴하면 된다.

Getter 와 Setter를 만들지 마라 그런게 있는건 객체가 아니라 자료구조다.

게터 세터를 만드는건 객체의 옷을 벗겨두는 행위이며 창피한 행위이다. 객체에 대한 예의가 아니다.

의존성 역전을 위해 클래스 내부에서 new 연산자를 사용하면 안된다. 인자로 받아야한다. 부ctor에서만 new 연산자 사용을 허용해야 한다.

의존성 주입과 의존관계 역전이라는 말이 우리를 헷갈리고 어렵게 만든다. 하지만 이게 의존성 주입에 대해 우리가 알아야할 전부다.

class Cash {
    private final int dollars;
    private final Exchange exchange;

    Cash(int value) { // 부 생성자
        this(value, new NYSE());
    }
    Cash(int value, Exchange, exch) {
        this.dollars = value;
        this.exchange = exch;
    }

    public int euro() {
        return this.exchange.rate("USE", "EUR") * this.dollars;
    }
}

인트로스팩션과 캐스팅을 피하세요

instance of 는 객체를 차별하기 때문에 좋지 않은 기능이다. 객체의 사용자는 그 객체의 내부 구조를 알 권한이 없다. 객체에게 그냥 맡기면 된다.

instance of를 사용한다는 것 자체가 알아야하는 객체의 수가 많다는걸 의미하기 때문에 유지보수성에도 좋지않다.

오버로딩을 이용하라

인트로스팩션을 피하고 차라리 오버로딩을 활용하라, 사용자가 사용법에 대해 알아야 할 사실이 줄어드므로 여러모로 우아하며 이롭다.

아래처럼 인트로스팩션과 캐스팅의 사용하고 싶은 유혹을 피하고

public <T> int size(Iterable<T> items) {
    if (tems instanceof Collection) {
        return Collection.class.cast(items).size();
    }
    int size = 0;
    for (...) {
        ++size;
    }
    return size;
}

아래처럼 오버로딩을 사용

public <T> int size(Collection<T> items) {
    return items.size();
}

public <T> int size(Iterable<T> items) {
    int size = 0;
    for (...) {
        ++size;
    }
    return size;
}

null을 절대 반환하지 말라

널을 반환하는 메서드를 가진 객체는 신뢰할수 없는 객체다.

반환값을 검사하는갓은 신뢰하지 못한다는 신호다. 일을 맡겼으면 신뢰해야되는데 null을 허용하므로서 신뢰하지 못하게 된다.

모든 객체에게 일을 맡기고, 결과를 전달받을 때마다 잘 했는지 검사한다면 모든 일이 엉망이 될것이다.

빠른실패의원칙을 기억하라, Null을 반환하는것 보다는 빠른 시점에 에러를 던지는게 좋다.

예외처리

체크예외외 비체크예외중 체크예외일 경우에는 에러를 다시 던져라(모든언어가 체크예외를 지원하진 않는다)

예외처리에는 빠른실패 전략과 안전한 실패 전략이 있다. 안전한 실패 전략은 에러를 catch하여 에러가 안난것 처럼 감추는 것이다. 안전한 실패전략은 문제를 더 키운다. 꼭필요한 경우가 아니라면 예외를 catch 하지 마라.

모든 catch문에는 납득할만한 이유가 있어야하며, 그렇지 않다면 그냥 에러가 발생하는게 좋다. 잡아서 로깅하겠다는 생각은 좋은 생각이 아니다. 대부분의 경우 로그를 잘 확인 안하며, 확인한다고 해도 대수롭지 넘긴다.

체크예외를 지원하는 언어라면 에러는 체이닝하여 다시 던져라

체크 예외를 사용하며 적절히 복구할 필요가 있을경우(대부분은 필요없다), 에러를 catch하여 체이닝하여 더 고차원의 함수에 다시 던져라. 이상적인 설계에서 하나의 앱 진입점에는 하나의 캐치문만 존재해야 한다. 위로 던지다가 적절한 처리시점에서 모든 에러를 한거번에 복구하라.

Aop를 적극 활용해라 예외 에러 처리로 코드를 뒤범벅으로 만들지 말자

예외타입은 하나만 있으면 된다. 왜냐하면 에러 복구는 체이닝 되어 다시 던져지며 한군데에서 한꺼번에 복구될것이기 때문이다.

상속을 사용하지 마라

모든 클래스는 abstract 이거나 final 인게 좋다. 그냥 미완성이라서 완성시켜야 하던지 아니면 완성되어있던지. 확장을 위한 상속은 코드를 더 복잡하게 만드는 안티패턴이라는게 전문가들 사이에 정설이다.

상속은 가상메소드라고 불리는 현상을 가능하게하여 문제를 복잡하게 한다(상속의 상속의 상속의 상속은 모든 부모 클래스들의 구현이 어떻게 되어 있는지 전부 알아야 하므로 복잡성이 커진다.)

클래스를 abstract 나 final로 제한한다면 복잡성이 해결된다.

상속은 대부분의 경우 필요하지 않으며 꼭 필요한 경우는 abstract 클래스의 경우에만 가능하게 하자(확장이 아닌 정제인 경우에만 상속을 사용)