객체 생성과 파괴

haaaalin·2023년 10월 14일
0

Effective Java

목록 보기
1/2
post-thumbnail

Static Factory Method

클래스의 인스턴스를 얻는 수단 public 생성자 말고도, 일명 정적 팩토리 메서드를 제공할 수 있다. API 서버를 개발할 때 자주 사용하는 mapper 메서드가 생각났다. (toEntity, toDto 등등)

아래는 boolean 기본 타입의 boxed class인 Boolean의 일부분이다.

기본 타입인 boolean 값을 받아, Boolean 객체 참조로 변환하고 있다.

public static Boolean valueOf(boolean b) {
	return (b ? TRUE : FALSE);
}

1. 정적 팩토리 메서드의 장점

1) 이름을 가질 수 있다

생성자는 매개변수와 생성자 자체만으로 반환될 객체의 특성을 잘 나타내지 못한다.

하지만, 정적 팩토리 메서드는 메서드명만으로도 어떤 객체를 반환할 지 나타낼 수 있다.

아래 코드에서 어떤 메서드가 ‘값이 소수인 BigInteger를 반환한다’는 의미를 더 잘 설명할 수 있을까?

BigInteger(int a, int b, Random);
vs
BigInteger.probabalePrime();

2) 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다

정적 팩터리 메서드를 사용하면, 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 방식을 사용할 수 있다.

이와 비슷한 기법으로는 플라이웨이트 패턴(Flyweight Pattern)이 있다.

💡 플라이웨이트 패턴이란?

객체를 가볍게 만들어 메모리 사용을 줄이는 패턴이다.

팩토리 클래스를 만들어, 공통으로 사용하는 클래스를 생성하도록 하고, 인스턴스를 최초 1개만 생성하고 공유하여 재사용할 수 있도록 하는 구조 패턴이다.

❔ 그렇다면 싱글톤 패턴과 같은 게 아닌가?

⇒ 싱글톤 패턴은 단 1개의 인스턴스를 가지도록 제한하고, 플라이웨이트 패턴은 인스턴스를 팩토리가 제어한다. 즉 인스턴스 생성을 누가 제어하느냐 ‼️

3) 반환 타입의 하위 타입 객체를 반환할 수 있다

객체의 클래스를 자유롭게 선택할 수 있는 엄청난 유연성을 얻을 수 있다.

구현 클래스를 공개하지 않고, 그 객체를 반환 가능하다. 인터페이스에 정적 메서드를 작성해 놓으면(자바 8부터 가능해졌다) 클라이언트는 인터페이스를 통해 원하는 객체를 얻을 수 있다.,

4) 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다

아래는 public 생성자 없이, 오직 정적 팩터리만 제공하는 EnumSet 클래스 일부분이다.

원소의 개수에 따라 64개 이하면 RegularEnumSet를, 65개 이상이면 JumboEnumSet를 반환한다.

public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
	Enum<?>[] universe = getUniverse(elementType);
	if (universe == null)
	    throw new ClassCastException(elementType + " not an enum");
	
	if (universe.length <= 64)
	    return new RegularEnumSet<>(elementType, universe);
	else
	    return new JumboEnumSet<>(elementType, universe);
}

클라이언트는 정적 팩터리 메서드가 반환하는 객체가 어느 클래스의 인스턴스인지 알 수도 없고, 알 필요도 없다. 그저 EnumSet의 하위 클래스기만 하면 된다.

5) 정적 팩터리 메서드를 작성할 때, 반환할 객체의 클래스가 존재하지 않아도 된다

이런 유연함은 서비스 제공자 프레임워크를 만드는 근간이 된다.

[서비스 제공자 프레임워크 핵심 컴포넌트]

  • 서비스 인터페이스: 구현체의 동작 정의
  • 제공자 등록 API: 제공자가 구현체를 등록
  • 서비스 접근 API: 클라이언트가 서비스의 인스턴스를 얻을 때 사용 ⇒ 서비스 제공자 프레임워크의 근간
  • 서비스 제공자 인터페이스 - SPI(종종 사용): 서비스 인터페이스의 인스턴스를 생성하는 팩터리 객체 설명

이게 대체 무슨 말일까? JDBC를 예로 들어보자.

JDBC란?

Java에서 Database를 사용할 수 있게 해주는 API이다.

  • 서비스 인터페이스: JDBC의 Connection
  • 제공자 등록 API: DriverManager.registerDriver
public static void registerDriver(java.sql.Driver driver) throws SQLException {
	registerDriver(driver, null);
}
  • 서비스 접근 API: DriverManager.getConnection
public static Connection getConnection(String url,
    String user, String password) throws SQLException {
  java.util.Properties info = new java.util.Properties();

  if (user != null) {
      info.put("user", user);
  }
  if (password != null) {
      info.put("password", password);
  }

  return (getConnection(url, info, Reflection.getCallerClass()));
}
  • 서비스 제공자 인터페이스: Driver

2. 정적 팩토리 메서드의 단점

1) 하위 클래스를 만들 수 없다

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

불변 타입 클래스를 만들 경우 또는 상속보다는 컴포지션을 유도할 때에는 장점이 될 수 있다.

👀 컴포지션이란?
기존 클래스를 확장하는 대신, 새로운 클래스를 만들고, private 필드로 기존 클래스의 인스턴스를 참조하는 방법을 통해 기능을 확장시키는 것

아래처럼 Set 클래스를 상속 받았지만, 확장해나가지 않고 private 필드로 참조하는 s의 메서드를 호출해 그 결과를 반환한다.

이렇게 하면 ForwardingSet 클래스는 기존 Set 클래스의 내부 구현 방식 영향에서 완전히 벗어날 수 있다.

public class ForwardingSet<E> implements Set<E> {

    // private 필드로 기존 클래스의 인스턴스 참조
    private final Set<E> s;
    public ForwardingSet(Set<E> s){
        this.s = s;
    }

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

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

		...
}

2) 정적 팩토리 메서드를 찾기 어렵다

생성자처럼 명확히 드러나지 않으니, 사용자는 해당 클래스를 어떻게 인스턴스화할지 알아내야 한다.

암묵적으로 사용되는 정적 팩토리 메서드 명명 규칙을 이용해 메서드명을 지으면 보완할 수 있다.

생성자에 매개변수가 많을 땐? 빌더!

정적 팩토리 메서드와 생성자는 선택적 매개변수가 많을 때 곤란하다.

이럴 때 점층적 생성자 패턴을 사용하곤 했다.

1. 점층적 생성자 패턴

필수 매개변수만 받는 생성자, 필수 매개변수와 선택 매개변수 1개를 받는 생성자… 이와 같이 생성자를 늘려가는 방식

하지만 매개변수에 비례해 늘어나는 생성자로 인해 점층적 생성자 패턴은 쓸 수는 있지만, 매개변수 개수가 많아지면 코드 가독성이 너무 떨어진다.

2. 자바빈즈 패턴

매개변수가 없는 생성자로 객체를 만든 후, setter를 호출해 원하는 매개변수의 값을 설정하는 방식

치명적인 단점

객체 하나를 만들기 위해, setter 즉 메서드를 여러개 호출해야하고 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태라는 것.

  • 일관성이 깨진 객체로 인한 버그 발생 가능
  • 클래스를 불변으로 생성 불가능

freezing

위의 치명적인 단점을 보완하기 위한 방법으로, 생성이 끝난 객체를 freezing하고, freezing 하기 전에는 사용할 수 없도록 하는 방법

하지만 다루기 어려운 방법이라 거의 쓰이지 않고, 객체 사용 전에 사용자가 freezing을 했는지 컴파일러가 보증할 방법이 없어 런타임 오류에 취약하다.

3. 빌더 패턴

점층적 생성자 패턴의 안정성 & 자바빈즈 패턴의 가독성을 가진 빌더 패턴

1) 빌더 패턴으로 객체 생성하는 방법

  • 필수 매개변수만으로 생성자를 호출해, 빌더 객체 얻기
  • 빌더 객체가 제공하는 일종의 setter로 원하는 선택 매개변수 설정
  • 매개변수가 없는 build()를 호출해 원하는 객체 생성
public class NutritionFacts {
    private final int servingSize;
    private final int servings;
    private final int calories;
    private final int fat;
    private final int sodium;
    private final int carbohydrate;

    public static class Builder {
        // 필수 매개변수
        private final int servingSize;
        private final int servings;

        // 선택 매개변수 - 기본값으로 초기화한다.
        private int calories      = 0;
        private int fat           = 0;
        private int sodium        = 0;
        private int carbohydrate  = 0;

        public Builder(int servingSize, int servings) {
            this.servingSize = servingSize;
            this.servings    = servings;
        }

        public Builder calories(int val)
        { calories = val;      return this; }
        public Builder fat(int val)
        { fat = val;           return this; }
        public Builder sodium(int val)
        { sodium = val;        return this; }
        public Builder carbohydrate(int val)
        { carbohydrate = val;  return this; }

        public NutritionFacts build() {
            return new NutritionFacts(this);
        }
    }

    private NutritionFacts(Builder builder) {
        servingSize  = builder.servingSize;
        servings     = builder.servings;
        calories     = builder.calories;
        fat          = builder.fat;
        sodium       = builder.sodium;
        carbohydrate = builder.carbohydrate;
    }

    public static void main(String[] args) {
        NutritionFacts cocaCola = new NutritionFacts.Builder(240, 8)
                .calories(100).sodium(35).carbohydrate(27).build();
    }
}

2) 계층적으로 설계된 클래스와 함께 쓰기

각 계층의 클래스에 관련 빌더를 멤버로 정의해 추상 클래스는 추상 빌더, 구체 클래스는 구체 빌더를 갖게 하자.

public abstract class Pizza {
    public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
    final Set<Topping> toppings;

    abstract static class Builder<T extends Builder<T>> {
        EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
        public T addTopping(Topping topping) {
            toppings.add(Objects.requireNonNull(topping));
            return self();
        }

        abstract Pizza build();

        // 하위 클래스는 이 메서드를 재정의(overriding)하여
        // "this"를 반환하도록 해야 한다.
        protected abstract T self();
    }
    
    Pizza(Builder<?> builder) {
        toppings = builder.toppings.clone(); // 아이템 50 참조
    }
}

하위 클래스의 메서드가 상위 클래스의 메서드가 정의한 반환 타입이 아닌, 그 하위 타입을 반환하는 기능을 공변 반환 타이핑(covariant return typing)이라고 한다.

NyPizza.Builder는 NyPizza를 반환

public class NyPizza extends Pizza {
    public enum Size { SMALL, MEDIUM, LARGE }
    private final Size size;

    public static class Builder extends Pizza.Builder<Builder> {
        private final Size size;

        public Builder(Size size) {
            this.size = Objects.requireNonNull(size);
        }

        @Override public NyPizza build() {
            return new NyPizza(this);
        }

        @Override protected Builder self() { return this; }
    }

    private NyPizza(Builder builder) {
        super(builder);
        size = builder.size;
    }
}

3) 빌더 패턴의 단점

  • 성능에 민감하다면, 빌더 생성 비용이 문제가 될 수 있다
  • 코드가 장황해서 매개변수 4개 이상은 되어야 값어치를 한다

→ 하지만 어차피 API는 시간이 지날수록 매개변수가 많아지므로, 시작부터 빌더를 사용하자.

싱글톤을 보장하기

싱글톤이란?

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

보통 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트를 싱글톤으로 설계한다.

싱글톤을 만드는 방식은 3가지인데, 두 방식 모두 생성자는 private으로 감추고, 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

1. 싱글톤을 만드는 방식 - public static final

인스턴스에 접근하는 수단인 public static 멤버가 final 필드인 방식이다.

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

    private Elvis() { }

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}

장점

  • 해당 클래스가 싱글톤임이 명백히 드러난다.
  • final 선언으로, 절대로 다른 객체를 참조할 수 없다.
  • 간결하다.

2. 싱글톤을 만드는 방식 - 정적 팩토리 메서드

정적 팩토리 메서드를 public static 멤버로 제공한다.

getInstance 는 항상 같은 객체의 참조를 반환하므로, 유일한 인스턴스를 유지할 수 있다.

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

    public void leaveTheBuilding() {
        System.out.println("Whoa baby, I'm outta here!");
    }
}

장점

  • API를 바꾸지 않고도 싱글톤이 아니게 변경 가능하다.
  • 정적 팩토리를 제너릭 싱글톤 팩토리로 만들 수 있다.

3. 싱글톤을 만드는 방식 - 열거 타입 선언

대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글톤을 만드는 가장 좋은 방법

원소가 하나인 enum 타입을 선언하는 방법이다.

public enum Elvis {
    INSTANCE;

    public void leaveTheBuilding() {
        System.out.println("기다려 자기야, 지금 나갈께!");
    }
}

특징

  • public 필드 방식과 비슷하지만, 더 간결하고 추가 노력 없이 직렬화 가능
  • 만들려는 싱글톤이 Enum 외의 클래스를 상속해야 한다면, 사용 불가

인스턴스화 방지용 private 생성자

java.lang.Mathjava.util.Collections처럼 객체를 생성해주는 정적 메서드를 모아놓거나, 정적 멤버만 담은 유틸리티 클래스 등과 같은 클래스는 인스턴스로 만들어 쓰려는 게 아니다.

private 생성자를 추가해, 클래스의 인스턴스화를 막을 수 있다.

인스턴스화를 막기 위해 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어주고, 추상 클래스로 만들어도, 하위 클래스를 만들어 인스턴스화할 수 있다.

인스턴스화도 막을 수 있고, 상속도 불가능하게 하는 효과도 있다.

의존 객체 주입을 사용하자

사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글톤 방식이 적합하지 않다.

즉, 클래스가 여러 자원 인스턴스를 지원해야 하며, 클라이언트가 원하는 자원을 사용할 수 있어야 한다면?

1. 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식

public class SpellChecker {
	private final Lexicon dictionary;
	
	public SpellChecker(Lexicon dictionary) {
		this.dictionary = Objects.requireNonNull(dictionary);
	}
}

의존 객체 주입은 생성자뿐만 아니라, 정적 팩토리, 빌더 모든 방식에 똑같이 응용할 수 있다.

2. 변형 - 생성자에 자원 팩토리를 넘겨주는 방식

팩토리: 호출할 떄마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체

이 방식으로, 클라이언트는 자신이 명시한 타입의 하위 타입이라면 무엇이든 생성할 수 있는 팩토리를 넘길 수 있다.

Mosaic create(Supplier<? extends Tile> tileFactory) {...}

주의할 점

의존 객체 주입이 유연성과 테스트 용이성을 제공하지만, 의존성이 많아진다면 코드가 어지러워질 수 있다. 이때 의존 객체 주입 프레임워크(ex 스프링)를 사용한다면 해소할 수 있으니 참고하자.

불필요한 객체 생성 피하기

static boolean isRomanNumeralSlow(String s) {
	return s.matches("^(?=.)M*(C[MD]|D?C{0,3})"
	        + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

이 방식의 문제는 String.matches 메서드를 사용한다는 데 있다.

String.matches 메서드 안에서 만들어지는 정규표현식용 Pattern 인스턴스는 한 번 쓰고 버려지는데,

Pattern은 입력 받은 정규표현식에 해당하는 유한 상태 머신을 만들기 때문에 인스턴스 생성 비용이 높다.

⇒ 결론적으로, 생성 비용이 비싼 Pattern의 불필요한 생성을 피해야한다.

아래처럼 미리 Pattern 인스턴스를 생성해 캐싱해두고, 메서드가 호출될 때마다 이 인스턴스를 사용하도록 하면 성능을 상당히 끌어올릴 수 있다.

public class RomanNumerals {
    private static final Pattern ROMAN = Pattern.compile(
            "^(?=.)M*(C[MD]|D?C{0,3})"
                    + "(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

    static boolean isRomanNumeralFast(String s) {
        return ROMAN.matcher(s).matches();
    }
}

오토박싱

불필요한 객체를 만들어 내는 또 다른 예시

오토박싱: 기본 타입과 박싱된 기본 타입을 섞어 쓸 때, 자동으로 상호 변환해주는 기술 (기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것 X)

아래 코드를 살펴보자.

public class Sum {
    private static long sum() {
        Long sum = 0L;
        for (long i = 0; i <= Integer.MAX_VALUE; i++)
            sum += i;
        return sum;
    }
} 

sum 변수를 long 이 아닌 Long 으로 선언해 불필요한 Long 인스턴스가 for문이 반복하는 횟수만큼 만들어지고 있다.

객체 풀은 만들지 말자

이번 내용은 “객체 생성을 가급적 줄이자”로 오해하지 말아야 한다. 사실 객체 생성과 회수는 JVM에서는 간단한 일이다.

객체 생성을 피하고자 객체 풀을 만든다면, 자체 객체 풀은 코드의 가독성을 떨어뜨리고, 메모리 사용량을 늘리며 성능을 떨어뜨린다.

다 쓴 객체 참조를 해제하자

자바는 C,C++처럼 메모리를 직접 관리하지 않아도 된다. 하지만 메모리 관리에 더 이상 신경 쓰지 않아도 된다는 것은 아니다.

1. 메모리 누수

아래 스택을 오래 사용하면 ‘메모리 누수’가 발생한다.

따라서 가비지 컬렉션 활동과 메모리 사용량이 늘어나 성능이 저하되고, 심할 경우, 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 종료될 수 있다.

public class Stack {
    private Object[] elements;
    private int size = 0;
    private static final int DEFAULT_INITIAL_CAPACITY = 16;

    public Stack() {
        elements = new Object[DEFAULT_INITIAL_CAPACITY];
    }

    public void push(Object e) {
        ensureCapacity();
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        return elements[--size];
    }

    private void ensureCapacity() {
        if (elements.length == size)
            elements = Arrays.copyOf(elements, 2 * size + 1);
    }
}

메모리 누수가 일어나는 이유

잘 보면, 스택의 크기가 커졌다가 줄어들 때, 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다.

스택이 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다.

객체 참조 하나를 살려둔다면, 가비지 컬렉터는 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다.

⇒ 쓰레기 하나인 줄 알았지만, 알고보니 쓰레기 봉투였던 것..!

2. 메모리 누수 해결 - null 처리

해당 참조를 다 썼을 때 null 처리해주기

public class Stack {
    public Object pop() {
        if (size == 0)
            throw new EmptyStackException();
        Object result = elements[--size];
        elements[size] = null; // 다 쓴 참조 해제
        return result;
    }
}

null 처리를 해주는 것은, 실수로 null 처리가 된 객체를 참조하려 할 때 NullPointerException을 던지면 종료시킬 수 있지만, 코드가 더러워지는 것은 사실이다.

언제 null 처리를 해주면 될까?

일단 null처리가 왜 필요한 지부터 다시 생각해보자.

stack 같은 클래스는 메모리를 자기가 직접 관리하기 때문에 클래스 내에서는 활성 영역, 비활성 영역으로 구분하지만 가비지 컬렉터는 이를 알 길이 없다. 따라서 가비지 컬렉터는 똑같이 유효한 객체처럼 보이기 때문에 아무 처리를 하지 않는다.

가비지 컬렉터에게 더 이상 참조하지 않는다는 것을 알리기 위해, 클래스 내에서 정의한 비활성 영역이 되자마자, null처리를 해주면 된다.

finalizer와 cleaner 사용을 피하자

1. finalizer와 cleaner가 뭘까?

자바가 제공하는 객체 소멸자

finalizer

예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다. 오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다. 자바 9부터는 finalizer를 deprecated API로 지정하고, cleaner를 대안으로 소개하고 있다.

cleaner

finalizer보다는 덜 위험하지만, 예측할 수 없고, 느리고, 일반적으로 불필요하다.

2. 제때 실행되지 않는 finalizer & cleaner

finalizer와 cleaner는 즉시 수행된다는 보장이 없다.

따라서 , 예를 들어 파일 닫기 등과 같은 제때 실행되어야 하는 작업을 finalizer 또는 cleaner에게 맡기면 위험하다. 소멸자 실행이 되지 않아, 파일이 안 닫힌다면 새로운 파일을 열지 못해 프로그램이 예기치 못한 종료가 될 수 있다.

profile
한 걸음 한 걸음 쌓아가자😎

0개의 댓글