본문 바로가기

Java/Java SE, EE

[Java/Java SE, EE] 불변 객체(Immutable object)와 방어 복사(Defensive copy) 구현

Immutable이란?

불변성(Immutable)은 상태를 바꿀 수 없는 상태를 의미합니다. 객체 지향 프로그래밍(OOP, Object Oriented Programming)에서  Immutable은 불변 객체(Immutable object)를 뜻합니다. 불변 객체란, 인스턴싱이 완료된 이후로도 속성을 유지하는 객체를 뜻합니다.

Immutable의 반대 개념으로는 가변성(Mutable)이 있습니다. OOP에서 Mutable Object란 우리가 일상적으로 프로그래밍에서 구현하고 사용하는 클래스를 의미합니다. 별도의 장치가 없는 클래스는, 인스턴싱 이후로도 설정자(Setter) 등을 사용하여 속성(Field)를 변경 할 수 있습니다.

그렇다면 자바에서 Immutable의 예시는 어떤 것이 있을까요? final 키워드로 장식된 필드 또는 변수는 일종의 Immutable 일 수 있습니다. 좀더 엄격한 수준에서는, final 키워드로 장식된 클래스 중 하나인 String 클래스가 있습니다.

String 클래스

이전 문서에서 클래스에 final 키워드가 붙으면 해당 클래스를 상속 받을 수 없다고 소개했습니다. 클래스의 상속이 불가능하면, 캡슐화(Encapsulation)로 은닉된 필드의 접근은 해당 클래스 내부에서만 가능합니다. 또한 상속이 불가능하다는 점은 우리가 작성한 클래스 본연의 성질을 재정의(Override) 할 수 없다는 의미입니다.

이처럼 Immutable의 핵심은 재정의 불가능에 있습니다. 우리가 앞으로 작성하게 될 Immutable object는 아마 대부분이 클래스 수준에서 final 키워드를 장식하게 될 것입니다.

다음은 자바의 대표적인 Immutable object인 String 클래스에 대한 정의입니다.

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
	...
}

String 역시 클래스 수준에서 final 키워드를 장식하고 있습니다. 따라서 우리가 프로그램에서 작성하는 어떠한 클래스도 String 클래스를 상속 받을 수 없습니다. 즉, String 클래스의 소스 파일을 직접 수정하지 않는 이상 처음 작성된 상태를 유지합니다. Immutable에 의해 필드와 메소드를 재정의 할 수 없으며, 지금까지 Stable한 코드였다면 앞으로도 동일한 수준의 안정성을 보장 할 것입니다.

즉, Immutable object는 잘못된 상속과 재정의(Override)로 인해 발생할 미연의 실수를 방지하기 위한 용도로 사용 될 수 있습니다. 물론 Imuutable을 사용하는 이유는 이것 뿐만은 아닙니다.

방어 복사(Defensive copy)

Immutable의 주요한 특징 중 하나는 방어 복사(Defensive copy)를 사용한 불변성과 안정성입니다. 자바 뿐만 아니라, 대부분의 프로그래밍에서 객체의 복사 방법은 얕은 복사, 깊은 복사, 그리고 방어 복사로 구분됩니다. 이중 방어 복사는 일종의 깊은 복사에 해당하며, 다음과 같이 요약 할 수 있습니다.

  • 인스턴싱 과정에서 외부로부터 전달 받은 인자를 사용하여 내부 필드를 초기화 할 때, 서로 간의 관계를 끊는 것
  • 접근자(Getter) 등으로 내부 필드를 외부에 전달 할 때, 서로 간의 관계를 끊는 것

관계를 끊는 다는 것은 어떤 의미일까요? 쉽게 말해서 들어오고, 나가는 데이터를 깊은 복사로 처리한다는 얘기입니다. 다음 코드는 Getter 접근자에서의 방어 복사의 예시입니다.

public Integer getValue() {
   return new Integer(this.value);
}

객체의 내부 필드(Integer value)를 외부에 전달(getValue)할 때, 깊은 복사(new Integer)의 결과를 반환하고 있습니다. 물론, 방어 복사의 개념을 전달하는 과정에서 사용된 필드 타입 Integer는 맥락 상 무의미하며, 실제로 사용되는 필드 타입은 좀 더 유의미한 참조(Reference) 타입이 될 것입니다.

이렇게 방어 복사를 수행하면 어떤 장점이 있을까요? 내/외부에서 접근하는 객체는 동일한 값을 갖고있지만 완전히 독립된 인스턴스가 됩니다. 즉, 방어 복사를 수행하면 내부의 객체-필드는 외부 코드의 영향을 받지 않게 됩니다.

다시 String 클래스로 돌아가봅시다. String 클래스의 메소드 concat()은 필드로 관리하는 문자열 뒤에 메소드의 인자로 받은 문자열을 이어 붙인 결과를 반환합니다.

public String concat(String str) {
    int olen = str.length();
    if (olen == 0) {
        return this;
    }
    if (coder() == str.coder()) {
        byte[] val = this.value;
        byte[] oval = str.value;
        int len = val.length + oval.length;
        byte[] buf = Arrays.copyOf(val, len);
        System.arraycopy(oval, 0, buf, val.length, oval.length);
        return new String(buf, coder);
    }
    int len = length();
    byte[] buf = StringUTF16.newBytesFor(len + olen);
    getBytes(buf, 0, UTF16);
    str.getBytes(buf, len, UTF16);
    return new String(buf, UTF16);
}

코드의 마지막 줄에 해당하는 return new String(buf, UTF16)에서 String 클래스가 반환하는 문자열이 방어 복사를 통해 처리되고 있음을 확인 할 수 있습니다. 즉, 우리가 concat() 메소드를 몇번이라도 호출할지라도 원래의 String 객체가 참조하고 있던 문자열에는 어떠한 영향도 미치지 않게 됩니다.

방어 복사의 개념을 보다 확실하게 이해하기 위해서, 간단한 예제를 직접 구현해보는 것이 도움이 됩니다. 아래 코드 int[] 필드를 갖는 불변 객체를 정의하고 있습니다. 필드는 어떠한 참조 타입이어도 무관하며, 테스트를 간소화하기 위해 배열을 사용하고 있습니다.

public final class ImmutableObject {
   private final int[] value;
   
   public ImmutableObject(int[] _value) { this.value = _value; }
   public int[] getValue() { return value; }
}

작성한 불변 객체는 다음과 같은 테스트를 거쳐 불변성을 검증합니다.

int[] value = new int[] { 1, 2, 3 };
ImmutableObject immutable = new ImmutableObject(value);

// immutable 객체의 내부 참조의 값을 출력합니다.
System.out.println(Arrays.toString(immutable.getValue()));
// 내부 참조 객체의 값을 변경합니다.
value[0] = -1;
// immutable 객체는 정말로 immutable 할까요?
System.out.println(Arrays.toString(immutable.getValue()));
// 다른 방법으로, 내부 참조 객체의 값을 다시 한번 변경합니다.
immutable.getValue()[0] = -2;
// immutable 객체는 정말로 immutable 할까요?
System.out.println(Arrays.toString(immutable.getValue()));

테스트 결과는 다음과 같습니다. 불변 객체의 필드가 원시(Primitive) 타입이라면 문제가 없겠지만, 참조(Reference) 타입인 경우 별도의 Setter 설정자를 제공하지 않았음에도 필드의 값이 변경 될 수 있음을 확인 할 수 있습니다.

[1, 2, 3]
[-1, 2, 3]
[-2, 2, 3]

이는 불변 객체의 생성자(Constructor)와 Getter 접근자에서 방어 복사를 통해 해결 할 수 있습니다. 불변 객체를 다음 코드와 같이 수정합니다.

public final class ImmutableObject {
   private final int[] value;
   
   public ImmutableObject(int[] _value) { this.value = Arrays.copyOf(_value, _value.length); }
   public int[] getValue() { return Arrays.copyOf(this.value, this.value.length); }
}

방어 복사를 사용한 테스트 결과는 다음과 같습니다. 외부 코드와 관계 없이, 내부 필드의 값은 초기화 단계에서 동일한 값을 갖도록 생성한 별개의 인스턴스이기 때문에 어떠한 영향도 받지 않습니다.

[1, 2, 3]
[1, 2, 3]
[1, 2, 3]

Immutable 구현

앞서 소개한 코드 ImmutableObject는 사실 방어 복사에 대한 예시에 더 적합합니다. 물론, 방어 복사를 통한 Immutable의 성격을 부여하는 것도 Immutable object를 구현하는 방법 중 하나입니다. 이번에 소개할 Immutable object는 방어 복사를 사용하지 않고, Immutable의 성격을 부여하는 방법입니다.

첫째로, 원시(Primitive) 타입의 필드를 갖는 Immutable object의 구현입니다. 원시 타입은 값에 의한 복사가 발생하기 때문에 클래스 수준에서 final 키워드를 장식하고, Setter 설정자와 같은 외부에서 접근 가능한 메소드를 구현하지 않는 것만으로도 충분합니다. 게다가 필드 타입에도 final 키워드를 장식하면 필드의 값을 수정하는 메소드를 구현하는 실수 또한 방지 할 수 있습니다.

public final class ImmutableObject {
   private final int value;
   
   public ImmutableObject(int _value) { this.value = _value; }
   public int getValue() { return this.value; }
}

이 객체는 인스턴싱(new ImmutableObject) 단계에서 필드(int value)가 설정되고, 더 이상 필드를 변경 할 수 있는 여지가 없기 때문에 충분히 Immutable하다고 할 수 있습니다.

두번째로, 참조(Reference) 타입의 필드를 갖는 Immutable object의 구현입니다. 참조 타입은 주소에 의한 복사가 발생하기 때문에 앞서 방어 복사의 예제처럼 구현 할 수 있습니다. 기존 예시처럼 방어 복사를 사용했을 때의 문제점은 어떤 것이 있을까요?

방어 복사의 문제는 Immutable object가 갖는 필드의 모든 접근 메소드에 대해서 방어 복사 코드를 추가하여야 하며, 이 과정에서 누락이 발생하면 버그로 이어질 수 있다는 점입니다. 또한 무분별한 방어 복사의 사용은 힙 메모리의 사용량을 늘리는 문제 또한 갖고 있습니다. 이어지는 코드에서는 참조 타입에 대한 Immutable object 구현의 또 다른 방법을 제시합니다.

public final class ImmutableOuter {
   private ImmutableInner value;
   
   public ImmutableOuter(ImmutableInner _value) { this.value = _value; }
   public ImmutableInner getImmutableInner() { return this.value; }
}

public final class ImmutableInner {
   private final int value;
   
   public ImmutableInner(int _value) { this.value = _value; }
   public int getMutableValue() { return this.value; }
}

Immutable object(ImmutableOuter)의 내부 필드에 속하는 참조 타입(ImmutableInner)에 대해서도 Immutable object로 구현하는 것입니다. 모든 하위 필드에 속하는 객체가 Immutable object로 구현되어 있다면, 이 객체는 방어 복사를 수행하지 않고도 충분히 Immutable하다고 말할 수 있습니다.

Immutable 장단점

지금까지 소개한 내용을 통해 Immutableobject를 사용했을 때의 장단점을 정리 할 수 있습니다. 우선, Immutable object의 장점은 다음과 같습니다.

  • 클래스를 상속받아 필드와 메소드를 재정의 할 수 없으므로 신뢰성이 높아집니다.
  • 내부 필드 역시 Immutable한 성격을 지니면, 방어 복사를 하지 않아도 됩니다. 이는 힙 메모리 사용량을 절약하고, 버그 발생의 여지를 미연에 방지 할 수 있습니다.
  • 객체의 속성이 시간에 따라서 변경되지 않으므로, 속성에 대한 참조 시 값을 명확하게 인지하고 사용 할 수 있습니다.
  • 객체의 속성이 시간에 따라서 불변하다는 것은, 여러 스레드에서 해당 객체를 안전하게 공유할 수 있음을 의미합니다.

마지막 내용은 멀티 스레드 환경에서 어떤 객체의 동시 참조에 대한 안정성을 제공하는 것을 뜻합니다. 이는 다수의 스레드가 해당 객체를 참조하고자 할 때 Lock 알고리즘을 통해 스레드의 동시 접근을 Blocking 할 필요가 없고, 따라서 프로그램의 동시성이 더 향상 될 수 있는 여지를 제공합니다.

다음으로 Immutable object의 단점을 다음과 같습니다.

  • 객체 내에서 방어 복사가 누락된 메소드가 있으면, 버그로 이어질 가능성이 높습니다.
  • 방어 복사를 사용하지 않더라도, 객체의 속성을 바꿀 수 없기 때문에 새로운 속성의 부여를 위해 매번 새로운 인스턴싱 과정이 필요합니다.
  • 어떤 방법을 사용하더라도, Immutable object는 결과적으로 기존 프로그래밍 방식보다 더 많은 힙 메모리 사용을 요구합니다.

이처럼 Immutable object의 가장 큰 단점은 메모리 사용량의 증가입니다. Immutable object는 신뢰성과 안정성, 그리고 멀티 스레드 환경에서의 동시성 향상을 제공하지만 더 많은 메모리를 요구하기 때문에 개발 단계에서 이들 간의 장단점을 비교하여 이점이 더 크다고 판단되었을 때 사용해야 합니다.