만쥬의 개발일기
article thumbnail

1장 : 들어가기

이 책은 총 90개의 아이템과, 아이템들을 주제별로 묶은 11개의 장으로 구성된다.

또한 많은 디자인 패턴과, 피해야할 안티패턴들을 소개한다.

이 책의 용어들은 대부분 자바8용 언어 명세를 따른다.

자바가 지원하는 타입은 다음 네가지이다.

  • 인터페이스
  • 클래스
  • 배열 (array)
  • 기본타입 (primitive)

위 네가지 타입 중 처음 세가지는 참조 타입(reference type)이다.

즉, 클래스의 인스턴스와 배열은 객체이지만, 기본 타입은 그렇지 않다.

클래스의 멤버로는 필드, 메서드, 멤버 클래스, 멤버 인터페이스 등이 있따.

메서드 시그니처는 메서드 이름과 파라미터의 타입들로 이뤄진다.

또한 이 책에서는 인터페이스 상속 ➡️ 클래스가 인터페이스를 구현한다 or 인터페이스가 다른 인터페이스를 확장한다(extend) 라고 표현한다.

API는 프로그래머가 클래스, 인터페이스, 패키지를 통해 접근 가능한 모든 클래스, 인터페이스, 생성자, 멤퍼, 직렬화된 형태(serialized form)을 말한다.

그리고 그 모든 것을 총칭해 API 요소라고 한다.

API의 사용자 : API를 사용하는 프로그램 작성자(사람)

API의 클라이언트: API를 사용하는 클래스(코드)

2장 : 객체 생성과 파괴

이번 장에서는 다음 내용들을 다룬다.

  • 객체의 생성과 파괴
  • 객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법
  • 올바른 객체 생성 방법과 불필요한 생성을 피하는 법
  • 객체를 관리하는 요령

아이템 1 : 생성자 대신 정적 팩터리 메서드를 고려하라

클라이언트가 클래스의 인스턴스를 얻는 방법은 public 생성자를 사용하는 것도 있지만,

정적 팩터리 메서드를 사용할 수도 있다.

정적 팩터리 메서드의 장점 다섯가지

  1. 이름을 가질 수 있다.
  2. 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
  3. 리턴 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
  4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
  5. 정적 팩터리 메서드를 작성하는 시점에 반환할 객체의 클래스가 존재하지 않아도 된다.

먼저 첫 번째 장점이다.

한 클래스에 시그니처가 같은 생성자가 여러개 필요할 것 같을 때, 생성자를 정적 팩터리 메서드로 바꾸고 각각의 차이를 드러내는 이름으로 지어주자.

여기서 시그니처란, 메서드의 리턴값과 파라미터의 개수/종류이다.

public class Car {

    private String color;
    private int power;

    private Car(String color, int power) {
        this.color= color;
        this.power = power;
    }

    public static Car createRedCar(int power) {
        return new Car("red", power);
    }

    public static Car createBlueCar(int power) {
        return new Car("blue", power);
    }
}
/**
** 외부에서
** Car car = new Car("red", 3); 이렇게 생성자를 직접 호출하는 것보다
** Car car = Car.createRedCar(3); 이렇게 호출하는 방식이 훨씬 의미를 파악하기 쉽다.
**/

두 번째 장점이다.

호출될 때마다 인스턴스를 새로 생성하지 않는다는 말은, 인스턴스를 미리 만들어 놓거나 생성한 인스턴스를 계속해서 재활용하여 불필요한 객체 생성을 피할 수 있다는 것이다.

특히, 생성 비용이 큰 같은 객체가 자주 요청되는 상황에 성능을 더 끌어올린다.

flyweight pattern도 이와 비슷한 기법이다.

정적 팩토리 메서드는 계속 같은 객체를 반환하므로, 언제 어느 인스턴스를 살아 있게 할지를 통제할 수 있다.

인스턴스를 통제하면 다음 장점들이 있다.

  • 클래스를 싱글턴으로 만들 수 있다. (아이템3)
  • 클래스를 인스턴스화 불가로 만들 수 있다. (아이템4)
  • 불변 값 클래스에서 동치인 인스턴스가 단 하나뿐임을 보장할 수 있다.(아이템17)

예시

/**
** 불필요한 객체 생성을 방지한다.
** 불변 클래스immutable class의 경우,
** 인스턴스를 미리 만들어 두거나,
** 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다.
**/

public class Robot {
    private String name;

    private static final Robot alphaRobot = new Robot("alpha");
    private static final Robot betaRobot = new Robot("beta");

    public Robot(String name) {
        this.name = name;
    }

    public static Robot getInstanceAlphaRobot() {
        return alphaRobot;
    }

    public static Robot getInstanceBetaRobot() {
        return betaRobot;
    }
}

Robot alphaRobot = Robot.getInstanceAlphaRobot();

세 번째 장점이다.

반환 타입의 하위 타입 객체를 반환한다는 것은, 엄청난 유연성을 제공한다.

예시

public List<String> foo1() {
    return new ArrayList<String>();
}

public List<String> foo2() {
    return new Vector<String>();
}

List의 하위 클래스인 ArrayList와 Vector로 선택하여 반환하는 모습. (Stack과 LinkedList도 마찬가지로 가능)

클라이언트는 내부 구조는 모른채, List으로 반환 받는다.

네번째 장점이다.

우리가 EnumSet 클래스를 사용할 때, 사실 EnumSet의 정적 팩터리 메서드는 원소 개수의 따라 각기 다른 클래스의 인스턴스를 반환한다. (원소가 64개 이하면 RegularEnumSet, 65개 이상이면 JumboEnumSet) 그러나 클라이언트는 두 클래스의 존재를 모르고 사용해도 괜찮다. EnumSet의 하위 클래스이기만 하면 상관없기 때문이다.

다섯번째 장점이다.

서비스 제공자 프레임워크를 만드는 근간으로,

서비스 제공자 프레임워크는 다음 3개의 핵심 컴포넌트로 이루어져있다.

  • 서비스 인터페이스
  • 제공자 등록 API
  • 서비스 접근 API

다음으로 정적 팩터리 메서드의 단점이다.

  1. 정적 팩터리 메서드만 제공하면 하위 클래스르 만들 수 없다.
  2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.

첫 번째 단점이다.

상속을 하려면 public 혹은 protected 생성자가 필요하므로, 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.

그러나 이 제약은 상속 대신 컴포지션을 사용하도록 유도하는 것일 수도 있다. (발상의 전환?)

*상위 클래스와 하위 클래스의 관계가 is a 라면 상속을, has a 라면 컴포지션을 사용하자.

두 번째 단점이다.

정적 팩터리 메서드 방식 클래스를 인스턴스화할 방법을 프로그래머가 알아놓아야한다.

생성자처럼 자바독을 사용한 API 설명에 명확히 들어나지 않기 때문.

API 문서를 잘 써두는 방식으로 문제를 완화할 수 있다.

다음은 정적 팩터리 메서드에 흔히 사용하는 명명 방식들이다.

  • from : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
    • Data d = Date.from(instant);
  • of : 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
    • Set faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf : from과 of의 더 자세한 버전
    • BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
  • instance / getInstance : ( 매개변수를 받는다면 ) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다.
    • StackWalker luke = StackWalker.getInstance(options);
  • create 혹은 newInstance : instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
    • Object newArray = Array.newInstance(classObject, arrayLen);
  • getType : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다. "Type"은 팩터리 메서드가 반환할 객체의 타입이다.
    • FileStore fs = Files.getFileStore(path)

아이템 2 : 생성자에 매개변수가 많다면 빌더를 고려하라.

정적 팩토리 메서드와 생성자의 공통된 고민

➡️선택적 매개변수가 많을 때 적절히 대응하기 어렵다!

이에 대해 발전해 온 해결책

점층적 생성자 패턴 ➡️ 자바 빈즈 패턴 ➡️ 빌더 패턴

점층적 생성자 패턴 : 원하는 생성자를 골라쓰는 방식 ➡️ 원하는 매개변수만 골라 쓸 수 있지만, 보통 원치 않는 매개변수도 포함하기 쉽다. 더불어 매개변수 개수가 많아지면 클린코드 x

자바 빈즈 패턴 : 빈 생성자를 만들고, setter로 매개변수 값을 채워나가는 방식 ➡️ 메서드를 여러개 호출해야 하고, 객체가 완성되기 전 일관성이 무너진다.

빌더 패턴: 빌더의 세터 메서드들이 빌더 자신을 반환하기 때문에 메서드 호출이 물 흐르듯 연결되고, 읽고 쓰기가 쉽다.

점층적인 생성자 패턴

Person person = new Person("탱", 29, "010-1234-1234", "hello@gmail.com");

자바빈 패턴

Person person = new Person(); 
person.setName("탱"); 
person.setAge(29); 
person.setPhoneNumber("010-1234-1234"); 
person.setEmail("hello@gmail.com");

빌더 패턴을 사용하면 점층적인 생성자 패턴의 안정성과 자바빈 패턴의 가독성을 함께할 수 있다.

Person person = new Person().Builder("탱", 29) .phoneNumber("010-1234-1234") .email("hello@gmail.com") .build();

Person person = new Person().Builder("탱", 29) 
                .phoneNumber("010-1234-1234") 
                            .email("hello@gmail.com") 
                            .build();

책의 Pizza 예제가 계층적인 빌더 패턴을 아주 잘 보여주고 있다.

빌더 패턴에 장점만 있는 것은 아니다. 객체를 만들려면, 그에 앞서 빌더부터 만들어야 한다. 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다. 하지만 API는 시간이 지날수록 매개변수가 많아지는 경향이 있음을 명심하자.

매개변수가 많다면, 초기부터 생성자와 정적 팩토리 메서드 대신 빌더 패턴을 사용하자.

아이템 3 - private 생성자나 열거 타입으로 싱글턴임을 보증하라

싱글턴이란?

인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.

싱글턴들 만드는 방식은 두가지이다.

두 방식 모두 생성자는 private으로 감춰두고, 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

정적 멤버가 public final인 필드 방식

아래는 public static 멤버가 final인 필드 방식이다.

public class Elvis {
   public static final Elvis INSTANCE = new Elvis();
   private Elvis() { }

   public void leaveTheBuilding() {}
}

생성자가 private이고, 이 생성자는 public static final 필드인 INSTANCE 를 초기화할 때 딱 한 번만 호출된다. public이나 protected으로 선언된 생성자가 없으므로, 다른 곳에서 호출될 일이 없다는 것을 보증할 수 있다. 리플렉션 API인 AccessibleObject.setAccessible을 사용해 private 생성자를 호출할 수 있는데, 이는 생성자를 수정하여 두 번 호출되지 못하도록 예외를 던지게 하면 된다.

정적 팩터리 방식

다음은 정적 팩터리 메서드 방식으로 public static 멤버를 제공한다.

public class Elvis {
   private static final Elvis INSTANCE = new Elvis();
   private Elvis() { }
   public static Elvis getInstance() { return INSTANCE; }

   public void leaveTheBuilding() {}
}

getInstance 메서드는 항상 같은 객체의 참조를 반환하므로, 다른 인스턴스는 만들어지지 않는다(리플렉션 API에 대한 예외 처리는 별도).

이 방식의 장점으로는 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다. 단순히 getInstance 내부 구현만 변경하면 되므로.

두 번째 장점은 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다는 점이다. 아이템 30에서 다루겠지만, 제네릭 싱글턴 팩터리로 만들면 형변환에 좀 더 유연하게 만들 수 있다.

세 번째 장점은 정적 팩터리의 메서드 참조를 공급자(supplier)로 사용할 수 있다는 점이다. StreamAPI 등을 사용할 때 사용할 수 있을 것이다.

*supplier는 불필요한 연산을 막아주는 인터페이스이다.

만약 이러한 장점이 필요하지 않다면 앞에서 다룬 정적 final 멤버 방식을 사용해도 좋다.

앞서 알아본 방식으로 만든 싱글턴 클래스를 *직렬화하려면, 단순히 Serializable를 implement 하는 것만으로는 부족하다.

모든 인스턴스 필드를 *일시적(transient)이라고 선언하고, *readResolve 메서드를 제공해야 한다. 그렇게 하지 않으면, 직렬화 후 역직렬화시 새로운 인스턴스가 만들어진다. 즉, 싱글턴이 아니게 된다.

직렬화란? JVM의 힙 영역에 상주하고 있는 객체 데이터를 바이트 형태로 변환하는 기술. 다음 작업 시에 필요하다 .

  • 생성한 객체를 파일로 저장할 때
  • 저장한 객체를 읽을 때
  • 다른 서버에서 생성한 객체를 받을 때
private Object readResolve() {
   // 만들어 뒀던 Elvis를 반환하고, 역직렬화된 가짜 Elvis는 가비지 컬렉터에 맡긴다.
   return INSTANCE;
}

열거 타입 싱글턴

위의 두 방법 말고도 싱글턴을 만드는 또다른 방법은 바로 열거 타입(enum)을 이용하는 것이다.

public enum Elvis {
   INSTANCE;

   public void leaveTheBuilding() { }
}

public 필드 방식과 비슷하지만, 더 간결하고, 추가 노력 없이 직렬화 할 수 있다. 대부분의 상황에서는 이런 열거타입으로 싱글턴을 만드는 것이 가장 좋은 방법이다.

다만 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면, 이 방법은 사용할 수 없다. (인터페이스는 구현할 수 있다.)

아이템 4 - 인스턴스화를 막으려거든 private 생성자를 사용하라

유틸리티 클래스와 같은 도구용 클래스, 즉 정적 메서드와 멤버만을 가지고 있는 클래스를 만들어 사용할 일이 있다.

즉, 이러한 정적 클래스들은 인스턴스화를 위해 설계된 클래스가 아니다.

그러나 생성자를 따로 정의하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다.

인스턴스화를 막기 위해서는 private 생성자를 사용하면 된다.

public class UtilityClass {
   // 기본 생성자가 만들어 지는 것을 막는다.
   private UtilityClass {
      throw new AssertionError();
   }

   // ...생략...
}

아이템 5 - 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

예를 들어 unitPrice * usage를 통한 최종 금액에서, 할인율 20%를 적용하려고 한다. 아래처럼 짤 수 있을 것이다.

@Override
public long calculateEachCharge(CityGasUser user) {
    long unitPrice = user.getUnitPrice();
    long usage = user.getUsage();

    return unitPrice * usage * 80 / 100;
}Copy

이런 매직 넘버는 차후 찾기도 힘들고, 클라이언트 입장에서 할인율을 변경할수도 없고, 저 소스를 가지고 운영하는 사람 입장에서 할인율을 알려면 소스를 전부 까봐서 찾는 수밖에 없다. 아래는 생성자에서 주입받아 사용하는 코드이다.

public class VulnerableCityGasChargeService extends CityGasChargeService {
    private final int discountRate;

    public VulnerableCityGasChargeService(CityGasUserService cityGasUserService, int discountRate) {
        super(cityGasUserService);
        this.discountRate = discountRate;
    }

    @Override
    public long calculateEachCharge(CityGasUser user) {
        long unitPrice = user.getUnitPrice();
        long usage = user.getUsage();

        return unitPrice * usage * (100-discountRate) / 100;
    }
}Copy

출처: https://nahwasa.com/entry/TDD-Mock-SOLID-얘기-도시-가스-요금-계산

아이템 6 - 불필요한 객체 생성을 피하라

책에 String, 정규식, 오토박싱 관련한 예시가 나와있다. String의 경우 literal로 코드에 작성 시 힙 영역의 String Constant Pool 객체가 저장된다. 이후 동일한 literal에 대해 이미 생성되어 있는 객체가 반환된다.

// [A]
String str1 = "abc";
String str2 = "abc";
System.out.println(str1 == str2);// true
System.out.println(str1.equals(str2));// true
// [B]
String str1 = "abc";
String str2 = new String("abc");
System.out.println(str1 == str2);// false;
System.out.println(str1.equals(str2));// true
// [C]
String str1 = "abc";
String str2 = new String("abc").intern();
System.out.println(str1 == str2);// true
System.out.println(str1.equals(str2));// trueCopy

[A]는 설명한대로 String Constant Pool에 들어있는 두개를 비교하게 된다. 따라서 == 으로도 동일한 객체이므로 (주소값이 동일하므로) 동일하다고 뜬다. equals로는 당연히 true이다.

[B]는 new로 생성한 경우이다. 이 경우 String Constant Pool에서 가져오지 않으므로 서로 다른 객체가 되어(주소값이 달라서) == 으로는 비교할 수 없다.

[C]는 intern() 함수를 사용해 직접 String Constant Pool에 넣은 경우이다. 이 경우 [A] 와 동일하게 동작한다.

결론은 전부 intern()을 사용하거나, 완벽히 통제한게 아닌 이상 그냥 equals() 를 써서 비교하면 된다.

Integer a = 5;
Integer b = 5;
System.out.println(a == b);
System.out.println(a.equals(b));

Integer c = 500;
Integer d = 500;
System.out.println(c == d);
System.out.println(c.equals(d));Copy

위의 예시에서는, a==b는 true지만, c==d는 false이다.

Integer의 경우 [-128, 127] 범위 이내에 대해 캐싱한다. 따라서 a와 b는 캐싱되서 동일한 객체이고, c와 d는 500이라 캐싱된 범위를 넘어간 값이라 false이다.

그러니 아무튼 primitive 타입이 아니라면 equals로 비교하자.

힙에 들어간 애들의 경우 ==으로 비교하는게 당연히 더 속도가 빠르다.

(TMI - jvm 옵션으로 AutoBoxCacheMax을 줘서 캐싱되는 범위를 조절할 수 있다. )

오토박싱 관련 내용도 중요하다.

사실 int는 4바이트이고 Integer는 20바이트정도이다.

즉, 시간 뿐 아니라 메모리 차이도 있으므로 아무생각 없이 오토박싱되게 하면 여러모로 비효율적이다.

오토박싱(Autoboxing)과 언박싱(Unboxing)은 Java 1.5 Version에 도입된 기능으로, 원시 타입(Primitive Type)에서 래퍼 클래스(Wrapper Class) 타입으로 또는 반대로 자동 변환하는 것을 말합니다.

오토박싱(Autoboxing)과 언박싱(Unboxing)은 Java 1.5 Version에 도입된 기능으로, 원시 타입(Primitive Type)에서 래퍼 클래스(Wrapper Class) 타입으로 또는 반대로 자동 변환하는 것을 말한다.

오토박싱은 변환 과정에서 메모리의 동적 할당과 각 원시 타입에 대한 객체 초기화가 포함되며, 객체를 명시적으로 생성할 필요가 없다. 예를 들어 int를 Integer 클래스로 변환한다.

원시타입:

  • 기본형이라고 부르고, 실제 연산에 사용되는 변수이다.(int, short,long,char…)
  • 반드시 사용전에 선언되어야 한다.
  • 비객체로, null 값을 가질 수 없다.

래퍼 클래스(Wrapper Class)

  • 인수로 데이터 타입을 전달받아, 해당 값을 가지는 객체로 만들어주는 ㅡㅋㄹ래스.
  • 내부에 멤버 변수가 final로 선언되어 있다.
  • 기본 타입을 객체로 변환하는 과정이 박싱, 반대가 언박싱이다.
  • 자동화된 박싱과 언박싱이 오토박싱, 오토 언박싱이다.
  • 동등 연산자(==)가 아닌 equals()메소드를 이용해야 한다. (동등 연산자는 주소값을 비교하므로)
profile

만쥬의 개발일기

@KangManJoo

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!