본문 바로가기
객체지향 프로그래밍(Object-Oriented Programming)

불변 클래스(Immutable Class)

by 제너럴종 2023. 5. 10.

객체 내부의 값의 불변성(immutability)을 보장하여,
내부 필드를 수정, 변경할 수 없는 클래스를 말한다.

불변 클래스의 장점과 단점

불변 객체는 단순하다.

생성된 시점부터 GC에 의해 파괴될 때 까지 객체의 값이나 상태가 변하지 않는다.

따라서 객체의 불변식(invariant)을 유지하기 위한 코드는 생성자를 제외하고, 그 외 메서드에서는 번거로운 불변식 체크가 필요없다.

불변 객체는 스레드 안전(Thread-safe)하다.

최초 생성 이후로 불변 객체 필드나 상태를 변경할 수 없으니, 멀티 쓰레드 프로그램 개발 중 발생하는 동시성 이슈(concurrency issue)에서 자유롭다.

이펙티브 자바의 저자 조슈아 블리크는 이 때문에 StringBuffer 클래스를 직접 사용하는 것을 '구닥다리(old-fashioned)' 라고 했다. String 객체는 불변 객체이기 때문이다.

불변 객체는 안심하고 공유가 가능하다.

불변 클래스를 확장(extends)해, 구체 클래스(Concrete Class)를 만들 수 없기 때문이다.
따라서 개발자는 상속을 고려한 API 문서 작성 부담 또한 줄어든다.

객체 재사용성이 높다.

예컨대 자주 사용하는 상수 풀(public static final float PI = 3.14 등..)을 모아두거나,
다른 클래스의 구현을 보조하는 도우미 역할의 클래스로 적합하다.

불변 객체임이 보장된다면, 불변 객체끼리의 내부 데이터 공유는 안전하다.

자주 사용하는 BigInteger, String 객체의 내부 구현을 구경해보자.

String 객체 인스턴스로 새로운 String 객체를 만들면, JVM이 String Pool에서 동일한 문자열 리터럴이 있는지 검색한다.

동일한 문자열 리터럴이 발견되면 Java 컴파일러는 새로운 String 객체를 Heap Area에 생성하지 않고, 동일한 문자열 리터럴을 가진 기존 String 객체의 메모리 주소값을 반환한다. 찾을 수 없으면 새 String 객체를 풀에 추가하고(interning) 해당 주소값을 반환한다.

String 객체를 문자열 리터럴로 인스턴스화 하면, HashMap으로 구현된 Heap Area에 있는 스트링 상수 풀(String Constant Pool)에 문자열 리터럴 값이 올라간다.

Heap Area에 존재하는 자원은 GC에 의해 수거가 가능하므로, 사용하지 않는 Constant Pool의 문자열 리터럴 값을 GC가 똑똑하게 릴리즈해준다.

따라서 문자열 리터럴로 String 객체를 초기화 하는 것이 성능상 유리하다.

불변 객체는 그 자체로 실패 원자성을 제공한다.

불변 객체의 불변식은 항상 보장되므로, 언제 어디서 예외를 던져도 객체의 내부 상태는 불일치 상태가 되지 않는다.

내부 값이 조금이라도 다르면 반드시 독립된 객체로 만들어야 한다.

따라서 불변 객체의 무분별한 사용은 큰 비용이 따른다.

이를 해결하는 방법으로 불변 객체에 비해 다루기 어렵고, 위험하며, 복합한 로직으로 구현된 가변 클래스(mutable class)를 여러개 나누어 구현하고, 하나의 불변 클래스 내부에 이 가변 클래스들이 실질적인 일을 하는 설계를 사용하는 경우가 많다.
(가변 동반 클래스, Companion Class)

이런 불변 클래스의 내부 구현은 수많은 가변 클래스로 이루어져 있다.

클라이언트는 복잡한 내부는 모른채 다양한 기능이 모인 불변 클래스를 쉽고 안전하게 다루거나, 성능 최적화를 위해 직접 가변 클래스를 쓸 수도 있다.

대표적인 예시가 불변 클래스 String과 가변 동반 클래스 StringBuilder이다.

우리는 문자열을 다룰 때 String 객체를 주로 사용하지만, 문자열의 수정, 연결이 잦을 때는 성능을 위해 StringBuilder 객체를 주로 사용한다. 사실 String 클래스는 불변 클래스이고, StringBuilder 클래스는 String 클래스의 가변 동반 클래스이다.

안전성을 검증받은 불변 클래스를 설계하는 법은 다음과 같다.

  class People{
    int age;
    String name;

    People(int age, String name){
      this.age = age;
      this.name = name;
    }


    public void setAge(int age){
      this.age = age;
    }

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

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

  class People{
    int age;
    String name;

    People(int age, String name){
      this.age = age;
      this.name = name;
    }
  }

클래스를 확장(extend)할 수 없도록 한다. 즉 상속을 문법적으로 금지한다.

  // 이 클래스는 절대 재정의 될 수 없음을 명확하게 전달한다.
  final class People{
    int age;
    String name;

    People(int age, String name){
      this.age = age;
      this.name = name;
    }
  }

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

  final class People{
    // 이 클래스의 필드는 절대 변경되지 않음을 명확하게 전달한다.
    final int age;
    final String name;

    People(int age, String name){
      this.age = age;
      this.name = name;
    }
  }

모든 필드의 접근 제어자(Access Modifier)를 private으로 선언한다.

  final class People{
    // 이 클래스의 필드는 외부에서 접근할 수 없게 문법적으로 안전하게 보호받고 있다.
    // People 클래스는 다음 릴리즈 때 People 클래스를 사용하던 외부 클라이언트의 호환성을 고려하여
    // 내부 구현을 수정하는 것에 부담이 줄어든다.
    // (SOLID, Open Closed Principle), (GRASP, Low coupling), 정보 은닉(information hiding)
    private final int age;
    private final String name;

    People(int age, String name){
      this.age = age;
      this.name = name;
    }
  }

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

  final class People{
    private final int age;
    private final String name;

    // 불변 클래스 내부의 가변 객체가 있다면 반드시 이 객체의 참조(Reference)를 얻을 수 없게 해야한다.
    private Book book = new Book();

    People(int age, String name){
      this.age = age;
      this.name = name;
    }
  }

  class Book{
    ...
  }

적시에 방어적 복사본을 만든다.

final class DiscountEventPeriod {
  private final Date start;
  private final Date end;

  public DiscountEventPeriod(Date start, Date end) {
    if (start.compareTo(end) > 0) {
      throw new IllegalArgumentException(
        "종료시간(" + end + ") 가 시작시간(" + start + ")보다 빠를 수 없습니다.");
    }

    this.start = start;
    this.end = end;
  }

  public Date getStart() {
    return start;
  }

  public Date getEnd() {
    return end;
  }
}

DiscountEventPeriod 클래스는 할인 이벤트 기간을 나타내기 위해 필드에 Date 객체를 가지고 있다.

사실 Date 클래스는 자신의 가변 필드를 노출하는 위험성이 내포된 클래스이고, Java 8 이후 LocalDateTime 클래스와 ZonedDateTime 클래스에게 자리를 넘겨주었다.

하지만 아직 Date 객체를 이용하는 프로그램은 많을 것이다. Date 객체(start, end)는 위에서 설명한 규약을 모두 지킨 불변 필드이고, 불변 필드만 보유한 DiscountEventPeriod 클래스는 불변 클래스임이 분명하다.

이제 DiscountEventPeriod 객체의 불변식을 망가뜨려보자.

public static void main(String args[]) {
  Date start = new Date();
  Date end = new Date();

  DiscountEventPeriod discountEventPeriod = new DiscountEventPeriod(start, end);
  start.setYear(122); // 2022년
  end.setYear(112); // 2012년
}

Date 객체는 값 객체이고, Date 객체의 가변 필드가 쉽게 노출되어 있어 발생한 이슈이다.

Date 클래스는 오버라이딩 된 메서드나, 공개(public)된 정적(static) 메서드를 제외하면 대부분 @Deprecated 에너테이션이 붙은 낡은 클래스다. 그럼에도 예전에 작성된 낡은 Date 객체를 새로운 날짜 객체로 전부 마이그레이션하는 것이 쉽지 않을 수 있다.

악의적인 외부 공격이나, 클라이언트의 실수를 막기 위해선 생성자에서 받은 가변 파라미터를 방어적 복사(defensive copy)해 자신의 불변 필드를 보호해야 한다.

final class DiscountEventPeriod {
  private final Date start;
  private final Date end;

  public DiscountEventPeriod(Date start, Date end) {
    // 새 Date 인스턴스를 만들어 방어적 복사(defensive copy)를 구현 했다.
    this.start = new Date(start.getTime());
    this.end = new Date(end.getTime());

    if (start.compareTo(end) > 0) {
      throw new IllegalArgumentException(
        "종료시간(" + end + ") 가 시작시간(" + start + ")보다 빠를 수 없습니다.");
    }
  }

  ...
}

실패 원자성(failure atomicity) 보장을 위해, 파라미터 유효성을 검사하는 코드를 최상위에 삽입해야 한다고 했지만, 방어적 복사 코드는 반드시 파라미터 유효성 검사 코드보다 상위에 삽입 되어야 한다.

유효성 검사 코드가 방어적 복사 코드보다 상단에 위치한다면, 멀티 쓰레드 환경에서 방어적 복사 코드가 무효화 될 수 있다.

    "종료 연도는 시작 연도보다 빠를 수 없다"  

가 DiscountEventPeriod 객체의 불변식이다. 1번 쓰레드가 DiscountEventPeriod 객체의 생성자의 유효성 검사를 통과하고 방어적 복사를 하려던 찰나의 순간, 원본 객체를 다루는 2번 쓰레드가 갑자기 시작 연도를 100년이나 늘려버린다면 우리의 불변식 검사 코드는 쓸모 없어진다.

한 쓰레드가 원본 객체의 유효성을 검사한 후 복사본을 만드는 찰나의 순간에 다른 쓰레드가 원본 객체를 수정 해 버린 것이다. 이런 공격을 검사시점/사용시점(TOCTOU, time-of-check) 공격 이라 한다.

Date 클래스는 Cloneable 인터페이스를 구현 하였으므로, 재정의된 clone() 메서드를 사용 할 수 있지만 위 코드에선 사용하지 않았다. Date 클래스는 final 모디피어가 붙지 않았기 때문에 자신의 파생 클래스(하위 클래스)를 만들 수 있기 때문이다.

즉 Date 객체의 clone() 메서드가 아닌 악의적인 하위 클래스의 clone() 메서드가 엉뚱한 인스턴스를 반환할 수도 있다. 만약 이 악의적인 하위 클래스가 Date 객체의 가변 필드 참조를 따로 인스턴스화 해 보관 중이라면, Date 객체를 이용하는 모든 객체의 불변식을 전부 망가뜨릴 수 있다.

class Hacker extends Date implements Cloneable{
  private static final long serialVersionUID = 1L;

  @Override
  public Object clone() {
    return this;
  }

  public void doMalware() {
    this.setDate(999999);
    this.setHours(1111111);
    this.setMinutes(2222222);
  }
}

DiscountEventPeriod 클래스가 clone() 메서드로 방어적 복사를 구현했고, DiscountEventPeriod 객체의 생성자로 이 객체를 넘겨졌다면, 이후 결과는 뻔하다.

아직 보안 구멍은 더 남아있다.

public static void main(String args[]) {
  Date start = new Date();
  Date end = new Date();

  DiscountEventPeriod discountEventPeriod = new DiscountEventPeriod(start, end);
  discountEventPeriod.getStart().setYear(122); // 2022년
  discountEventPeriod.getEnd().setYear(112); // 2012년
}

답은 간단하다.

불변 객체의 가변 필드 참조를 반환하는 게터(getter) 메서드가 있다면, 역시 방어적 복사를 적용한다.

final class DiscountEventPeriod {
  ...

  public Date getStart() {
    return new Date(start.getTime());
  }

  public Date getEnd() {
    return new Date(end.getTime());
  }

  ...
}

게터 메서드에서는 방어적 복사에 clone을 사용해도 된다. 우리가 생성자에서 new Date()로 필드를 초기화 했으므로, start 객체와 end 객체는 Date 클래스임이 확실하기 때문이다.

드디어 DiscountEventPeriod 클래스는 흠잡을 곳 없는 완벽한 불변 클래스가 되었고, 가장 높은 단계의 캡슐화(Encapsulation) 를 구현하였다.

(네이티브 메소드나 리플렉션 같이 언어 외적인 수단은 예외로 한다.)

방어적 복사의 성능 이슈

방어적 복사는 새로운 객체를 계속 생성하므로 성능 저하가 따르고, 같은 패키지에 속한 객체라면, 항상 쓸 수 있는 기법은 아니다. 성능 이슈로 방어적 복사를 포기하려면 다음 규약을 참고하자

호출자(클라이언트)를 신뢰 할 수 있다면 생략 한다.

대신 클라이언트의 조작으로 불변 클래스의 불변식이 깨질 수 있음을 명확히 문서화 해야 한다.

호출자(클라이언트)에게 객체의 통제권을 명백히 이전 받음을 약속받는다

아규먼트에 삽입된 객체는 더 이상 수정하는 일이 없고, 수정 시 불변 클래스의 불변식이 깨질 수 있음을 명확히 문서화 한다. 하지만 악의적인 객체를 넘기는 사람은 약속을 지킬 마음이 없을 것이다.

이 경우 불변식이 훼손되어도, 그 영향이 호출자에게만 국한되도록 해야 한다. 이 때 방어적 복사를 구현한 클래스를 래퍼 클래스(Wrapper Class)로 구현하는 것도 좋은 해답이 될 수 있다.

불변 필드의 필드, 상태 등을 특정 값으로 매핑 한다.

Date 객체에 getTime() 메소드는 타임 스탬프(Time Stamp)를 long 정수로 반환한다.

(타임 스탬프 : 1970 년 1 월 1 일부터 UTC로 시작하는 초 수의 시간 표현 중 하나.)

final class DiscountEventPeriod {
  ...

  public long getStart() {
    return start.getTime();
  }

  public long getEnd() {
    return end.getTime();
  }

  ...
}

타임 스탬프 값은 어떤 Date 객체에서도 쓸 수 있으니 적절한 해답이 될 수 있다.

Reference

이펙티브 자바 Effective Java 3/E
조슈아 블로크 저/개앞맵시 역 | 인사이트(insight) | 2018년 11월 01일

댓글