final 키워드는 불변 ?
흔히 final 키워드를 사용하는 이유로
불변을 보장해주니까요!
라고 얘기한다.
그렇다면 정말 final은 불변을 보장해줄까?
final 키워드를 사용했을 때 많이들 언급하는 이점으로
- 변경 불가
- 불변
정도가 있을 것 같다.
해당 글에선 불변에 초점을 맞춰서 얘기해 볼 것이다.
final 사용범위
final 키워드는
- 클래스 레벨
- 메서드 레벨
- 변수 레벨
에서 사용될 수 있다.
모두 다 중요한 개념이니, 하나씩 간단하게 짚고 가보자.
final class

클래스를 final로 선언하면 상속이 불가하다.
final 키워드는 재할당을 금지하기 때문에 오버라이딩(재정의) 할 수 없다.
따라서 클래스를 final로 선언하면 자식에서 부모의 메서드를 오버라이딩 할 수 없다.
🤔 메서드가 없는 final 클래스는 왜 상속이 안되지?
메서드가 없더라도 추후에 메서드가 추가되어 오버라이딩 할 가능성이 있기 때문에 상속이 불가하다.
final method

final 메서드도 상속할 수 없다. final은 재할당을 금지하기 때문이다.
final variable

final로 선언된 변수도 재할당이 불가하다.
재할당 불가 != 불변
위의 내용을 보면 이런 생각을 할 수 있다.
💁재할당 불가면 결국 불변 아닌가요?
아니다!
원시 타입(Primitive Type)에 한정 한다면 불변이 맞다고 할 수 있다.
원시타입: 실제 데이터 값을 저장하는 타입
참조 타입(Reference Type)에서는 어떨까?
class Woowa { private String name; public Woowa(final String name) { this.name = name; } public String getName() { return name; } public void setName(final String name) { this.name = name; } }
public class FinalTest { @DisplayName("final 객체는 불변이 아니다.") @Test void notImmutableFinalObject() { //given final Woowa woowa = new Woowa("우테코"); //when //then assertThat(woowa.getName()).isEqualTo("우테코"); woowa.setName("테크코스"); assertThat(woowa.getName()).isEqualTo("테크코스"); } }
Woowa 클래스를 final로 생성한 후 setName() 메서드로 객체의 상태를 변경해주었다.

테스트 결과는 성공이다.
final 키워드를 이용해서 woowa 인스턴스를 불변으로 선언하였는데 상태값이 바뀌었다. 어떻게 된걸까?
final 키워드는 재할당(만) 불가
final은 변수의 “재할당”을 막을 뿐이지, 참조된 객체의 “상태 변경”을 막지는 않는다.
상태 변경 과 재할당 은 다른 얘기다.
final로 선언된 woowa 인스턴스의 참조 주소 값이 01 이라고 했을때
해당 인스턴스에 새로운 참조 주소(02)를 할당 하는 것은 final 키워드로 막을 수 있다.

하지만 동일한 참조 주소를 가진 인스턴스의 내부 상태에 접근하여 값을 변경하는 것은 final이 막을 수 없다.
왜? 재할당을 안했으니까!
❓ 그럼 어떻게 불변으로 만들 수 있을까?
객체의 내부 상태를 불변으로 만들고, 외부에서 변경하지 못하도록 제한하면 된다.
class Woowa { private final String name; public Woowa(final String name) { this.name = name; } public String getName() { return name; } }

Woowa 클래스의 내부 상태를 final 로 선언하고, setter()를 제거하여 외부에서 값을 변경하지 못하도록 막아줬다.
이제 woowa 인스턴스는 외부에서 변경할 수 없게 되었다.
이렇게 하면 이제 진짜 불변일까?
반은 맞고 반은 틀렸다!
상속을 이용하면 상태가 변경된 것 처럼 보이게 할 수 있다.
static class Person { private final String name; Person(final String name) { this.name = name; } public String getName() { return name; } }
static class NewPerson extends Person { private String newName; NewPerson(final String name) { super(name); this.newName = name; } public void setNewName(final String newName) { this.newName = newName; } @Override public String getName() { return this.newName; } }
Person 클래스를 상속하는 NewPerson 클래스가 있다.
@Test void test() { Person person = new NewPerson("woowa"); System.out.println(person.getName()); NewPerson newPerson = (NewPerson) person; newPerson.setNewName("tech"); System.out.println(person.getName()); }
이 테스트의 결과는 어떻게 될까?

결과는 person 인스턴스의 name 이 변경되었다. (woowa -> tech)
🤔 Person 클래스의 name 필드는 final 로 선언되었는데 어떻게 변경되었을까?
사실 변경된 것은 아니다! 변경된 것 처럼 보이는 것!

Person타입의 person 인스턴스를 NewPerson 타입으로 타입 캐스팅을 하면 같은 아래와 같이 동일한 주소를 참조하는 person인스턴스와 newPerson인스턴스 두개가 된다.

여기서 setNewName()으로 NewPerson 타입의 newPerson인스턴스의 상태값(newName)을 바꾸면 위와같은 상태가 된다.
아직 person 인스턴스의 상태(name)는 그대로다
이제 person.getName()을 호출하면 NewPerson에서 오버라이드 하여 재정의 해주었듯이 NewPerson 객체의 상태값(newName)이 반환된다.

그래서 person 타입의 상태가 변경된 것 처럼 보이게 된다.
위의 예시가 조금 과하다고 생각할 수도 있다. 하지만 대비해서 나쁠건 없다.
💁 상속을 못하게 막아버리자
객체의 상태를 final로 정의하여도 위와같이 상속을 이용하면 필드를 바꿀 수 있게 된다.
그럼 상속을 못하게 막으면 된다.
위에서 얘기했듯, class 레벨에 final을 선언해주면 해당 클래스는 상속이 불가능한 클래스가 된다.

🤔 그럼 모든 클래스에 final 키워드를 붙여야 하나?
final 키워드가 없는 클래스는 언제든 확장할 수 있고, 상태의 변경이 가능할 수도 있다.
따라서 확장성이 필요없는 클래스라면 final을 붙여 변경을 막아주자. 그리고 명시적으로 final 키워드가 붙어있다면 확장하지 못하는 클래스임을 바로 알 수가 있다.
이제 진짜 진짜 불변일까?
안타깝지만 아직 아니다!
public final class Person { private final List<String> names; public Person(final List<String> names) { this.names = names; } public List<String> getNames() { return names; } }
String 타입의 names 리스트를 필드를 가진 Person 객체가 있다.
이 객체는 final 클래스이며, 모든 필드가 final로 선언되어 있고, setter 메서드가 존재하지 않는다.
@DisplayName("진짜 불변인가?") @Test void realFinal() { List<String> names = new ArrayList<>(); names.add("woowa"); names.add("dogy"); Person person = new Person(names); person.getNames().add("dev"); System.out.println(person.getNames()); }
이제 이 테스트를 실행하면 어떤 결과가 나올까?

final을 사용했지만 값이 추가 되었다. 왜 일까?
앞서 얘기했듯 final은 재할당(참조)만 막아준다. 리스트의 상태를 바꾸는 것은 막을 수 없다.
그럼 어떻게 리스트의 값을 변경할 수 없게 불변으로 만들 수 있을까?
컬렉션을 불변으로

외부에서 주입받은 리스트를 생성자에서 불변 리스트로 반환하여 생성하는 방법이 있다.
List.copyOf() : 인자로 받은 리스트를 참조 주소가 다른 불변 리스트로 반환한다.
❗️참고
List.copyOf() 는 자바10에 추가된 문법이다. 자바10 이전 버전에서는 어떻게 불변 리스트를 구현했는지 비교해보자.
자바 10
public Person(final List<String> names) { this.names = List.copyOf(names); }
자바 8
public Person(final List<String> names) { this.names = Collections.unmodifiableList(new ArrayList<>(names)); }
이처럼 생성하면 새로운 참조 주소를 가진, 크기가 고정된 불변 리스트가 생성되기 때문에 외부의 리스트와 별개의 리스트가 된다.
다시 테스트를 실행하면

이처럼 예외가 발생한다.
UnsupportedOperationException: 지원되지 않는 요청이 들어왔을 때 발생하는 예외
에러로그를 보면 불변 컬렉션에 수정을 시도 했기 때문에 예외가 발생했다.
public final class Person { private final List<String> names; public Person(final List<String> names) { this.names = List.copyOf(names); } public List<String> getNames() { return names; }
이제 Person 객체는 상속도 할 수 없고, 외부에서 필드의 값을 바꿀수도 없게 되었다.
위와 같이 컬렉션을 불변으로 변경하는 방법은 '방어적 복사' 방법이다.
여러가지 복사 방법이 존재하지만 이번 글에서는 한 가지만 소개했다.
결론
final 키워드가 좋아보여서 따라서 사용하기 보단, 자바에서 final 키워드를 왜 사용해야 하며, 어떤 이점 때문에 사용해야 하는지 생각해보는 것이 중요하다.
참고
'Java' 카테고리의 다른 글
Enum 사용 시 생각해 볼거리 (3) | 2024.12.03 |
---|