JAVA/Effective Java

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

꾸준함. 2024. 2. 5. 00:03

불변 클래스

불변 클래스란 인스턴스의 내부 값을 수정할 수 없는 클래스이며 immutable class라고 지칭합니다.

불변 클래스는 가변 클래스보다 설계, 구현 및 사용하기 쉽고 오류가 생길 여지가 적기 때문에 훨씬 안전합니다.

특히 멀티 쓰레드 환경에서 안전하게 사용할 수 있으며 값이 바뀌지 않기 때문에 캐싱을 할 수 있어 성능적으로도 유리합니다.

 

불변 클래스를 만드는 원칙

 

불변 클래스를 만드는 원칙은 아래와 같이 총 다섯 가지입니다.

  • 객체의 상태를 변경하는 메서드를 제공하지 않는다
  • 클래스를 확장할 수 없도록 한다
  • 모든 필드를 final로 선언한다
  • 모든 필드를 private으로 선언한다
  • 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

 

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

불변 객체의 값은 오직 인스턴스를 초기화할 때만 설정할 수 있으며 이 때문에 setter 메서드를 제공하면 안 됩니다.

정리하자면 값의 상태는 생성자 혹은 정적 팩토리 메서드를 통해서만 설정할 수 있습니다.

 

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

클래스를 상속 가능하게 만들 경우 하위 클래스에서 상태를 변경할 수 있습니다.

따라서 상속을 막기 위해 클래스 자체를 final로 선언하거나 public 생성자를 막아야 합니다.

 

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

불변 객체는 멀티 쓰레드 환경에서도 값이 변경되면 안 됩니다.

따라서 최대한 많은 필드를 final로 선언하여 멀티 쓰레드 환경에서 안전하게끔 설계를 하고 해당 필드가 변경되지 않을 것이라는 의도를 명시적으로 표시해야 합니다.

 

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

필드를 private으로 선언하면 클라이언트가 직접 접근하여 수정하는 것을 방지해 줍니다.

 

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

가변 컴포넌트 값은 얼마든지 내용을 변경할 수 있기 때문에 클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 객체의 참조를 얻을 수 없도록 처리해야 합니다.

따라서 getter 메서드를 통해 가변 컴포넌트를 반환할 때 새로운 인스턴스를 반환하는 방어적 복사를 수행해야 합니다.

 

 

불변 클래스의 장단점

 

장점

  • 함수형 프로그래밍에 적합
  • 불변 객체는 단순
  • thread-safe 하고 별도로 동기화할 필요 없음
  • 불변 객체는 안심하고 공유 가능
  • 불변 객체끼리는 내부 데이터를 공유 가능
  • 객체를 만들 때 불변 객체로 구성하면 이점이 많음
  • 실패 원자성 제공

 

단점

  • 값이 다를 경우 반드시 별도의 객체로 만들어야 함

 

저자가 작성한 복소수 클래스인 Complex를 기준으로 위에 언급한 장단점에 대해 부연 설명을 진행하겠습니다.

 

 

 

장점 1. 함수형 프로그래밍에 적합

Complex의 사칙연산 메서드인 plus, minus, times, dividedBy를 확인해 보면 인스턴스 자신은 수정하지 않고 새로운 Complex 인스턴스를 생성해 반환하는 모습을 확인할 수 있습니다. (5번 원칙)

이처럼 피연산자에 함수를 적용해 그 결과를 반환하지만 피연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라고 합니다.

함수형 프로그래밍 방식으로 개발을 진행하면 코드에 immutable이 되는 영역의 비율이 높아지는 장점을 누릴 수 있습니다.

 

장점 2. 불변 객체는 단순

불변 객체는 생성된 시점의 상태가 파괴될 때까지 간직됩니다.

모든 생성자가 클래스 불변식을 보장할 경우 해당 클래스를 사용하는 프로그래머가 다른 노력을 들이지 않더라도 영원히 불변으로 남게 됩니다.

 

장점 3. thread-safe 하고 별도로 동기화할 필요 없음

불변 객체에 대해서는 그 어떤 쓰레드도 다른 쓰레드에 영향을 줄 수 없기 때문에 thread-safe하게 만드는 가장 쉬운 방법입니다.

 

장점 4. 불변 객체는 안심하고 공유 가능

불변 클래스는 쓰레드 간 영향을 줄 수 없기 때문에 안심하고 공유 가능합니다.

아무리 복사해 봐야 원문과 똑같기 때문에 방어적 복사와 같은 처리를 할 필요가 없기 때문에 public final static 인스턴스를 통해 한번 만든 인스턴스를 최대한 재활용하는 것을 권장합니다.

  • ex) ZERO, ONE, I

 

장점 5. 불변 객체끼리는 내부 데이터를 공유 가능

두 객체가 같은 데이터를 참조할 경우 한 객체의 상태 변경이 다른 객체에 영향을 미치기 때문에 가변 필드를 절대 공유하면 안 됩니다.

하지만 불변 객체일 경우 필드가 변경되지 않음을 보장하기 때문에 동일한 데이터를 바라봐도 무방하며 이 덕분에 메모리를 절약할 수 있습니다.

BigInteger의 negate 메서드는 부호를 변경하는 메서드인데 이때 mag가 불변임을 보장하기 때문에 인스턴스를 새로 생성하더라도 원본 인스턴스와 새 인스턴스 모두 동일한 mag 배열을 가리키는 것을 확인할 수 있습니다.

 

 

장점 6. 객체를 만들 때 불변 객체로 구성하면 이점이 많음

값이 바뀌지 않는 구성 요소들로 이루어진 객체라면 그 구조가 복잡해도 불변식 유지가 수월합니다.

불변 객체는 Map의 키와 Set의 원소로 사용하기에 적합합니다.

Map이나 Set은 내부 값이 변경되면 불변성이 깨질 수 있는데, 불변 객체를 사용하면 이러한 우려 없이 안전하게 활용할 수 있습니다.

 

장점 7. 실패 원자성 제공

실패 원자성은 메서드에서 예외가 발생한 후에도 해당 객체가 메서드 호출 전과 같은 유효한 상태를 유지한다는 개념을 나타냅니다.

불변 객체의 상태는 절대 변하지 않기 때문에 잠깐이라도 불일치 상태에 빠질 가능성이 없고 이에 따라 실패 원자성을 제공한다고 볼 수 있습니다.

 

단점 1. 값이 다를 경우 반드시 별도의 객체로 만들어야 함

불변 객체의 경우 값이 다를 경우 반드시 독립된 객체로 만들어야 합니다.

Complex 인스턴스에 대한 연산이 빈번하게 수행된다고 가정하면, 연산 횟수만큼 인스턴스가 생성되어야 하며, 이를 모두 생성하는 데는 상당한 비용이 들 것입니다.

그래도 다행인 점은 위 단점에 대한 해결책이 없는 것이 아닙니다.

1회 연산에 대한 메서드와 함께 다단계 연산 메서드도 제공할 경우 생성하는 인스턴스 개수를 줄일 수 있습니다.

또한, 가변 동반 클래스를 활용하여 대응할 수 있습니다.

예를 들어, String은 자체적으로 불변성을 가지지만, StringBuilder나 StringBuffer를 사용하여 여러 연산을 한 번에 처리하고 그 결과로 새로운 인스턴스를 생성하는 가변 동반 클래스를 통해 이 문제를 해결할 수 있습니다.

 

불변 클래스를 만들 때 고려할 요소

 

1. 상속을 막을 수 있는 또 다른 방법 

앞서 언급한 두 번째 원칙처럼 불변 클래스는 확장할 수 없도록 만들어야 합니다.

가장 간단한 방법은 클래스 자체를 final로 선언하여 상속을 못하게 막는 것이지만 보다 유연한 방법이 존재합니다.

Complex 클래스처럼 모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩토리 메서드를 제공하는 것이며 해당 방식이 최선인 경우가 많습니다.

private 생성자만 제공할 경우 내부 클래스만 상속이 가능하고 package-private 생성자일 경우 패키지 내 클래스만 상속이 가능하기 때문에 구현 클래스를 원하는 만큼 만들어 활용할 수 있지만 패키지 바깥에 위치한 클라이언트에서 바라본 이 불변 객체는 사실상 final입니다.

정적 팩토리 메서드 방식을 통해 여러 구현 클래스 중 하나를 선택하여 활용할 수 있으며, 객체 캐싱 기능을 통해 성능을 향상할 수 있습니다.

 

2. 재정의가 가능한 클래스는 방어적인 복사를 사용

앞서 다섯 번째 원칙에서 BigInteger를 불변 객체로 소개했지만 사실 BigInteger를 설계할 당시에는 불변 객체가 사실상 final이어야 한다는 생각이 널리 퍼지지 않았고 재정의할 수 있도록 설계가 되었습니다.

따라서 신뢰할 수 없는 클라이언트로부터 BigInteger나 BigDecimal의 인스턴스를 인수로 받는다면 해당 객체가 BigInteger인지 혹은 상속받은 하위 클래스인지 확인을 해야 합니다.

만약 하위 클래스의 인스턴스라고 확인이 될 경우 모든 인수들이 가변이라고 가정하고 방어적 복사를 사용해야 합니다.


 

3. 모든 "외부에 공개하는" 필드가 final이어야 함

실제로 3번 원칙처럼 모든 필드를 final로 선언하는 것은 불변 객체라 할지라도 다소 과한 감이 없지 않습니다.

만약 계산 비용이 큰 값을 필드로 가지고 있다면 필요할 때 lazy loading을 하여 final이 아닌 필드에 캐싱해서 쓰고 싶은 니즈가 있을 것입니다.

따라서 성능 향상을 위해 3번 원칙을 외부에 공개하는 모든 필드가 반드시 final이어야 한다로 변경할 필요가 있습니다.

 

참고

이펙티브 자바
이펙티브 자바 완벽 공략 2부 - 백기선 강사님

 

 

 

 

 

 

반응형