개념
불변 객체(Immutable Object)는 생성된 후 내부 상태가 절대로 변하지 않는 객체를 말한다.
끝! 이름만 들으면 복잡해 보일 수 있지만, 사실 쉽고 간단하다.
객체 내부 상태를 변경 불가능하게 설계하고 외부에서 변경하지 않고 사용하면 그게 바로. 불변 객체!
“객체를 생성하고 변경 못하면 불편하지 않나?! 개발이 가능한가?!”
우선 의문은 잠시 접어두고 불변 객체를 만드는 방법부터 차근차근 알아보자. 레고레고!
불변 객체 만들기
// Person class
public final class Person {
private final String name;
private final String gender;
public Person(String name, String gender) {
this.name = name;
this.gender = gender;
}
public String getName() {
return name;
}
public String getGender() {
return gender;
}
// 객체 상태를 변경하는 메서드를 제공하지 않음
}
// Main class
public class ImmutableMain {
public static void main(String[] args) {
Person person = new Person("superpil", "M");
// person 객체 생성 후 name, gender 값 변경 불가 + 상속 불가
}
}
- 필드에 private을 사용해 외부에서 접근과 변경을 막는다.
- 필드에 final을 사용해 객체 생성 시 내부 필드값을 초기화되게 강제하고 변경 불가능하게 한다.
- class에 final을 사용해서 상속을 막는다.
- 객체 상태를 변경하는 어떠한 메서드도 제공하지 않는다.(대표적인 setter)
쉽게 생각해서 불변 객체는 내부상태를 변경 불가능하게 설계하면 된다.
객체 생성 시 생성자를 통해 내부 상태를 반드시 초기화하고, 내부 상태를 변경 가능한 요소가 없게 만들면 final을 사용하지 않아도 불변 객체가 된다.
다만, final을 사용해서 강제성을 부여하고 다른 개발자도 불변 객체라는 의도를 명확하게 파악할 수 있게 하자.
여기서 잠깐! final에 대한 자세한 내용은 아래 글에서 확인해 보자.
객체 필드에 final을 사용하는 이유는 이해가 된다. 하지만 상속은 왜 막을까?!
왜 불변 객체는 부모가 될 수 없어?
public class Person {
private final String name;
public Person(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
- Person 객체에 private final name 필드 선언
public class SubPerson extends Person {
private String newName;
public SubPerson(String name) {
super(name);
this.newName = name;
}
public void setName(String name) {
this.newName = name;
}
@Override
public String getName() {
return this.newName;
}
}
- Person 객체를 상속받은 SubPerson 객체가 있다.
- SubPerson 객체 생성 시 생성자에서 name을 매개변수로 받고 SubPerson에 newName 필드와 Person에 name 필드에 초기화한다.
- SubPerson은 setName 메서드로 newName 필드를 변경할 수 있다.
- SubPerson은 부모인 Person 객체에 getName 메서드를 오버라이딩한다.(핵심핵심✨)
public static void main(String[] args) {
Person person = new SubPerson("superpil");
System.out.println(person.getName()); // superpil
SubPerson subPerson = (SubPerson) person;
subPerson.setName("pil");
System.out.println(person.getName()); // pil
}
- new SubPerson("superpil")를 생성하고 다형성으로 Person타입인 person 변수를 선언했다.
- 첫 번째 person.getName()은 우리가 원하는 결괏값을 얻을 수 있다.
- 하지만 (SubPerson) person부터 문제가 발생된다.
- person을 다운캐스팅하여 SubPerson타입을 가진 새로운 subPerson가 나타났다.
- SubPerson타입이기 때문에 당연히 setName()을 호출할 수 있다.
- newName은 setName()로 pil로 변경된 상태고 person의 name은 기존 superpil 상태이다.
- 하지만 person.getName()을 하면 pil 출력된다.
- 문제 원인은 오버라이딩 때문이다.
- 오버라이딩로 인해 마지막 코드 person.getName()을 호출하면 subPerson getName()가 호출된다.(다형성 원리)
- 조금 복잡하지만, 아래 그림을 참고하면 이해하는 데 도움이 된다.
- subPerson.setName("pil")가 실행되면 newName값이 변경
- 다형성으로 person.getName()을 호출하면 오버라이딩된 getName()이 호출
- 결국에는 불변 객체인 person name은 변경된 값으로 출력되는 마법 같은 이슈가 발생된다.
“상속까지 막았으니, 이제 완벽한 불변 객체를 완성했다!”라고 생각하겠지만,
아쉽게도 아직 한 발 남았다.🔫
불변 객체에서 final 허점
public final class Person {
private final List<String> nicknames;
public Person(List<String> nicknames) {
this.nicknames = nicknames;
}
public List<String> getNicknames() {
return nicknames;
}
}
public static void main(String[] args) {
List<String> nicknames = new ArrayList<>();
nicknames.add("super");
nicknames.add("pil");
Person person = new Person(nicknames);
person.getNicknames().add("developer"); // 에러없이 추가 될까?!
}
- Person 객체에 nicknames 필드는 final로 선언되어 있다.
- final 키워드는 이 필드가 초기화된 후에는 다시 변경될 수 없음을 보장한다고 했다.
- 그럼 nicknames 리스트에 값을 추가하면 어떻게 될까? final을 사용했으니 값을 추가하지 못할까?
System.out.println(person.getNicknames()); // [super, pil, developer]
- final을 사용했지만 에러 없이 값을 추가할 수 있다.
- 이는 nicknames 참조가 변경될 수 없다는 의미일 뿐, 리스트 자체 값을 변경할 수 없다는 의미는 아니다.
- 리스트 값이 변경가능하니, 단순히 final 키워드만 사용하는 것으로는 불변 객체를 만드는 것이 충분하지 않다. 허점을 파악했으니, 이제 문제를 해결해 보자.
// Person class
public final class Person {
private final List<String> nicknames;
public Person(List<String> nicknames) {
this.nicknames = Collections.unmodifiableList(new ArrayList<>(nicknames));
}
public List<String> getNicknames() {
return nicknames;
}
}
// Main class
public static void main(String[] args) {
List<String> nicknames = new ArrayList<>();
nicknames.add("super");
nicknames.add("pil");
Person person = new Person(nicknames);
person.getNicknames().add("developer"); // UnsupportedOperationException발생!
}
- 읽기 전용 리스트를 만들어서 해결하자.
- Collections.unmodifiableList는 새로운 리스트를 반환하며, 반환된 리스트에 대해 추가, 삭제 또는 수정 작업을 시도하면 UnsupportedOperationException이 발생한다.
- 이런 허점은 List뿐만 아니라 참조형은 동일하게 발생하니, 객체 설계 시 주의하자.
- 자바 9부터는 List.of로 간단하게 불변 리스트를 만들 수 있다. 아래 코드를 참고하자.
// Person class
public final class Person {
private final List<String> nicknames;
public Person(List<String> nicknames) {
this.nicknames = nicknames;
}
public List<String> getNicknames() {
return nicknames;
}
}
// Main class
public static void main(String[] args) {
List<String> nicknames = List.of("super", "pil");
Person person = new Person(nicknames);
person.getNicknames().add("developer"); // UnsupportedOperationException발생!
}
- final 허점은 list만 아니라 map, set 등 참조형 자료구조는 모두 해당된다.
상속도 안돼, 객체 내부 상태도 변경 불가능해. 야박한 제약사항 때문에 숨통이 쪼여온다.
원하는 상태를 변경해서 숨 쫌 쉬면서 개발해 보자.
불변 객체 상태 변경하기
불변 객체 상태를 변경한다고?! 불변인데? 사실 말장난이다.
현실세계는 사람이 태어나서 정해진 이름과 성별은 죽을 때까지 대부분 변경되지 않는다.
간혹 특별한 이유로 법원을 통해 이름을 개명할 수 있고 의사 도움을 받아 성별을 변경할 수 있다.
그렇다면 객체 세상에 태어난 불변 객체인 Person은 name과 gender를 어떻게 변경 할 수 있을까?!
Person 클래스에 name과 gender를 변경하기 위해 코드를 추가해 보자.
public final class Person {
private final String name;
private final String gender;
public Person(String name, String gender) {
this.name = name;
this.gender = gender;
}
public String getName() {
return name;
}
public String getGender() {
return gender;
}
// 추가 코드
public Person withName(String newName) {
return new Person(newName, this.gender);
}
// 추가 코드
public Person withGender(String newGender) {
return new Person(this.name, newGender);
}
}
public static void main(String[] args) {
Person person = new Person("superpil", "M");
Person newName = person.withName("pil");
System.out.println("person : " + person); // @30f39991
System.out.println("newName : " + newName); // @452b3a41
}
- Person객체에 withName, withGender 메서드를 보자.
- newName, newGender 메서드는 매개변수를 받아 새로운 Person 객체를 생성한다.
- 새로운 Person 객체 생성 시 매개변수 값을 주입한다.
- withName, withGender메서드에서 리턴 받은 객체는 기존 Person 객체 내부상태를 변경한 게 아닌 새로운 Person 객체다.
- main에 기존 Person 객체와 새로운 이름으로 변경한 Person 객체 참조값이 다르다.
- 불변 객체는 값을 변경한게 아니고 새로운 값으로 초기화된 신규 Person 객체가 탄생한다.
지금까지 우리는 불변 객체를 만들고 상태까지 변경할 수 있게 속임수를 추가했다.
불변 객체는 우리가 직접 설계하고 사용하는 특별한 객체처럼 느껴질 수 있다.
하지만 자바에서 불변성을 기본으로 가지는 객체들이 존재한다. 자바에서 대표적인 불변 객체를 알아보자.
대표적인 Java 불변 객체
String
String str1 = "Hello";
String str2 = str1.concat(" Superpil");
System.out.println("str1 : " + str1); // Hello
System.out.println("str2 : " + str2); // Hello Superpil
- String은 자바에서 대표적인 불변 객체다.
- concat()를 사용해서 str1과 str2를 합쳤다.
- str1은 값이 변경되지 않고 새로운 String 객체가 str2에 할당된다.
- String 객체 내부를 한번 보자.
// String class
public final class String {
private final byte[] value;
}
- String 클래스에 final을 사용해 상속을 불가능하게 했다.
- value에도 final을 사용해 최초 생성된 문자열을 변경 불가능하게 했다.
- value에 private 접근 제어자로 외부에서 접근 불가능하게 했다.
- 이렇게 String은 불변 객체로 설계되었다. 다음은 concat() 내부를 한번 살펴보자.
// String class concat method
public String concat(String str) {
if (str.isEmpty()) {
return this;
}
return StringConcatHelper.simpleConcat(this, str);
}
@ForceInline
static String simpleConcat(Object first, Object second) {
String s1 = stringOf(first);
String s2 = stringOf(second);
// ... 중간코드
return newString(buf, indexCoder);
}
- concat() 내부를 보면 최종적으로 내부 상태를 변경하지 않고 새로운 String을 반환하는 불변 객체 메서드로 설계되었다.
LocalDate, LocalTime, LocalDateTime, ZonedDateTime (java.time 패키지)
LocalDate now = LocalDate.now();
LocalDate plusDays = now.plusDays(10);
System.out.println("now : " + now); // 2024-05-16
System.out.println("plusDays : " + plusDays); // 2024-05-26
- Java time 패키지에 객체들도 불변 객체다.
- plusDays()를 사용해 날짜를 더하면 기존 LocalDate에 값을 변경하지 않고 새로운 LocalDate를 반환한다.
Wrapper 클래스 (Integer, Double, Boolean 등)
Integer i1 = Integer.valueOf(10);
Integer i2 = i1 + 5;
System.out.println(i1); // 10
System.out.println(i2); // 15
- 다른 클래스와 동일하게 새로운 클래스를 생성 후 반환한다.
Wrapper Class에 자세한 내용은 아래 링크에서 확인하자!
지금까지 내용을 읽어보면 의문점이 생긴다.
애초에 가변 객체로 만들면 되지 않나? 왜 굳이 불변 객체로 만들고 값을 변경하기 위해 새로운 객체를 만들지?
가변 객체 vs 불변 객체
불변 객체는 생성 후 변경 불가능하고 가변 객체는 불변 객체와 반대로 생성 후 상태 변경이 가능한 객체다.
불변 객체를 사용하는 이유
사실 불변 객체를 실무에서 사용해 본 경험이 없다.
어떤 상황에서 왜 불변 객체를 사용해야 하는지, 그리고 그 결과는 어떤지에 대한 실제 예제를 함께 작성하고 싶다. 😥
기회가 된다면 실제로 불변 객체를 사용하고 경험과 예제를 수정 보안 하겠다.
그전에, 다른 자료를 참고해서 공부해 보자.
Thread 안정성
멀티 스레드 환경에서 발생하는 문제 중 하나는 동기화 이슈다.
스레드끼리 서로 객체를 공유해서 접근하고 변경하는 문제로 데이터를 안전하게 보장할 수 없다.
이로 인해 데이터 불일치, 데이터 손실, 예상치 못한 동작 등이 일어날 수 있다.
하지만 불변 객체는 생성 후 변경할 수 없고, 상태가 변경될 때마다 새로운 객체를 생성해서 멀티 스레드 환경에서 사용하기 유용하다.
객체 신뢰성과 안정성
public void demo() {
Person person = new Person();
// ... 100줄 코드
person.getName(); // 과연 뭘까?
}
불변 객체는 생성 후 상태가 변경되지 않는다.
불변 객체 생성 후 몇백 줄 코드를 거쳐 객체 값을 사용해도 생성 당시 값은 그대로 유지된다.
모든 코드를 확인하지 않아도 된다. 이는 예측이 용이하며 믿고 사용할 수 있다.
가변 객체의 경우, 생성 후 사용까지의 모든 코드를 검토해야만 객체의 값을 확실하게 확인할 수 있다.
이로 인해 유지보수가 복잡해지며 시간과 노력이 소모된다.
지금까지 불변 객체에 대해 파헤쳐봤다.
간단하게 정리하고 마무리하자.
정리
불변 객체는 생성된 후 그 상태가 절대 변하지 않는 객체를 의미한다.
이는 클래스를 final로 선언하여 상속을 막고, 필드에 private과 final을 사용하여 외부 접근과 변경을 막는다.
또한, 어떠한 메서드도 객체의 상태를 변경하지 않는다.
만약 불변 객체의 상태를 변경하려면, 새로운 객체를 생성한다.
이런 특성 때문에 불변 객체는 신뢰성과 안정성을 제공한다.
참고 자료
- https://tecoble.techcourse.co.kr/post/2020-05-18-immutable-object/
- https://www.youtube.com/watch?v=EOGOJdBy2Rg
- https://pygmalion0220.tistory.com/entry/Java-초급-final-키워드
'개발노트 > Java' 카테고리의 다른 글
[Java Wrapper Class] 래퍼 클래스 파헤치기 (1) | 2024.06.13 |
---|---|
[Java Basic] 자바 표준 라이브러리 소개 (0) | 2024.05.14 |
[Java Final] 키워드 파헤치기 (0) | 2024.05.09 |
[Java 초급] Overloading - 오버로딩 (0) | 2020.08.25 |
개발 기록
포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!