본문 바로가기
개발 공부/Java

이펙티브 자바 아이템 17 - 변경 가능성을 최소화 하라 - 핵심 정리

by 개발인생 2022. 11. 21.
반응형

아이템 17 - 변경 가능성을 최소화 하라 - 핵심 정리

이 글은 백기선 님의 이펙티브 자바 강의와 이펙티브 자바 3 / E 편을 참고하여 작성하였습니다.

불변 클래스

불변 클래스란 한번 만들어지면 그 인스턴스의 상태가 바뀌지 않는 것 을 말한다.

그 인스턴스가 소멸될 때까지 인스턴스의 내부의 값들이 변경되지 않는 것이다.

이렇게 설계된 클래스를 불변 클래스 라고 하고 불변 클래스의 인스턴스를 불변 인스턴스 라고 한다.

불변 클래스를 만들기위한 다섯가지 규칙이 있다.

  • 객체의 상태를 변경하는 메서드를 제공하지 않는다.
public class PhoneNumber {

    private short areaCode, prefix, lineNum;

    public void setAreaCode(short areaCode) {
        this.areaCode = areaCode;
    }

    public void setPrefix(short prefix) {
        this.prefix = prefix;
    }

    public void setLineNum(short lineNum) {
        this.lineNum = lineNum;
    }

    public short getAreaCode() {
        return areaCode;
    }  

    public short getPrefix() {
        return prefix;
    }

    public short getLineNum() {
        return lineNum;
    }
}

위의 클래스처럼 상태를 변경할 수 있는 setter 를 제공하면 안된다.

public class PhoneNumber {

   private short areaCode, prefix, lineNum;

   public PhoneNumber(short areaCode, short prefix, short lineNum) {
      this.areaCode = areaCode;
      this.prefix = prefix;
      this.lineNum = lineNum;
   }

   public short getAreaCode() {
      return areaCode;
   }

   public short getPrefix() {
      return prefix;
   }

   public short getLineNum() {
      return lineNum;
   }
}

위처럼 생성자를 통해 객체를 처음 만들 때 값을 받은 뒤 객체의 값을 변경하는 메서드를 제공하지 않는다.

  • 상속을 할 수 없도록 막는다.
public class PhoneNumber {

   private short areaCode, prefix, lineNum;

   public PhoneNumber(short areaCode, short prefix, short lineNum) {
      this.areaCode = areaCode;
      this.prefix = prefix;
      this.lineNum = lineNum;
   }

   public short getAreaCode() {
      return areaCode;
   }

   public short getPrefix() {
      return prefix;
   }

   public short getLineNum() {
      return lineNum;
   }
}

위의 클래스는 상속을 허용한 상태이다.

class 에 final 키워드가 없기 때문이고, public 생성자를 가지고 있기 때문이기도 하다.

public class MyPhoneNumber extends PhoneNumber {

   public MyPhoneNumber(short areaCode, short prefix, short lineNum) {
      super(areaCode, prefix, lineNum);
   }

   private String name;

   public String getName() {
      return name;
   }

   public void setName(String name) {
      this.name = name;
   }
}

MyPhoneNumber 클래스는 PhoneNumber 를 상속받아 PhoneNumber 타입으로 사용할 수 있다.

PhoneNumber 는 불변 클래스를 의도했지만 MyPhoneNumber 는 값을 언제든지 변경할 수 있다.

불변 클래스를 상속받으면서 불변 클래스가 아니게된다.

public final class PhoneNumber {

   private short areaCode, prefix, lineNum;

   public PhoneNumber(short areaCode, short prefix, short lineNum) {
      this.areaCode = areaCode;
      this.prefix = prefix;
      this.lineNum = lineNum;
   }

   public short getAreaCode() {
      return areaCode;
   }

   public short getPrefix() {
      return prefix;
   }

   public short getLineNum() {
      return lineNum;
   }
}

이러한 문제를 방지하기 위해서는 상속을 막아야한다.

상속을 막는 방법은 final 클래스 를 만들거나 private 생성자만 가지고 있게 만들면 된다.

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

필드를 변경할 수 없도록 final 로 선언한다.

public final class PhoneNumber {

    private short areaCode, prefix, lineNum;

    public PhoneNumber(short areaCode, short prefix, short lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    public short getAreaCode() {
        return areaCode;
    }

    public short getPrefix() {
        return prefix;
    }

    public short getLineNum() {
        return lineNum;
    }

    public void doSomething() {
       this.areaCode = 10;
    }
}

final 로 선언하지 않았다면 누군가가 실수로 해당 클래스의 메서드 안에서 값을 변경하는 경우가 발생할 수 있다.

public final class PhoneNumber {

    private final short areaCode, prefix, lineNum;

    public PhoneNumber(short areaCode, short prefix, short lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    public short getAreaCode() {
        return areaCode;
    }

    public short getPrefix() {
        return prefix;
    }

    public short getLineNum() {
        return lineNum;
    }

   public void doSomething() {
      this.areaCode = 10; // final 키워드 사용으로 인해 해당 코드에 컴파일 에러가난다.
   }
}

이러한 경우를 막기위해 모든 필드를 final 키워드로 선언하도록 한다.

final 키워드는 쓸 수 있다면 사용할 수 있다면 최대한 사용하도록 해야한다.

성능적인 장점도 있고, 프로그램을 견고하게 만들어 주는 장점이 있다.

  • 모든 필드를 private 으로 선언한다.
public final class PhoneNumber {

    public final short areaCode, prefix, lineNum;

    public PhoneNumber(short areaCode, short prefix, short lineNum) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    public short getAreaCode() {
        return areaCode;
    }

    public short getPrefix() {
        return prefix;
    }

    public short getLineNum() {
        return lineNum;
    }

   public void doSomething() {
      this.areaCode = 10; // final 키워드 사용으로 인해 해당 코드에 컴파일 에러가난다.
   }
}

위처럼 public 필드라면 내부 표현을 자유롭게 바꿀 수 없다.

클라이언트 코드에서 public 필드에 PhoneNumber.areaCode 이런식으로 접근을 한다면

PhoneNumber 클래스에서 필드명이 바뀐다면 클라이언트 코드에 수정이 들어가기 때문에 내부 표현을 자유롭게 바꿀 수 없다.

물론 final 필드이기 때문에 값을 바꿀 수 없지만 클라이언트 코드에서 우리가 원치 않은 방법으로 값을 참조할 수 있게된다.

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

    private String zipCode;

    private String street;

    private String city;

    public String getZipCode() {
        return zipCode;
    }

    public void setZipCode(String zipCode) {
        this.zipCode = zipCode;
    }

    public String getStreet() {
        return street;
    }

    public void setStreet(String street) {
        this.street = street;
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }
}
public final class Person {

   private final Address address;

   public Person(Address address) {
      this.address = address;
   }

   public Address getAddress() {
      return address;
   }

   public static void main(String[] args) {
      Address seattle = new Address();
      seattle.setCity("Seattle");

      Person person = new Person(seattle);

      Address redmond = person.getAddress();
      redmond.setCity("Redmond");

      System.out.println(person.address.getCity()); // Redmond
   }
}

지금까지 모든 규칙을 적용했지만 Address 클래스는 가변적인 클래스이다.

아무리 Person 클래스가 불변 클래스이다 하더라도 내부 정보가 얼마든지 바뀔 수 있다.

Address 클래스의 레퍼런스가 final 이지 해당 클래스가 불변이라는 의미는 아니다.

이러한 가변적인 컴포넌트에 접근할 수 있는 방법을 차단해야한다.

public final class Person {

    private final Address address;

    public Person(Address address) {
        this.address = address;
    }

    public Address getAddress() {
        Address copyOfAddress = new Address();
        copyOfAddress.setStreet(address.getStreet());
        copyOfAddress.setZipCode(address.getZipCode());
        copyOfAddress.setCity(address.getCity());
        return copyOfAddress;
    }

    public static void main(String[] args) {
        Address seattle = new Address();
        seattle.setCity("Seattle");

        Person person = new Person(seattle);

        Address redmond = person.getAddress();
        redmond.setCity("Redmond");

        System.out.println(person.address.getCity()); // Seattle
    }
}

위의 getAddress 처럼 값을 제공해야한다면 방어적인 복사 를 사용해 값을 제공해야한다.


불변 클래스의 장점과 단점

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 생성자와 함께 사용해야 한다.) (110-111쪽)
    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)";
    }
}
  • 함수형 프로그래밍에 적합하다.

자신이 가지고 있는 값을 변경하는게 아니라 새로운 인스턴스를 만들어 리턴하기 때문에

피연산자들이 바뀌지 않으면서 새로운 결과를 만들어 내기 때문에 함수형 프로그래밍에 적합하다.

자신의 값을 변경하게 되면 함수의 결과로 매번 같은 값이 나오지 않을 수 있다.

  • 불변 객체는 단순하다.

굳이 함수형 프로그래밍이 아니라도 클라이언트 코드에서 전달 받은 값에 대해 매번 같은 결과를 내기때문에

클래스 내부의 값이 변경되었다는 가정을 하지 않아도 되기때문에 프로그래밍이 훨씬 단순해진다.

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

여러 스레드가 동시에 사용해도 값이 훼손되지 않는다.

여러 스레드 간에 공용으로 사용하는 값이 변경되지 않기 때문에 근본적으로 안전하다.

  • 불변 객체는 안심하고 공유할 수 있다. (상수, 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);
  • 불변 객체 끼리는 내부 데이터를 공유할 수 있다.
public class BigIntExample {

    public static void main(String[] args) {
        BigInteger ten = BigInteger.TEN;
        BigInteger minusTen = ten.negate();
    }
}

BigInteger 같은 경우 내부 레퍼런스를 공유해 사용할 수 있다.

negate() 메서드를 사용하면 부호를 바꿔주는데 내부의 레퍼런스는 그대로 넘겨준다.

// BigInteger 내부
final int signum;
final int[] mag;

public BigInteger negate() {
        return new BigInteger(this.mag, -this.signum);
    }

배열은 변경이 가능하지만 사용하는 클래스는 불변 클래스이기 때문에 안전하다.

주의할 점은 불변 클래스끼리에만 가능하다는 점이다.

  • 객체를 만들 때 불변 객체로 구성하면 이점이 많다.

어떤 컬렉션은 그 컬렉션을 구성하는 요소가 같아야 같다고 볼 수 있다.

public class BigIntExample {

    public static void main(String[] args) {
        final Set<Point> points = new HashSet<>();
        Point firstPoint = new Point(1, 2);
        points.add(firstPoint);

        firstPoint.x = 10; // Set 은 더이상 불변이 아니다.
    }
}

Set 에 값을 넣었지만 해당 값이 변경이 될 수 있다.

불변 클래스로 다른 클래스를 구성할 수록 해당 객체가 조금 더 견고해진다.

불변으로 만들기 더 유리해지기도 한다.

  • 실패 원자성을 제공한다.

어떠한 계산을 수행하다가 잘못되더라도 원래의 데이터 가 변경되지 않는다.

    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);
    }

위의 오퍼레이션을 수행하다 에러가 발생해도 파라미터로 받은 불변 클래스와 해당하는 불변 클래스의 값은 변경되지 않는다.

원자성이 꺠지지 않는다.

  • 값이 다르다면 반드시 별도의 객체로 만들어야 한다.

불변 객체는 값이 변경된다면 새로운 인스턴스 를 만들 수 밖에 없다.

만약 인스턴스를 만드는 비용이 크다면 시간이 오래걸리고, 메모리를 많이 사용하게 된다.

이것의 대안으로는 다단계 연산 을 제공하거나 가변 동반 클래스 를 제공하는 것이다.

여러 계산을 하나로 합치는 것, 다단계의 연산을 하나로 하는 다단계 연산자 를 제공하면

인스턴스를 만드는 횟수를 줄일 수 있다.

public class StringExample {

    public static void main(String[] args) {
        String name = "whiteship";

        StringBuilder nameBuilder = new StringBuilder(name);
        nameBuilder.append("keesun");
    }
}

String 자체는 불변이지만 변경이 많은 작업일 경우 해당 작업을 쉽게 처리해주는 StringBuilder 를 제공한다.

StringBuilder 클래스는 String 의 가변 동반 클래스 이다.


불변 클래스 만들 때 고려할 것

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

불변 클래스는 상속을 못하게 막아야한다.

불변 클래스를 확장해 가변 클래스로 만들 수 있기 때문이다.

public final 클래스로 정의해 상속을 막는 방법 외에도

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

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

생성자를 private 으로 만들어 상속을 막는 방법이 있다.

생성자 호출을 private 으로 만들어 해당 클래스 내부에 있는 클래스만 상속이 가능해진다.

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

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

또는 생성자를 package-private 레벨로 바꿔주면 해당 패키지 내부에서만 상속을 할 수 있다.

제한된 범위 내에서만 상속을 허용하는 방법이다.

이렇게 제한적으로 상속을 허용하게 되면 다양한 구체적인 클래스를 만들 수 있다.

public 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);

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

    private static class MyComplex extends Complex {

        private MyComplex(double re, double im) {
            super(re, 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 생성자와 함께 사용해야 한다.) (110-111쪽)
    public static Complex valueOf(double re, double im) {
        return new MyComplex(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)";
    }
}

외부 클래스에서 인스턴스를 생성할 수 있도록 정적 팩터리 메서드 를 제공한다.

    public static Complex valueOf(double re, double im) {
        return new MyComplex(re, im);
    }
public class ComplexExample {

    public static void main(String[] args) {
        Complex complex = Complex.valueOf(1, 0.222);
    }
}

클라이언트 코드에서는 정적 팩터리 메서드 를 사용해 객체를 생성한다.

정적 팩터리 메서드 를 사용하면 내부의 구현체를 바꿔줄 수 있기 때문에 유연성을 제공할 수 있다.

또다른 장점으로는 값을 캐싱 할 수 있다.

자주 사용하는 인스터스들을 캐싱해서 정적 팩터리 메서드 를 사용할 때 캐싱해 놨던 인스턴스를 리턴해줌으로써

성능을 개선할 수 있다.

public final 로 상속을 막는 것 보다 생성자를 통해 상속을 제한하는 것이 더 유연한 방법 이다.

생성자를 private 만드는 방법은 외부에서 볼 때 사실상 final 이라고 한다.

주의할 점은 private 이나 package-private 생성자만 있어야한다는 점이다.

  • 재정의가 가능한 클래스는 방어적인 복사를 사용해야 한다.

만약 불변 클래스의 상속을 허용했다면

public class BigIntegerUtils {

    public static BigInteger safeInstance(BigInteger val) {
        return val.getClass() == BigInteger.class ? val : new BigInteger(val.toByteArray());
    }
}

BigInteger 클래스는 불변을 의도했지만 상속을 허용한다.

BigInteger 타입으로 값을 받더라도 하위 타입이 불변 객체가 아닐수도 있다.

이런 상황에서 안전하게 사용하려면 인스턴스의 실제 타입 을 검사해

원하는 타입이 아니라면 new BigInteger(val.toByteArray()); 와 같이 방어적인 복사를 사용해야한다.

  • 모든 “외부에 공개하는” 필드가 final 이어야 한다.

외부에 공개되지 않는 내부 데이터는 final 이 아니어도 되는 경우가 있다.

final 은 인스턴스 생성시 초기화 되어야한다.

하지만 계산 비용이 큰 값은 해당 값이 필요로 할 때 (나중에) 계산하여 final 이 아닌 필드에 캐시해서 사용해야하는 경우도 있다.

반드시 모든 필드가 final 일 필요는 없지만 외부에 공개하는 필드의 경우에는 final 로 하는게 좋다.

반응형

댓글