Java

엘레강트 오브젝트 내용 정리

이 글은 엘레강트 오브젝트: 새로운 관점에서 바라본 객체지향을 읽고 정리한 내용입니다.
올해 중반 스터디로 한번 정리했던 내용을, 다시 한번 정리합니다. 

1장 출생

1.1 -er로 끝나는 이름을 사용하지 마세요

클래스는 객체의 능동적인 관리자이다. (클래스는 객체의 어머니이다.)

클래스 이름을 짓는 적절한 방법은 객체가 노출하고 있는 기능에 기반한 것이 아닌, 클래스가 무엇인지에 기반해야한다. 예를 들어, CashFormatter가 아닌 Cash라고 명명해야한다. 즉, 객체는 그의 capability로 특징지어져야한다.

여기서 -er로 끝나는 이름을 짓는다면, 그것은 바로 기능에 기반하여 클래스를 명명했다고 볼 수 있다. ex. Manager, Controller, Handler, Converter 이와 상반된 명명으로는 Target, Content, EncodedText 등이 있다.

1.2 생성자 하나를 주 생성자로 만드세요

하나의 클래스는 2~3개의 메서드와 5~10개의 생성자를 포함하는 것이 적당하다. 응집도가 높고 견고한 클래스에는 적은 수의 메서드와 상대적으로 더 많은 수의 생성자가 존재한다. 생성자가 많을수록 클래스는 더 개선되고, 사용자 입장에서 더 편하게 해당 클래스를 사용할 수 있다. 메서드가 많아지면 클래스의 초점이 흐려지고, 단일 책임 원칙을 위반한다. 생성자가 많아지면 유연성이 향상된다.

new Cash(30);
new Cash("$30");
new Cash("30d");
new Cash("30", "USD");

생성자의 주된 작업은 제공된 인자를 사용해 캡슐화하고 필드를 초기화하는 일이다.

이러한 초기화 로직을 단 하나의 생성자에만 위치시키고, 주생성자라고 부르기를 권장하며, 부 생성자라고 부르는 다른 생성자들이 이 주 생성자를 호출하도록 만들자.

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

1.3 생성자에 코드를 넣지 마세요

생성자에서 객체를 초기화할 때에는 코드가 없어야하고 인자를 건드려서는 안된다. 대신, 필요하다면 인자를 다른 타입의 객체로 감싸거나 가공하지 않은 형식으로 캡슐화해야한다. 예를 들어 다음과 같은 것은 잘못된 방법이다.

class Cash {
    private int dollars;
    Cash(String dlr) {
        this.dollars = Integer.parseInt(dlr);
    }
}

위와 같은 방법 대신, 우리는 다른 타입의 객체로 감싸 할당할 수 있다. 즉, 생성자에서는 단지 객체의 상태를 초기화만 해야하지 별도의 로직을 수행하지 않아야한다. 이는 작은 객체의 조합으로 더 큰 객체를 만들어간다는 점에서 진정한 객체지향에서의 인스턴스화를 이루어낼 수 있으며, lazy-loading을 통해 실제 필요한 시점에서 파싱될 수 있도록 파싱되는 시점을 늦출 수 있다는 장점을 갖는다.

따라서 생성자에서 코드를 없애면 사용자가 쉽게 제어할 수 있는 투명한 객체를 만들 수 있다. 객체는 요청받을 때만 일하고, 그 전에는 어떠한 일도 하지 않게 된다.

2장 학습

2.1 가능하면 적게 캡슐화하세요.

내부 필드는 4개 또는 그 이해의 객체를 캡슐화하자. 내부에 캡슐화된 객체를 가리켜 객체의 상태 또는 식별자라고 부른다. 객체를 판별할 때 그 상태들을 비교하여 판별하기 때문에 만약 3개의 상태를 갖고 있다면 그 3가지 상태가 모두 같아야 같은 객체라고 볼 수 있다. 4가지인 이유는 직관을 통해 이해하기에는 4가지까지가 적합하기 때문이다. (좌표를 생각)

또한 더불어 == 연산자를 사용하지 말고 항상 equals() 메서드를 오버라이드 해 사용하자.

2.2 최소한 뭔가는 캡슐화하세요.

앞선 2.1 에서 가능한 적게 캡슐화하라고 했다. 그렇다면 0개는 어떤가?

객체의 필드는 객체의 상태이자 식별자이다. 어떤 것도 캡슐화하지 않은 클래스의 모든 객체는 동일하다. 프로퍼티가 없는 클래스는 정적 메서드와 유사하다. 이 클래스는 아무런 상태와 식별자도 가지지 않고 오직 행동만을 포함한다. 정적 메서드가 존재하지 않고 인스턴스 생성과 실행을 엄격하게 분리하는 순수한 객체지향에서는 기술적으로 프로퍼티가 없는 클래스를 만들 수 없다.

객체가 '무'와 비슷한 어떤 것이 아니라면 무언가를 캡슐화해야한다. 자기 자신을 식별할 수 있도록 하기 위해 무언가를 캡슐화해야한다. 어떤 것도 캡슐화하지 않는 상태는 객체 자신이 세계 전체가 된다. 오직 하나의 세계만 존재할 수 있기 때문에 이 클래스는 오직 하나만 존재해야한다.

2.3 항상 인터페이스를 사용하세요.

객체 간의 관계를 형성하기 위해서는 결합이 필요하다. 하지만 수 십개의 객체가 관계를 형성하기 위해 결합을 맺는다면, 강한 결합도가 문제가 된다. 따라서 우리는 객체 간의 관계를 맞을 수 있도록 하며, 객체를 분리하기 위해 인터페이스를 사용할 수 있다. 인터페이스는 다른 객체와 소통하기 위한 계약이라고 볼 수 있다. 특정 인터페이스를 구현하는 것은, 그 인터페이스에 담긴 계약을 준수한다고 보면 된다. 더불어 클래스 안의 모든 퍼블릭 메서드가 인터페이스를 구현하도록 만들어야한다. 올바르게 설계된 클래스라면 최소한 하나의 인터페이스라도 구현하지 않는 퍼블릭 메서드를 포함시켜서는 안된다.

2.4 메서드 이름을 신중하게 선택하세요.

빌더

  • 뭔가를 만들고 새로운 객체를 반환하는 메서드
  • 빌더의 이름은 항상 명사이어야 한다.
  • 형용사를 이용해 의미를 더할 수 있다.
  • ex. int pow(); float speed();

조정자

  • 객체로 추상화한 실세계 엔티티를 수정하는 메서드
  • 반환 타입은 항상 void, 이름은 항상 동사
  • ex. void save(String content); void put(String key, Float value);

Boolean을 반환하는 경우

  • 반환값이 있기 때문에 빌더이지만, 이름은 형용사
  • 대부분 isEmpty와 같이 지으나, 읽을 때 is를 붙이고 실제 메서드명은 empty로 지어야한다.

2.5 퍼블릭 상수를 사용하지 마세요.

public static final인 상수는 객체 사이에 데이터를 공유하기 위해 사용하는 메커니즘이다. 하지만 객체들은 그 어떤 것도 공유해서는 안되고, 독립적이어야하고 닫혀있어야한다. 따라서 상수를 이용한 공유 메커니즘은 캡슐화와 객체 지향적인 사고를 부정한다.

따라서 공유되는 상수를 Constants라는 클래스에 public static final로 선언하여 공유할 수 있다. 하지만 이는 결합도를 증가시킬 수 있기 때문에 특정 상수가 하는 행위를 담을 수 있는 클래스를 생성해 활용하는 것이 좋다.

2.6 불변 객체로 만드세요.

불변 객체는 필요한 모든 것을 내부에 캡슐화하고 변경할 수 없도록 통제합니다. 불변 객체를 수정해야 한다면 프로퍼티를 수정하는 대신 새로운 객체를 생성해야 합니다.

class Cash {
    private int dollars;
    public void mul(int factor) {
        this.dollars *= factor;
    }
}

class Cash {
    private final int dollars;
    public Cash mul(int factor) {
        return new Cash(this.dollars * factor);
    }
}

불변 객체는 자기 자신을 수정할 수 없고, 항상 원하는 상태를 가지는 새로운 객체를 생성해서 반환해야 한다.

불변객체를 사용해야 하는 이유

  1. 식별자 가변성 - 가변 객체의 경우 다른 자료 구조를 사용할 때, 자료구조에 해당 가변 객체의 값을 넣어 둔 뒤 값을 변경할 경우 해당 자료구조 내에서 식별자가 변경되게되는 이슈가 생긴다. (ex. map)
  2. 실패 원자성 - 완전하고 견고한 상태거나, 실패하거나 둘 중 하나의 상태를 갖는다. 따라서 불변 객체로 만든다면 실패 시에는 내부의 어떤 것도 수정할 수 없도록 하고, 성공 시 아예 새로운 객체를 반환하도록 할 수 있다. 만약 가변 객체라면 중간에 일부만 변경될 수 있는 위험이 존재한다.
  3. 시간적 결합 - 어떤 객체를 선언할 때, setter를 이용해서 프로퍼티를 초기화한다면 초기화 순서에 따라 값의 상태가 달라질 수 있다. 하지만 불변 객체를 이용한다면 프로퍼티의 초기화는 생성자에서만 가능하므로 시간적 결합에 대한 이슈를 없앨 수 있다.
  4. 부수효과 제거 - 객체가 가변인 경우 누구든 손쉽게 객체를 수정할 수 있다. (이 말 만으로도 4번은 증명되는 것 같다)
  5. NULL 참조 없애기 - 3번과 관련있다. setter가 아닌 생성자에서 초기화함으로 NULL 이 생기는 것은 생성자에 null을 전달한 경우밖에 없다.
  6. 스레드 안전성 - 불변이므로 당연하지 않을까?
  7. 더 작고 더 단순한 객체 - 250줄을 넘는 클래스는 리팩토링이 필요하다. 불변 객체의 경우에는 프로퍼티를 생성자를 이용해 초기화해야하므로 가변 객체에 비해 더 작고, 더 단순한 객체가 될 수 밖에 없다.

2.7 문서를 작성하는 대신 테스트를 만드세요.

문서화가 필요하다는 것은, 코드가 깔끔하지 않다는 증거이다.

우선 스스로를 설명할 수 있는 이상적인 코드를 짜는 것이 중요하다. 그렇지 않다면 문서화를 통해 설명을 포함해야한다.

여기서 이상적인 코드에는 단위 테스트도 포함되어있다. 단위 테스트는 코드로서 클래스의 사용 방법을 보여준다. 깔끔하고 유지보수 가능한 단위 테스트를 추가하면, 클래스를 더 깔끔하게 만들 수 있고 유지보수성을 향상시킬 수 있다.

단위 테스트 자체만으로도 문서화가 되고, 작동 방식을 통한 클래스의 사용 방식을 보여줄 수 있다. main 코드를 짜는 것처럼 단위 테스트에도 관심을 기울이자.

2.8 모의 객체 대신 페이크 객체를 사용하세요.

모킹은 나쁜 프랙티스이며, 최후의 수단으로만 사용해야한다.

모킹 대신 페이크 객체를 사용할 것을 제안한다.

페이크 객체는 interface의 일부이며 인터페이스와 함께 제공된다.

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

페이크 클래스는 단위 테스트 안에서 Exchange 클래스를 쉽게 사용할 수 있도록 지원한다. 페이크 클래스는 실제보다 더 복잡해지는 경우도 있지만 모킹에 비해 다음과 같은 장점들을 제공한다.

  1. 클래스 내부의 구현에 대해 알지 못해도 된다. <-> 모킹을 사용하면 가정 / 결과를 통해 구현하기 때문에 내부 구현을 알아야한다.
  2. public 메서드를 변경했을 때 테스트에서 실패하지 않는다.
  3. 인터페이스의 설계에 대해 더 깊이 고민하도록 해 준다. '테스트'라는 리소스를 사용해서 사용자와 동일한 기능을 구현한다.

2.8 인터페이스를 짧게 유지하고 스마트를 사용하세요.

클래스를 작게 만드는 것이 중요하다면 인터페이스를 작게 만드는 것은 훨씬 더 중요하다. 왜냐? 클래스가 다수의 인터페이스를 구현하기 때문이다. 두 인터페이스가 5개씩 메서드를 선언한다면, 두 인터페이스를 구현한 클래스는 10개의 public 메서드를 강제로 가진다. 따라서 우리는 Smart 클래스를 통해 이를 해결할 수 있다.

공통되지만 경우에 따라 필요할 수도 있고, 아닐 수도 있는 메서드는 인터페이스 내에 Smart 클래스를 통해 해결하자. Smart 클래스는 공통적인 작업을 수행하는 많은 메서드를 포함할 수 있으며, 인터페이스를 구현하는 다른 클래스들안에 동일한 기능을 반복해서 구현하지 않아도 된다.

3장 취업

3.1 5개 이하의 public 메서드만 노출하세요.

클래스의 크기를 정하는 기준으로 public(protected도 동일) 메서드의 개수를 사용하길 권장한다. public 메서드가 많을수록 클래스의 응집도가 낮아진다. public 메서드가 많다면 다른 클래스와 조화를 이루는 것이 어려워진다. public 메서드를 5개 이하로 줄이고, 나머지를 private 메소드를 이용해 구현하고, 그래도 안된다면, 클래스를 분리하자.

public 메서드의 개수가 줄어들게 된다면 그 클래스는 테스트하기에도 용이해진다. 또 다른 이유를 들어보자. 만약 클래스의 프로퍼티들이 각각 서로 다른 메서드에서 분리되어 상호작용한다면 이는 서로 관계없는 프로퍼티를 뭉뜽그려 한 클래스안에 모아두었다고 볼 수 있으므로, 좋지 않다. 그러므로 public 메서드 개수를 줄임으로서 한 메서드가 해당 클래스의 모든 프로퍼티와 상호작용할 가능성이 더 높아진다.

3.2 정적 메서드를 사용하지 마세요.

저자가 말하는 정적 메서드를 사용하면 안되는 이유는 다음과 같다.

절차적인 프로그래밍과 다를 바 없다. 우리는 객체 내부에서 어떻게 구현되어있는지 알 필요 없이 단순히 정의만 하면 된다.

Number max = new Max(2); // O
Number max = Max.max(2); // X

객체를 통한 lazy-loading이 불가능하다. 값이 필요하지 않은 시점에 CPU가 그 값을 알고 있을 필요가 없다. 호출하는 횟수가 증가하면 lazy-loading을 이용한 방법이 훨씬 빠르다.

다형성이 불가하다. (불가하다고 하니깐 말이 이상하긴 하지만..) 객체 내부의 메서드들로 이루어져있기 때문에 다른 객체로 대체하기 위해서는 새로운 정적 메서드를 생성해야한다.

객체를 통한 조합이 불가능하다. 진정한 OOP는 작은 객체를 더 큰 객체로 만들어가는 과정이다.

3.3 인자의 값으로 NULL을 절대 허용하지 마세요.

인자의 값으로 널은 무슨 일이 있어도 절대 허용하면 안된다.

값이 비어있다는 의미로 널을 전달하는 경우가 존재하는데, 그러한 경우 메서드는 다음과 같이 작성되어야한다.

public void temp(Obj obj) {
    if (obj == null) {
        sout("null");
    } else {

    }
}

이렇게 널인지 확인을 하고, 널이 아닌 경우에만 로직을 수행하도록 한다면 이는 객체의 책임(자신의 존재여부를 스스로 판단하도록 하는)을 빼앗는 것과 같다.

그렇다면, 값이 없는 경우에는 어떻게 처리해야할까?

객체의 상위에 인터페이스를 만든 뒤 존재하지 않는 객체를 의미하는 객체를 만든다. 다음과 같은 AnyFile은 어떠한 내부 로직도 포함하지 않고 어떤 파일을 전달하든 항상 true를 반환한다. 따라서 null 대신 anyfile 인스턴스를 생성해서 전달하면 된다.

class AnyFile implements Mask {
    @Override
    boolean matches(File file) {
        return true;
    }
}

하지만 외부에서 널이 들어오는 경우(클라이언트) 어떻게 처리해야할까?

  1. 그냥 무시하고 널포인터예외가 던져지도록 냅둔다. - 널 넣은 니들 잘못임
  2. if (obj == null) 을 추가한다

3.4 충성스러우면서 불변이거나, 아니면 상수이거나

불변이라 해서 반환하는 값이 항상 고정된 것은 아니다. 반환 값이 항상 고정된다면 상수라 표현하고 필드, 프로퍼티는 고정되있지만 값이 계속해서 달라질 수 있는 객체를 불변객체라 칭한다. by 나봄 정리글

불변 객체를 사용해야한다.

하지만 내가 생각한만큼 빡센 불변객체가 아니었다.

저자가 생각하는 불변 객체는 상태값이 항상 동일한 객체이다.

내부 메서드를 통해 새로운 값을 리턴(ex. 책 예제에서의 content())하더라고 어쨌든 그 객체의 상태가 변경되지 않는다면 불변이라면 그것은 불변 객체이다.

필드로 리스트를 갖는 경우에도, add 메서드를 통해 그 필드에 값을 추가하는 것은 허용된다. (이에 대해서는 그 리스트가 가리키는 주소값이 동일하기 때문인가?)

(근데 주소만 같으면 불변인가?)

이 책에서 하도 불변 불변거려서 다음 지하철 미션에는 최대한 모든 객체를 불변으로 만들어 볼 생각이다.

3.5 절대 getter와 setter를 사용하지마세요.

이 장은 제목을 좀 헷갈리게 지은 것 같다.

내 생각에 저자의 의견은 메서드명에 get / set을 쓰지 말라! 인 듯.

왜냐면? 프로퍼티로 String name;을 갖는 경우 getName()은 안되고, name()은 된다고 하기 때문이다.

사실 이 장을 전에도 봤던 것 같은데, getName()이 아니라 name()을 쓰니 뭔가 게터를 안쓰는 것 같다는 마음의 위안을 얻긴 했는데, 이렇게 메서드명만 바꾼다고 getter를 사용하지 않는 것이 되는걸까?

3.6 부 ctor 밖에서는 new를 사용하지 마세요.

토비의 스프링에서도 비슷한 내용을 봤다.

생성되는 부분을 특정 객체 내부에서 하게 된다면 의존성을 갖게 된다.

여기서도 부 ctor 밖에서는 New를 사용하지 말라는 뜻이, 특정 객체를 생성할 때 필요한 것을 의존성 주입을 통해 해결해야하며 내부 메서드에서 연관 관계를 갖고 있지 마라는 뜻인 듯?

이렇게 되면 관심사의 변화가 생겼을 때 확장하기 용이할 것이다.

3.7 인트로스펙션과 캐스팅을 피하세요.

What is Introspection?

빈의 프로퍼티와 메소드 등을 알려주기 위해 빈의 디자인 패턴을 자동으로 분석하는 과정

객체의 클래스, 구현 메소드, 필드 등의 객체 정보를 조사하는 과정을 의미한다

타입캐스팅이란, 형변환을 말한다.

모두 런타임에 객체의 클래스를 확인하는 데 사용된다.

하지만 이는 모두 안티패턴이므로 사용하면 안된다.

방문한 객체에 대한 기대를 문서에 명시적으로 기록하지 않은 채 외부에 노출시킨 것과 같기 때문이다.

또한 런타임에 객체의 상태를 확인하므로 소스코드를 확인해야만 그 객체가 어떤 타입으로 형변환되었는지 파악할 수 있어 유지보수하는 것이 매우 어렵다.

  • 의견

이전 상태 패턴을 쓸 때 현재 상태가 Blackjack인지, Hit인지 확인하기 위해 if (state instanceof Blackjack) 과 같은 코드를 작성한 적이 있다. 이런 경우에는 런타임에 객체를 비교할 수 있어 좋았으나 어떻게 작성하느냐에 따라 다른 결과를 낼 때도 있었다. 따라서 OOP도 만족시킬 수 있도록 상태에게 너 지금 어떤 상태이니?라고 물어보는 코드로 바꿨던 기억이 난다.

아무튼, 난 타입캐스팅이나 형변환은 사용하지 말자!에 한표이지만, 상속 관계에 있을 때 upcasting은 사용해도 되지 않을까...?

4장 은퇴

4.1 절대 NULL을 반환하지 마세요.

리턴값으로 널을 던진다는 것은, 받는 곳에서 그 값이 널인지 아닌지 매번 확인해줘야하는 것을 의미한다. 이는 객체에 대한 신뢰성을 떨어트리며 불필요한 Null 확인 코드를 모든 메서드마다 추가해야한다.

또한, 소프트웨어 견고성과 실패 탄력회복성과 관련해서 상반되는 철학(빠르게 실패하기 vs 안전하게 실패하기) 중 빠르게 실패하기의 지지자인 저자는 버그, 입출력 문제 등이 발생한 상황에서도 소프트웨어가 계속 실행될 수 있도록 최대한 많은 노력을 기울일 것을 권장하는 안전하게 실패하기 보다는 문제가 발생하면 곧바로 실행을 중단하고 최대한 빨리 예외를 던지는 빠르게 실패하기를 주장한다. 따라서 null을 반환하고 널인경우 다르게 처리하는 것이 아닌, null일 경우에는 바로 nullPointerException이 던져지도록! 하는 것이 맞다고 주장한다.

4.2 체크 예외만 던지세요

  • 체크 예외는 해롭고 안전하지 않은 메서드를 다루고 있다는 사실을 기억해야 한다. 예외를 상위로 올리거나 잡아야 한다. 언체크 예외는 메서드를 호출하는 쪽에서 어떤 예외가 던져질 지 예상할 수 없다.
  • 예외를 다시 던지지 않고 잡아주는 것은 안전하게 실패하기를 하는 것이다.
  • 예외를 체이닝함으로써 문제가 발생했다는 사실을 무시하지 않을 수 있다.
  • 가장 최상위 수준에서 오직 한 번만 복구하는 방식을 택해야 한다.
  • 실패 재시도는 OOP의 코드를 깔끔한 상태로 유지하기 위해 AOP를 적용할 수 있는 현실적이면서 실용적인 예다.
  • 하나의 예외 타입이면 충분하다.

4.3 final이나 abstract이거나

  • 상속을 받아 기존의 메서드를 오버라이딩 한 경우 사용자가 부모의 메소드를 사용하듯이 자식에게 똑같은 이름의 메소드를 호출했을 때 부모가 행동하던 것을 기대하지만 다르게 행동하기 때문에 문제의 원인을 찾아내기까지 꽤 오랜 시간이 걸린다.
  • 상속은 복잡성이 상승하고 코드를 읽고 이해하기가 굉장히 어려워져 유지보수성을 해친다. 이를 위해, 클래스와 메소드를 final이나 abstract 둘 중 하나로만 제한한다면 문제가 발생할 수 있는 가능성을 없앨 수 있다.
  • 상속은 클래스의 행동을 확장하지 않고 정제할 때 사용한다.

4.4 RAII를 사용하세요

RAII: 리소스 획득이 초기화 (가비지 컬렉션을 이용해서 객체를 제거하는 Java에서는 제거된 개념이다.)

try-with-resources를 통해 connection을 잘 관리해주자. 아니면 직접 Free하는 것을 놓치지 말자.