4장

필드 직접 노출 < Accessor, Mutator 제공

  • public 클래스에서 public field를 사용하면 여러 단점 존재
  • 외부에서 직접 접근하므로 캡슐화 X
  • 내부 구조를 변경하기 어려워짐
  • 값이 변하지 않도록 할 수 없음
  • 필드에 접근할 때 부수적인 조치를 취할 수 없음

가변 객체 < 불변 객체

불변 클래스

  • 불변 클래스는 생성할 때 이외엔 인스턴스의 값을 변경할 수 없음
  • 설계와 구현 및 사용이 더 쉬움
  • 에러 발생이 더 적음
  • 보안 및 사용 측면에서 안전
  • 불변 클래스는 객체를 보다 더 많이 재활용하도록 유도하면 좋음
  • static 팩토리를 제공하면 유연한 캐싱 가능
  • clone 또는 복사 생성자가 필요 없음
  • 객체 그 자체 뿐만 아니라 내부 구조의 공유 가능
  • 값마다 새로운 객체가 필요한 것이 단점
  • 최종 결과만 필요한 다단계 연산이 있다면 그 사이에는 가변 클래스를 쓰는게 효율적
  • 패키지 전용의 가변 클래스 사용 추천
  • ex) String, StringBuilder
  • 인스턴스가 가변적이어야 할 타당한 이유가 없다면 클래스는 불변 클래스로 만들어야 함
  • 그게 불가능하더라도 가능한한 필드는 final
  • 생성자나 static 팩토리 메소드에서 완벽하게 초기화된 객체를 반환
  • 객체 재사용을 위한 재 초기화 메소드 제공 X
  • 기대하는 만큼 성능 향상이 없음

생성 규칙

  1. Mutator 제공 X
  2. 상속 막기 (보통은 final class)
  3. 모든 필드를 final 지정
  4. 모든 필드를 private 지정
  5. 가변 객체 필드는 외부에서 참조할 수 없게 막기
    • Constructor, Accessor, readObject에서 객체의 방어 복사본을 만들어 사용

상속 < 컴포지션

상속

  • 상속은 캡슐화를 위배. 부모 클래스의 변화에 지나치게 영향을 받음
  • 부모 클래스의 변경이 자식 객체를 망가뜨릴 수 있음
  • 개발하고 있는 순간에도 부모 클래스의 문서에 나와있지 않는 부분 때문에 버그가 발생할 수 있음
  • 부모 클래스에서 적용하는 여러 validation들이 미처 적용되지 않아 보안 취약점 발생 가능
  • private 필드, 메소드에 접근해야만 구현 가능한 케이스도 많음
  • 꼭 상속을 해야 된다면 되도록 override 대신에 새로운 메소드로 확장해나가는 것이 안전함
  • 물론 이 경우에도 부모 클래스가 같은 이름, 패러미터 타입을 가진 메소드를 추가한다면 의도치않게 오버라이딩 해버리거나 최악의 경우엔 컴파일 자체가 불가능해짐
  • 부모 클래스와 자식클래스가 is-a 관계일 때만 상속 사용

컴포지션

  • 확장을 원하는 클래스를 상속하는 대신에 그 인스턴스를 필드로 갖는 새로운 클래스를 만들어 쓰는 방법

포워딩

  • 새로운 클래스의 각 메소드를 기존 클래스의 메소드와 매핑하여 외부에 노출시키는 것
  • 매핑된 메소드는 포워딩 메소드
  • 포워딩 메소드로만 이루어진 클래스는 포워딩 클래스
  • 래퍼를 만들 땐 바로 상속하는 것 보다 포워딩 클래스를 만들어 상속하는 것이 좋음
public class ForwardingList<T> implements List<T> {
    private final List<T> instance;

    public ForwardingList(List<T> instance) {
        this.instance = instance;
    }

    @Override
    public int size() {
        return instance.size();
    }

    @Override
    public boolean isEmpty() {
        return instance.isEmpty();
    }

    @Override
    public boolean contains(Object o) {
        return instance.contains(o);
    }

    //... 이후 생략
}
public class PrintWrapperList<T> extends ForwardingList<T> {
    public PrintWrapperList(List<T> instance) {
        super(instance);
    }

    @Override
    public boolean add(T t) {
        System.out.println("element added: " + t.toString());
        return super.add(t);
    }

    @Override
    public boolean remove(Object o) {
        System.out.println("element deleted: " + t.toString());
        return super.remove(o);
    }

    //... 이후 생략
}

데코레이터 패턴

  • 기존 클래스의 인스턴스를 갖고 거기에 새로운 기능을 덧붙인 래퍼 클래스를 만들어 쓰는 것
  • 래퍼 클래스는 콜백 프레임워크에서는 사용 불가
  • 콜백을 할 때 래퍼 객체 자체가 아니라 일부분에 불과한 자기 자신 this 를 전달함 => SELF 문제

위임

  • 컴포지션과 포워딩을 결합하고, 래퍼 객체 참조가 자신이 포함한 객체에 전달되는 것

상속을 위한 설계, 문서화

  1. 메소드 오버라이딩 파급 효과 문서화
    • 오버라이드 가능 메소드들은 반드시 그 메소드가 같은 클래스의 다른 메소드를 호출하는지 문서화
    • 반대로 오버라이드 가능한 메소드를 호출하는 부분도 문서화 필요
    • 좋은 API문서는 이 메소드가 무슨 일을 하는지 설명하지 어떻게 하는지 설명하진 않음. 그러나 상속이 캡슐화를 위반하므로 어쩔 수 없이 설명 필요
  2. 적절한 protected 멤버 제공
  3. 서브 클래스를 직접 만들어보며 테스트
    • 사용 빈도가 적은 멤버는 private로, 꼭 필요했던 멤버는 protected
    • 보통 3개의 서브 클래스 정도로 판단 가능
    • 최소한 1개는 다른 사람이 작성해보도록 할 것
  4. 생성자에서는 절대 오버라이드 가능한 메소드를 호출하지 말 것
    • 수퍼 클래스의 생성자가 서브 클래스 생성자에 앞서 실행되기 때문
  5. 되도록이면 Serializable, Cloneable는 수퍼 클래스에서 implement 하지 말 것
    • 만약에 해야된다면 clone(), readObject() 메소드는 오버라이딩 가능한 메소드를 호출하면 안 됨
  6. 설계나 문서화되지 않은 클래스의 상속은 금지
    • final 클래스로 만들기
    • 모든 생성자를 private, default로 하고 팩토리 메소드 추가

추상 클래스 < 인터페이스

  • 기존 클래스에다 추가로 추상 클래스를 상속하는 것 보다 인터페이스를 구현하도록 바꾸는 것이 훨씬 쉬움
  • 인터페이스는 Mixin 정의에 이상적
  • 인터페이스는 비계층적인 타입 프레임워크 구축 가능
  • 메소드 구현 부분을 일부 포함해서 제공하고 싶다면 인터페이스를 먼저 만들고 그 인터페이스의 중요한 부분을 일부 구현한 골격 구현 추상 클래스를 만들 것
  • 네이밍은 보통 Abstract + {Interface name}
  • 익명 클래스를 만들기 쉽게 해줌
  • 모의 다중 상속 가능
  • 인터페이스를 구현하되, 내부 클래스를 만들어 필요한 골격 구현 추상 클래스를 상속하게 하고 그 내부 클래스의 인스턴스를 만들어 메소드 호출을 전달
  • Java 8 이상에서는 인터페이스도 default, static method 포함 가능
  • 골격 구현 추상 클래스의 변종으로 가장 간단하게 동작 가능한 클래스인 단순 구현 클래스가 있음
  • 새로운 메소드 추가 등 앞으로 클래스가 진화할 여지가 많고 그게 유연성, 능력보다 더 중요하다면 추상 클래스 고려
  • 인터페이스에 새로운 메소드를 넣으면 기존 구현 클래스 전체를 다시 수정하여 컴파일해야 됨
  • 인터페이스와 골격 구현 추상 클래스를 만들고 두개를 모두 업데이트하면 되긴 하지만 이 경우 골격 구현 추상 클래스를 상속하는게 아니라 인터페이스를 직접 구현한 클래스들은 포함되지 않음
  • 그러므로 public interface는 정말 신중하게 설계해야

상수를 모아두기 위해 인터페이스를 쓰지 말 것

  • 대표적인 안티 패턴 - 상수 인터페이스
public interface MyConstants {
    String NAME = "application";
    String ID = "kwonsci";
}

보통 이러한 패턴은 공통적인 상수를 쓸 때, 상수를 사용하기 위해 SomeClass.CONST_STRING 처럼 되어있는 부분에서 클래스 이름을 빼고 쓰기 위해서 사용하곤 한다. 내 경우엔 어차피 전부 public static final인거 interface로 하면 모든 필드에 자동으로 public static final이 붙으므로 코드가 간결해지지 않을까? 하는 생각에 쓴 적도 있었고.

그러나 이러한 방식의 인터페이스 사용이 좋지 않은 데에는 몇 가지 이유가 존재한다.

  1. 저 상수들은 무조건 외부로 노출된다. (무조건 public)
  2. 저 인터페이스를 implement 한 클래스를 사용자들이 사용할 때 혼란을 줄 수 있다.
  3. 저 상수가 필요없어지더라도 바이너리 호환성을 위해 implement를 뗄 수가 없다.
  4. 만약 저 인터페이스를 implement 한 클래스를 상속하게 되면 마찬가지로 저 상수들이 존재하므로 네임스페이스가 좁아지고 캡슐화에 위배된다.

어쨌든, 인터페이스는 타입을 정의할 때만 사용해야하므로 내가 좀 더 코드를 줄이겠답시고 쓴 부분도 문제가 있다.

그러므로 지향해야될 방법은 다음과 같다.

  1. 기존 클래스나 인터페이스와 밀접한 연관관계를 맺는다면 그 클래스에 상수를 넣기
  2. 되도록 enum으로 만드는 것을 고려해보기
  3. 이도저도 아니라면 유틸리티 클래스 (인스턴스 생성 불가) 에 상수 넣기

그리고 호출할 때 클래스 이름을 빼고 호출하고 싶다면 차라리 static import로 땡겨오자.

태그 클래스 < 클래스 계층

태그 클래스는 어떤 태그를 기준으로 하나의 클래스가 여러 종류의 인스턴스를 가질 수 있게 만든 클래스를 말하는데, 그야말로 단점 투성이라고 볼 수 있다.

  1. 여러 인스턴스를 위한 코드들이 난잡하게 섞여있어 가독성이 떨어진다.
  2. 인스턴스를 생성할 때, 불필요한 필드까지 초기화해서 메모리의 할당과 해지에 들어가는 비용이 커진다.
  3. 보통 메소드를 호출하면 태그를 switch case문으로 로직을 선택적으로 돌아가게 하는데 컴파일러의 도움을 받지 못해 태그를 추가하거나 삭제하는 경우에 런타임 오류가 나기 쉽다.
  4. 생성자에서 잘못된 필드를 초기화하고 있는 경우에도 알기 힘들어서 런타임 오류가 날 수 있다.

요약하면 코드가 쓸데없이 장황하고 에러가 나기 쉬우며 비효율적이다.

따라서 객체지향적 관점에 맞게 상속을 이용하여 여러 클래스를 계층화하는 것이 바람직하다.

그 방법은 다음과 같다.

  1. root로 쓸 추상 클래스나 인터페이스를 만들고 태그에 따라 동작이 달라지는 메소드들을 거기에 추상 메소드로 추가한다.
  2. 태그랑 관계없이 언제나 동작하는 메소드, 전체 인스턴스가 공통으로 사용하는 필드도 root에 추가한다.
  3. 각 인스턴스 종류를 root의 서브 클래스로 만들고, 각각 전용으로 쓰는 필드들을 채워넣고, root의 추상 메소드들을 구현한다.

함수 객체

오직 1개의 메소드만을 가지는 클래스. 함수포인터와 비슷한 용도로 사용된다. 내가 원하는 기능을 구현하여 다른 메소드나 클래스에 패러미터로 넘겨주면 그 쪽에서 실행하게 된다.

대체로 아무런 필드도 존재하지 않는 stateless 필드이고, 그렇기 때문에 불필요한 인스턴스의 생성을 막기위해 싱글톤으로 만들어주면 좋다. 그러나 만약에 JDK8 이상 버전을 쓴다면 람다식을 쓸 수 있고, 람다식은 stateless에 대해서 알아서 static method로 변환해주므로 신경쓸 필요가 없을 것이다.

보통 함수 객체는 전략 패턴을 구현하는데 쓰인다. 이는 전략 인터페이스구체 전략 클래스로 나누어진다.

전략 인터페이스는 Comparator 같은 갈아끼울 수 있게 만들어진 인터페이스이고,

구체 전략 클래스는 전략 인터페이스를 구현하여 만드는 클래스이다.

이 때, 전략 인터페이스가 구체 전략 클래스의 타입 역할을 하므로 구체 전략 클래스는 public이 아니어도 좋다. 즉, 전략 인터페이스의 inner class로 만들어도 좋고, static factory method를 제공해도 좋다.

보통은 private static class로 구현하고 public static final 필드로 외부에 제공한다.

Inner Class

클래스의 안에 정의된 중첩 클래스. 외곽 클래스를 지원하는 용도로만 쓰여야한다. 만약에 일부분이 다른 클래스나 분야에서도 유용하게 쓰인다면 독립적인 클래스로 분리시켜야한다.

종류

  1. static
    • 가장 기본적인 형태
    • static이라면 외곽 클래스의 private까지 모두 사용할 수 있음
    • helper 클래스로 주로 사용됨
    • 외곽 클래스의 인스턴스에 영향받지 않는다면 static
  2. non-static
    • 외곽 클래스의 인스턴스에 의존하는 경우
    • 외곽 클래스의 static을 포함한 모든 멤버에 접근 가능
    • 일반적으론 외곽 클래스의 메소드에서 생성자를 호출. 정 필요하다면 myInstance.new InnerNonStaticClass(args) 처럼 호출할 수도 있긴 함
    • 주로 어댑터 패턴 구현에 사용
    • Map.keySet(), Map.entrySet(), Map.values(), Iterable.iterator() 같은 메소드들이 반환하는 값을 보면 자체 클래스 인스턴스처럼 사용되지만 실제로는 외곽 클래스 인스턴스의 데이터를 끌어다 쓰고 있음
    • 그런데 정작 JDK8에서 AbstractHashedMap 까서 보니까 protected static 으로 선언하고 Map 인스턴스를 생성자에서 받아다가 컴포지션으로 쓰고 있더라
  3. private static
    • 외곽 클래스의 컴포넌트들을 표현하는데 보통 사용
    • non-static과 유사한 용도지만 특정 인스턴스에 의존하지 않을 경우에 사용
    • Map.Entry 처럼 Map의 일부를 표현하지만 각 메소드들은 Map의 인스턴스에 관계없는 경우
    • static으로 선언하지 않아도 동작은 잘 하겠지만 각 Entry들이 불필요하게 Map의 객체 참조를 가지게 됨
  4. anonymous
    • static 멤버 가질 수 없음
    • instanceof 연산자 사용 불가
    • 여러 인터페이스 구현, 상속과 동시에 구현 불가
    • 클래스 네임이 필요한 곳에서 사용할 수 없음
    • 가급적 코드가 짧아야
    • 함수 객체 생성에 자주 사용
  5. local
    • 지역 변수가 선언될 수 있는 모든 곳에서 선언 가능
    • static 멤버 가질 수 없음
    • 가급적 코드가 짧아야
    • 익명 클래스로 만들어야되는데 타입 인터페이스가 존재하지 않을 때 사용
    • 어차피 요샌 람다도 있어서 뭐..

results matching ""

    No results matching ""