[Effective Java] 아이템 17 : 변경 가능성을 최소화하라

Loopy·2022년 7월 1일
0

이펙티브 자바

목록 보기
16/76
post-thumbnail

불변 클래스란, 인스턴스의 내부 값을 수정할 수 없는 클래스이며 불변 인스턴스에 간직된 정보는 고정되어 객체가 파괴되는 순간까지 절대 달라지지 않는다.대표적인 immutable 클래스에는 String, Boolean, Integer, Float, Long 등이 존재한다.

🔖 주의할 점
객체 데이터의 수정이 불가능 한 것이 아니라 힙 영역에 저장된 값의 수정이 불가능한 것을 의미. 즉, 재할당은 가능

🔖 final 키워드
불변을 만드는 키워드가 아닌, 재할당을 금지하는 키워드
final로 선언된 list는 add, remove, set과 같은 함수를 통해 list의 안 element를 변경 가능하다. 단지 final로 선언된 list는 항상 같은 list 객체를 참조하고 있을 뿐!

  final List<Integer> results = getResult();
  results = getResult();  // 재할당 시 에러 발생

ex) 자바 플랫폼 라이브러리의 String, BigInteger, BigDeciaml, 기본 타입의 박싱된 클래스들...

🔗 클래스를 불변으로 만드는 다섯 가지 규칙

1. 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.

  • 대표적인 예시로 게터와 세터 메서드가 있다.
  • 해당 메서드를 제공하는 대신 반대로 객체에 메세지를 던지자!

2. 클래스를 확장할 수 없도록 한다.

  • 하위 클래스에게 부주의하게 혹은 나쁜 의도로 객체의 상태를 변하게 만드는 사태를 막아준다. 상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이지만, 다른 방법도 존재한다.

3. 모든 필드를 final로 선언한다.

  • 시스템이 강제하는 수단을 이용해 설계자의 의도를 명확히 드러내는 방법이다. 새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하는 데도 필요하다.

4. 모든 필드를 private으로 선언한다.

  • 필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다. 기본 타입 필드나 불변 객체를 참조하는 필드를 public final로만 선언해도 불변 객체가 되지만, 다음 릴리스에서 내부 표현을 바꾸지 못하므로 권하지 않는다.(아이템 15,16)

5. 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

  • 방어적 복사에 관한 이야기이다.
  • 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면, 클라이언트에서 그 객체의 직접적인 참조를 얻을 수 없도록 해야 한다. 생성자, 접근자, readObject() 메서드(아이템 88) 모두에서 방어적 복사를 수행하라.

🔗 예시 불변 클래스

불변 복소수 클래스

public final class Complex {
    private final double re;
    private final double im;

    public static final Complex ZERO = new Complex(0, 0);
    public static final Complex ONE  = new Complex(1, 0);
    public static final Complex I    = new Complex(0, 1);

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public double realPart()      { return re; }
    public double imaginaryPart() { return im; }

    public Complex plus(Complex c) {
        return new Complex(re + c.re, im + c.im);
    }

    // 코드 17-2 정적 팩터리(private 생성자와 함께 사용해야 한다.)
    public static Complex valueOf(double re, double im) {
        return new Complex(re, im);
    }

    public Complex minus(Complex c) {
        return new Complex(re - c.re, im - c.im);
    }

    public Complex times(Complex c) {
        return new Complex(re * c.re - im * c.im,
                re * c.im + im * c.re);
    }

    public Complex dividedBy(Complex c) {
        double tmp = c.re * c.re + c.im * c.im;
        return new Complex((re * c.re + im * c.im) / tmp,
                (im * c.re - re * c.im) / tmp);
    }

    @Override public boolean equals(Object o) {
        if (o == this)
            return true;
        if (!(o instanceof Complex))
            return false;
        Complex c = (Complex) o;

        // == 대신 compare를 사용하는 이유는 63쪽을 확인하라.
        return Double.compare(c.re, re) == 0
                && Double.compare(c.im, im) == 0;
    }
    @Override public int hashCode() {
        return 31 * Double.hashCode(re) + Double.hashCode(im);
    }

    @Override public String toString() {
        return "(" + re + " + " + im + "i)";
    }
}

해당 클래스에서는, 사칙연산 메서드들이 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 만들어 반환하고 있다.

이처럼 피연산자에 함수를 적용해 그 결과를 반환하지만, 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라 한다. 함수형 프로그래밍을 사용한다면, 코드에서 불변이 되는 영역을 증가시킬 수 있다.

🔖 함수형 프로그래밍과 달리 절차형/명령형 프로그래밍에서는 메서드에서 피연산자인 자신을 수정해 자신의 상태가 변하게 된다!
ex) 전역 변수

또한, 해당 메서드가 객체의 값을 변경하지 않는다는 사실을 강조하기 위해 메서드 이름으로 동사(add)보다 전치사(plus)를 사용하였다.(참고로 BigInteger, BigDecimal은 해당 명명 규칙을 따르지 않음)

🔗 불변 클래스 특성 및 장점

1️⃣ 불변 객체는 생성된 시점의 상태를 파괴될 때까지 그대로 간직한다.

2️⃣ 불변 객체는 근본적으로 스레드에 안전하여 따로 동기화할 필요가 없다.

그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니, 불변 객체는 안심하고 공유할 수 있다. 따라서, 한번 만든 인스턴스를 최대한 재활용하자.

💡불변 객체 재사용 방법
가장 쉬운 방법은 자주 쓰이는 값들을 상수(public static final) 로 만드는 것이다.

public static final Complex ZERO = new Complex(0, 0);
public static final Complex ONE  = new Complex(1, 0);
public static final Complex I    = new Complex(0, 1);

3️⃣ 불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해주는 정적 팩터리를 제공할 수 있다.

여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다. 예시로는 박싱된 기본 타입 클래스들, BigInteger가 존재한다.

4️⃣ 불변 객체가 공유 가능하다는 점은 방어적 복사가 필요 없어지니, clone 메서드나 복사 생성자을 제공하지 말아야 한다.

5️⃣ 불변 객체는 자유롭게 공유할 수 있음은 물론, 불변 객체끼리는 내부 데이터를 공유할 수 있다.

자바의 BigInteger 클래스를 예시로 들어보자.
BingInteger 클래스는 부호에는 int 변수를, 크기(절댓값)에는 int 배열을 사용한다.

BigInteger.negate() 는 크기가 같고 부호만 반대인 새로운 BigInter를 생성하는데, 이때 절댓값인 배열은 비록 가변이지만 복사하지 않고 원본 인스턴스와 공유해도 된다. 따라서 새로 만든 BigInteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다.

6️⃣ 불변 객체는 그 자체로 실패 원자성을 제공한다.(아이템 76) 상태가 절대 변하지 않으니 불일치 상태에 빠질 가능성이 없다.

💡 실패 원자성?
메서드에서 예외가 발생한 후에도 그 객체는 여전히 유효한 상태여야 함

🔗 불변 클래스 단점

1️⃣ 값이 다르면 반드시 독립된 객체로 만들어야 한다.

예를 들어, 백만 비트짜리 BigInteger에서 비트 하나를 바꿔야 한다고 해보자.

BigInteger

BigInteger moby = ...;
moby = moby.flipBit(0);  //새로운 BigInteger 인스턴스 생성

flipBit()는 원본과 단지 한 비트만 다른 인스턴스를 생성하기 때문에, 이 연산은 BigInteger의 크기에 비례해 시간과 공간을 잡아먹는다.

BigSet

BigSet moby = ...;  // BigInteger과 달리 가변
moby.flip(0);

반대로 BigSet는 가변이기 때문에, flip() 메서드를 통해 원하는 비트 하나만 상수 시간 안에 바꿀 수 있다.

2️⃣ 원하는 객체를 완성하기까지의 단계가 많고, 중간 단계에서 만든 객체들이 모두 버려진다면 성능 문제가 더 대두된다.

이 문제에 대처하는 방법은 두가지이다.

  1. 다단계 연산들을 예측하여, 기본 기능으로 제공한다.
    예를 들어, BigInteger는 모듈러 지수 같은 다단계 연산 속도를 높여주는 가변 동반 클래스를 package-private으로 두고 있다.

  2. 위처럼 클라이언트들이 원하는 복잡한 연산을 정확히 예측할 수 없다면, 해당 클래스를 public으로 제공하는 것이 최선이다.
    예를 들어, String 클래스의 가변 동반 클래스는 StringBuilder 이고 해당 클래스의 기능들을 public 으로 제공하고 있다.

🔗 불변 클래스를 만드는 다른 설계 방법

클래스가 불변임을 보장하려면, 자신을 상속하지 못하게 해야 한다.

가장 쉬운 방법인 final 클래스를 통해서도 가능하지만, 모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩터리를 제공하는 방법도 존재한다.

생성자 대신 정적 팩터리를 사용한 불변 클래스

public class Complex {
	private final double re;
    private final double im;
    
    private Complex(double re, double im) {
    	this.re = re;
        this.im = im;
    }
  
    public static Complex valueOf(double re, double im){
    	return new Complex(re, im);
    }
    ...
}

public 이나 protected 생성자가 없으니, 다른 패키지에서 이 클래스를 확장하는 것이 불가능하다.

이처럼 정적 팩터리 방식은 1)다수의 구현 클래스를 활용한 유연성을 제공하고, 2)다음 릴리스에서 객체 캐싱 기능을 추가해 성능을 끌어올릴 수도 있다.

추가 사항

  1. BigIntegerBigDecimal에는 방어적 복사를 사용하자.

    이 두 클래스의 메서드들은 모두 하위 클래스에서 재정의할 수 있게 설계되었기 때문이다. 따라서, 이처럼 신뢰할 수 없는 하위 클래스라고 확인되면 인수들은 불변이 아니라 가변이라 가정하고 방어적으로 복사해 사용해야 한다.

public static BigInteger safeInstance(BigInteger val) {
    // 하위 클래스임이 보장되면 그대로 반환, 아니라면 방어적 복사 수행
	return val.getClass() == BigInteger.class ?
    		val : new BigInteger(val.toByteArray());
  1. 불변 클래스라면 계산 비용이 큰 값을 처음 쓰일때만 계산하고 final이 아닌 필드에 캐시해 재사용할 수 있다. 불변이기 때문에 몇번을 계산해도 항상 같은 결과가 만들어짐이 보장되기 때문이다.

    hashCode 지연 초기화 전략

    private int hashCode;
    
    @Override public int hashCode() {
        int result = hashCode;
        if (result == 0) {
            result = Short.hashCode(areaCode);
            ...
            hashCode = result;   // final이 아닌 필드에 캐싱
        }
        return result;
    }

    참고로 지연 초기화(아이템 83)의 예이기도 한 이 기법을 String도 사용한다.

📚 정리

1) 게터 메서드가 있다고 해서 세터 메서드를 만들지 말자.

2) 클래스는 꼭 필요한 경우가 아니라면 불변이여야 한다.

단순 값 객체는 항상 불변으로 만들어야 하며, String이나 BigInteger 처럼 무거운 값 객체도 불변으로 만들 수 있는지 고심해야 한다. 성능 때문에 어쩔 수 없다면(아이템 67) StringBuilder와 같이 불변 클래스와 쌍을 이루는 가변 동반 클래스를 public 클래스로 제공하자.

2) 불변으로 만들 수 없는 클래스라도, 변경할 수 있는 부분을 최소한으로 줄여야 한다. 즉, 변경해야할 필드를 뺀 나머지 모두를 final로 선언하고 다른 합당한 이유가 없다면 모든 필드는 private final 이여야 한다.

3) 생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다.

생성자와 정적 팩터리 외에는 어떤 초기화 메서드도 public 으로 제공해서는 안된다. 또한, 객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안된다.


2022.11.11 추가사항

우아한 테크코스 프리코스를 수행하는 도중 다음과 같이 코드를 짰다.

public final class Player {

     private Balls balls;

     public void setInputBalls() {   // 객체 재활용 목적으로 상태 초기화하는 메서드
         String number = Console.readLine();
         balls = Balls.valueOf(number); 
     }
     ...
     public List<Integer> getBalls() {
         return balls.asList();
    }
}

setInputBalls() 는 객체 생성자도 아니고 정적 팩터리 메서드도 아니다. 매 반복문마다 객체를 생성하는 비용이 비싸다고 잘못 오해하여, 객체를 재활용하기 위해 상태를 초기화 하는 메서드를 작성해주었다. 사실상 저렇게 해두면 불변 객체의 의미가 사라지는 것인데 필드를 private 으로 한 것도 웃기고 정말 아무것도 모르고 짰구나 하는 생각이 든다.

public class Balls {
     private final List<Integer> balls;

     public Balls(List<Integer> balls) {
         this.balls = balls;
     }
     
     public static Balls valueOf(String number) {
         validateBall(number);
         List<Integer> list = Stream.of(number.split(""))
                 .map(Integer::valueOf)
                 .collect(Collectors.toList());

         return new Balls(list);
     }
     ...
     
     public List<Integer> asList() {
         return new ArrayList<>(balls);
     }
}

불변 객체를 만들고자 private final을 선언했다. 지금 보니 class에 상속을 금지하는 final 키워드도 없고, 생성자는 private이 아닌 public이여서 완벽한 불변 객체는 아니다. 웃긴거는 정적 팩터리를 사용할때는 생성자가 private 이어야 하는데 아무것도 모른채 정적 팩터리 메서드를 쓴 점이다.

또한, 불변 객체라고 가정했어도 asList() 에서 방어적 복사를 하고 있다. 방어적 복사는 가변 객체에 해야 하며, 불변 객체에 해도 어짜피 같은 값을 매번 반환하니 아무 의미가 없어진다. 그냥 return balls로 해주어도 되었다.

public final class Computer {

    private Balls balls;

    public Computer() {
        this.balls = new Balls(createRandomNumber());
    }

    public void generateNewBalls() {
        this.balls = new Balls(createRandomNumber());
    }
    ...  
    public List<Integer> getBalls() {  
        return balls.asList();
    }
}

이거도 불변을 막기 위해 final 클래스로 만들었는데 정작 필드는 final도 아니고 재활용 하기 위해 매번 값을 초기화할 수 있는 메서드도 있다. 생성자도 private으로 선언 안되어 있고 정말 얕은 지식으로 구현했다고 느껴진다.

참고 자료
https://mangkyu.tistory.com/131

profile
개인용으로 공부하는 공간입니다. 잘못된 부분은 피드백 부탁드립니다!

0개의 댓글