[자바 프로그래밍] 값 타입, 값 객체, 불변 객체

프로그래밍에서 값(value)이란 보통 변수에 저장되는 원시값이나 객체의 메모리 주소값을 의미한다. 변수에 값을 저장하고 변수의 값을 꺼내 사용하는 것이 일반적이다.

클래스에 데이터와 기능을 정의할 때 해당 클래스의 인스턴스가 갖는 프로퍼티(필드)를 정의하기 위해 인스턴스 변수를 정의한다. 클래스가 적절한 시점에 인스턴스화되면 setter를 통해 프로퍼티에 값을 할당하고 getter를 통해 프로퍼티로부터 값을 꺼낸다. 또는 클래스의 생성자를 사용하여 인스턴스 생성 시점에 프로퍼티를 특정값(기본값)으로 초기화할 수도 있다.


값 기반 클래스

오라클에서는 값 기반 클래스(value-based class)의 특성을 다음과 같이 설명하고 있다.

java.util.Optionaljava.time.LocalDateTime과 같은 일부 클래스는 값 기반 클래스이다. 값 기반 클래스의 인스턴스는 다음과 같은 특성을 가진다.

  1. 값 기반 인스턴스는 final이며 불변(immutable)이다. 가변(mutable) 객체에 대한 참조를 포함할 수는 있다.
  2. 값 기반 인스턴스는 equals(), hashCode(), toString() 메서드 구현을 가지고 있다. 이러한 메서드들은 인스턴스가 참조하고 있는 다른 인스턴스(또는 변수)의 상태나 인스턴스 자신의 식별(identity)이 아닌 인스턴스의 상태로부터 계산된다.
  3. 값 기반 인스턴스에 대해 == 연산자를 통해 참조 동등성(reference equality)(또는 동일성(identity))을 비교하거나 인스턴스의 해시 코드 값 비교, 인스턴스의 고유 잠금에 대한 동기화와 같은 인스턴스의 식별에 민감한 관련 연산을 수행하는 경우 의도치 않은 결과가 나올 수 있으므로 권장되지 않는다.
  4. 값 기반 인스턴스는 참조 동등성 비교 연산자(==)가 아닌 equals()에 의해서만 동일한 것으로 간주된다. equals()는 각 클래스에서 재정의될 수 있기 때문에 == 연산자와 equals() 메서드를 통한 비교 결과가 항상 동일한 것은 아니다.
  5. 값 기반 인스턴스는 접근 가능한 public 생성자를 가지고 있지 않으며 팩토리 메서드를 통해 인스턴스화된다. 팩토리 메소드가 반환하는 인스턴스들은 식별값과 속성값이 항상 동일하지 않다. 즉, 팩토리 메서드를 호출할 때마다 서로 다른 식별값과 속성값을 갖는 인스턴스가 반환된다.
  6. 값 기반 인스턴스의 경우 equals() 결과에 따라 동등한 두 인스턴스는 자유롭게 서로 대체할 수 있다. 즉, 두 인스턴스에 대해 equals() 호출 결과가 참인 경우 두 인스턴스를 교환하더라도 기능에 눈에 띄는 변화가 없어야 한다.


가변 객체와 불변 객체

자바에서는 가변 상태의 자바빈을 정의하기 위해 클래스에 인자가 없는 기본 생성자, 프로퍼티를 읽기 위한 getter, 가변 프로퍼티를 변경하기 위한 setter를 정의하여 사용한다. 자바 객체를 사용하는 기본 패턴은 getter와 setter를 통해 객체의 프로퍼티를 다루는 가변 객체를 사용하는 것이다.

가변 객체의 경우 가변 프로퍼티가 객체 간 동등성과 객체의 해시 코드를 결정한다. 즉, 가변 객체의 프로퍼티가 변하면 equals()hashCode() 메서드의 반환 결과도 달라진다.

데이터를 표현하기 위해 사용하면서 변하지 않는 값을 가지는 데이터 구조인 값 객체(value object)(또는 값 타입)가 필요한 경우 자바빈이나 POJO 객체를 불변 객체로 만들 수도 있다.

불변 객체를 사용할 때 얻을 수 있는 장점은 다음과 같다.

  • 집합 원소나 맵의 키로 불변 객체를 저장할 수 있다. 집합과 맵은 객체를 저장할 때 객체의 equals()hashCode() 메서드를 사용한다. 따라서 객체를 집합에 넣거나 맵의 키로 사용한 다음에 그 프로퍼티 값을 변경하는 경우(가변 객체를 사용하는 경우) 의도치 않은 결과가 일어날 수 있다. 가변 객체의 상태(프로퍼티) 변화는 hashCode() 값을 변경시키므로 가변 객체를 해시값 기반 컬렉션의 키로 사용하게 되면 컬렉션을 사용하는 동안 예상치 못한 일이 발생할 수 있다. 컬렉션에서 해시 키로 사용되는 동안 해당 가변 객체의 상태가 변하지 않도록 강제하는 것이 좋다.
  • 불변 객체의 불변 컬렉션에 대해 이터레이션하는 경우 원소가 변경될 가능성이 없다.
  • 객체의 초기 상태를 깊은 복사(deep copy)할 필요가 없다. 얕은 복사를 사용하는 경우 가변 객체에 변경이 일어나면 해당 가변 객체에 대한 참조를 사용하는 코드는 변경된 상태의 가변 객체에 대한 동일한 참조를 사용하므로 의도치 않은 객체 참조가 될 수 있으며 이를 해결하기 위해 깊은 복사를 사용해야 했다. 불변 객체를 사용하면 가변 객체에 대한 동일한 참조를 바꾸기 위해 깊은 복사를 사용할 필요가 없으며 서로 다른 불변 객체에 대해 참조를 변경하기만 하면 된다.
  • 여러 스레드에서 불변 객체를 안전하게 공유할 수 있다. 즉, 불변 객체는 스레드 안전하며 경합 조건(race condition)에 관여하지 않는다. 가변 객체의 경우 객체의 값을 읽고 쓰는 스레드가 서로 다른 경우 변경된 프로퍼티를 대상으로 동기화 처리가 필요하다.


객체를 불변 객체로 만들기 위해서는 다음과 같은 과정이 필요하다. 먼저 클래스에 setter 메서드를 정의하지 않는다. 그리고 생성자에서 프로퍼티를 설정한 후 접근 지정자를 사용하여 인스턴스화된 객체의 프로퍼티를 절대로 변경할 수 없게 만든다.

불변 객체를 사용하려면 프로그래밍 방식에 변화가 필요하다. 하나의 가변 객체를 참조한 후 가변 객체의 프로퍼티를 변경하고 변경된 프로퍼티를 사용하는 대신 서로 다른 프로퍼티를 가지는 서로 다른(동등하지 않은) 불변 객체에 대한 참조를 변경하는 방식을 사용하는 것이다. 즉, 가변 객체에 대한 불변 참조를 불변 객체에 대한 가변 참조로 바꾼다.


equals()와 hashCode() 메서드 계약

자바에서 Object 클래스가 제공하는 equals() 메서드의 기본 구현은 객체의 메모리 주소를 비교하여 두 객체의 참조가 같은지 판단하는 것이다. == 연산자와 동일하게 동작한다. 따라서 메모리의 다른 곳에 위치한(주소가 다른) 두 객체를 비교하면 두 객체의 프로퍼티가 같은 값이더라도 다른 객체로 판단한다. 모든 클래스는 Object 클래스를 상속하므로 equals() 메서드는 모든 객체가 제공한다. hashCode() 메서드의 기본 구현도 마찬가지로 두 객체의 참조가 같다면 메서드의 반환값인 해시 코드 값이 동일하다.

참조와 관계 없이 객체의 프로퍼티 값이 같으면 같은 객체로 판단하기 위해서는 equals() 메서드를 오버라이드하여 두 객체의 타입이 같은지 먼저 확인한 후 두 객체의 프로퍼티 값이 같은지 확인하면 된다.

자바 명세에 따르면 equals()hashcode() 메서드에는 계약이 존재한다. 내용은 다음과 같다.

  1. equals() 메서드 계약
    • 반사적 (reflexive): 널이 아닌 참조값 x에 대해 x.equals(x)true를 반환해야 한다. 즉, 객체는 스스로 같아야 한다.
    • 대칭적 (symmetric): 널이 아닌 참조값 x, y에 대해 x.equals(y)true를 반환하면 y.equals(x)true을 반환해야 한다.
    • 전이적 (transitive): 널이 아닌 참조 값 x, y, z에 대해 x.equals(y)true를 반환하고 y.equals(z)true를 반환하면 x.equals(z)true를 반환해야 한다.
    • 일관성 (consistent): 객체의 프로퍼티 값이 변경되지 않았다면 널이 아닌 참조값 x, y에 대해 x.equals(y)를 여러 번 호출하더하도 일관되게 true를 반환하거나 일관되게 false를 반환해야 한다.
  2. hashCode() 메서드 계약

equals()hashCode()를 사용한 객체의 참조 동등성(동일성) 비교 결과는 항상 같아야 한다. 다시 말해, 두 객체에 대해 equals() 결과값이 true라면 반드시 hashCode() 값이 서로 동일해야 한다. 이 조건이 만족되지 않을 경우 HashMap 컬렉션 사용 시 키로 의도된 값을 조회할 수 없는 예상치 못한 문제가 생길 수 있다. HashSet 컬렉션의 경우 HashMap을 기반으로 하기 때문에 동일한 원인으로 인해 문제가 발생 가능하다.


참고

Comments