Java의 불변(Immutable)에 대해 + final
자바에서 "불변"이라는 용어는 객체 생성 후 그 상태를 변경할 수 없는 특성을 의미합니다.
자바에서는 이러한 객체를 "불변 객체(Immutable Object)"라고 부릅니다.
불변 객체는 생성 시점에 상태를 한 번 설정하고, 이후 그 상태가 절대로 바뀌지 않습니다.
이는 일반적으로 모든 필드가 final로 선언되고, 해당 객체에 대한 수정자(setter) 메서드가 없음을 보장합니다.
또한, 해당 객체가 참조하는 다른 객체도 변경 불가능해야 합니다.
*final에 대해서 모르신다면 글 아래에 final 부분을 먼저 보시는걸 추천드립니다.
불변성(Immutability)은 자바의 기능이라기 보다는 하나의 개념에 가깝습니다.
"넌 이제부터 불변이야" "넌 이제부터 int야"라고 선언하는 것이 아닌
불변성이라는 개념을 적용해 객체를 만든다면 그 객체는 불변 객체가 되는 것입니다.
아래의 코드는 자바에서 불변 객체를 만드는 기본적인 방법입니다.
public final class ImmutableExample {
private final String name;
public ImmutableExample(String name){
this.name = name;
}
public String getName() {
return name;
}
}
위의 코드에서 ImmutableExample 클래스는 불변입니다.
name 필드는 final로 선언되어 생성된 후에 변경할 수 없습니다. 또한 이 코드는 setter 메소드를 제공하지 않아 클래스 외부에서 name을 변경할 수 없습니다. 이러한 특성으로 인해 이 클래스의 인스턴스는 생성 시점의 상태를 유지합니다.
불변 객체의 대표적인 예시는 String 클래스입니다.
String은 불변 클래스로 설계되어 있습니다.
하지만 String 클래스의 내부 코드를 들여다보면
아래와 같은 구조로 만들어져 있습니다.
public final class String
implements java.io.Serializable, Comparable<String>, CharSequence,
Constable, ConstantDesc {
@Stable
private final byte[] value;
private final byte coder;
private boolean hashIsZero; // Default to false;
@java.io.Serial
private static final long serialVersionUID = -6849794470754667710L;
분명 모든 필드가 final로 선언되는 것이 불변 객체의 조건이라고 했었는데
왜 final이 아닌 필드가 있을까요?
Java의 String 클래스는 불변 클래스로 설계되어 있지만, hash와 hashIsZero처럼 final이 아닌 멤버 변수를 포함하고 있습니다. 이는 String 객체의 "중요한 상태"가 변하지 않는다는 불변성의 원칙을 유지하면서, 특정 연산에 대한 결과를 캐시(cache)하려는 성능 최적화 전략 때문입니다.
Java의 String 객체에서 hashCode() 메소드를 이용하여 해시코드를 계산하면, 그 결과가 hash 필드에 저장됩니다. 이후에 같은 String 객체에 대해 hashCode()를 호출하면 이미 계산되어 저장해둔 값인 hash를 반환하므로, 불필요한 해시코드 계산작업을 줄일 수 있습니다. 본질적으로 hash와 hashIsZero는 String의 중요한 상태(has semantic meaning)를 나타내는 것이 아니라, 성능 최적화를 위한 목적으로 사용되기 때문에, 이들 필드가 final에 포함되지 않아도 String 클래스의 불변성은 유지됩니다.
따라서 String의 주요 상태는 불변이며, hashCode() 메소드에서 반환한 값은 동일 String 객체에 대해 항상 같은 값을 반환하므로, String 객체는 불변 객체로 간주됩니다.
결론적으로 불변이란 하나의 개념이기 때문에 모든 필드가 final로 선언되어 있어야 한다거나 하는 엄격한 규칙을 가진 것이 아닌 객체가 불변성을 만족한다면 그 객체는 불변 객체가 되는 것입니다.
final에 대해
Java에서 final 키워드는 한 번 할당되면 그 값을 변경할 수 없음을 나타냅니다.
final을 변수, 메소드, 클래스에 대해 사용할 때 각각 다른 의미가 있습니다.
기본 타입(int, double, boolean 등): 그 변수의 값은 초기화 이후에 바꿀 수 없습니다. 즉, 그 변수는 상수가 됩니다.
참조 타입(Object, Array, String 등): 그 변수가 참조하는 객체 또는 배열은 변경할 수 없습니다. 그러나 해당 객체의 내부 상태(필드 값) 또는 배열의 요소는 변경할 수 있습니다.
메소드: final 메소드는 오버라이딩할 수 없다는 것을 나타냅니다. 즉, 서브클래스에서 이 메소드의 정의를 변경할 수 없습니다.
클래스: final 클래스는 상속할 수 없다는 것을 나타냅니다. 다른 클래스가 이 클래스를 확장할 수 없습니다.
이런 성질로 인해 final 키워드는 불변성을 강제하는 데 사용되며, 이는 버그를 줄이고 코드의 안전성을 높이는 데 도움이 됩니다.
final 키워드는 불변성을 만족시키기 위한 중요한 요소이지만 final로 선언된 모든 것이 불변은 아닙니다.
예를 들어 아래와 같은 코드는 가능합니다.
final int[] arr = {1, 2, 3};
arr[0] = 100; // 가능
final int[] arr = {1,2,3};
arr = new int[]{4,5,6}; // 불가능
이는 final 키워드가 모든 것을 불변으로 만드는 만능 키워드가 아닌 단순히 reference를 불변으로 만들기 때문입니다.
이 코드에서 배열 arr은 참조변수이지만 final로 선언되었기 때문에 배열 arr이 참조하는 것을 다른 배열로 변경할 수 없습니다.
하지만 배열 arr의 각 요소들의 값은 변경이 가능하기 때문에
final로 선언 되었다고 무조건 불변성을 가지는 것이 아닙니다.
따라서 String 클래스의 경우 단순히 final로 선언되어서 불변이라고 하기 보다는
"String 클래스는 내부적으로 final char 배열을 사용하여 구현되므로 불변성을 가진다" 라고 표현하는 것이 더 정확합니다.
Java에서 불변 객체를 만드는 것은 final 키워드의 사용 여부와는 상관없이 가능합니다. 그러나 final 키워드를 사용하면 컴파일러가 변하지 않는 필드를 보장하도록 도와주기 때문에 더 안전하고 쉽습니다.
자바 커뮤니티에서는 불변 객체를 만들 때 필드를 final로 선언하는 것이 일반적으로 추천되는 사항입니다.
final 키워드는 필드가 생성자에서 한 번만 초기화될 수 있음을 의미하며, 이것이 불변성을 유지하는 데 큰 역할을 합니다. 또한 final 필드는 메모리 가시성을 보장하여 다중 스레드 환경에서의 안전성을 높여주는 데 도움이 됩니다.
이러한 이유로, 불변 객체를 만들 때는 일반적으로 모든 필드를 final로 선언하는 것이 좋습니다.
*String 클래스는 불변이라고 하지만 변경이 가능하지않나? 라는 의문이 생긴다면 제 전 글을 봐주시길 바랍니다.
Java의 String 클래스의 원리
문자열을 다루는 방법은 프로그래밍 언어마다 다양하다. C와 C++처럼 char 배열과 포인터로 다루는 경우도 있고, Python처럼 문자열을 기본 데이터 타입으로 가지고 있는 경우도 있다. 자바에서는
kimpuro.tistory.com
추가적으로 가변을 불변으로 만드는 방법도 있습니다.
List와 HashMap은 가변 인터페이스 입니다.
아래 코드와 같이 생성된 후에도 요소의 추가, 삭제 또는 변경이 가능합니다.
List<String> list = new ArrayList<>();
list.add("Hello"); // 요소 추가
list.remove(0); // 요소 제거
HashMap<String, Integer> map = new HashMap<>();
map.put("Apple", 1); // 요소 추가
map.remove("Apple"); // 요소 제거
그러나 불변 버전의 List나 Map을 만들어낼 수도 있습니다. Java 9부터는 List.of(), Set.of(), Map.of() 등의 메소드를 제공하고 있으며, 이는 주어진 요소로 구성된 불변 컬렉션을 반환합니다.
즉 엄밀하게 말한다면 List를 불변으로 변경하는 것이 아닌 전달된 요소로 새로운 불변 리스트를 만들어서 반환합니다.
List<String> immutableList = List.of("hello");
Map<String, Integer> immutableMap = Map.of("Apple", 1);
이렇게 생성된 불변 컬렉션은 추가, 삭제 또는 변경이 불가능합니다. 어떤 변화를 가하려고 시도하면UnsupportedOperationException이 발생합니다.
또한 Collections.unmodifiableList(), Collections.unmodifiableMap() 등을 사용하면 기존의 가변 컬렉션을 불변으로 감싸서 변화를 막을 수 있습니다. 이는 "뷰"를 생성하는 것이므로 원본 컬렉션의 변화를 막는 것은 아닙니다.