불변객체란 상태값을 수정할 수 없는 객체를 의미합니다.
즉, 객체를 처음 생성(초기화) 후에는 객체가 가지는 상태를 변경할 수 없는 것을 의미합니다.
이러한 불변객체의 특성은 어플리케이션을 만드는데 있어 많은 도움을 줍니다.
불변 객체는 상태가 변하지 않기 때문에 단순합니다.
가변 객체는 변경자 메소드 호출로 인해 어떻게 상태가 바뀔지 예측하기 힘듭니다.
또한 Multi Threading 환경에서 Thread Safety를 보장하고, 상수로서 Cache 사용하기도 용이합니다.
해당 아티클에서는 불변객체 사용으로 얻는 이점 보다는 불변객체를 생성하는 방법에 대해 알아보도록 하겠습니다.
🚀 가변객체를 불변객체로 만드는 방법과 불변객체의 특징을 알아봅니다.
public class IPhone {
public int modelNumber;
public int price;
public List<String> contactList;
public IPhone(int modelNumber, int price, List<String> contactList) {
this.modelNumber = modelNumber;
this.price = price;
this.contactList = contactList;
}
}
public class IPhoneUser {
public static void main(String[] args) {
//Mutable Ojbect
IPhone iPhone = new IPhone(395848, 1_200_000, new ArrayList<>());
iPhone.price = 1;
iPhone.modelNumber = 0;
iPhone.contactList = null;
}
}
IPhone Class의 Instance는 내부의 상태가 모두 공개되어있습니다.
그 때문에 자신의 상태를 외부에서 마음대로 수정할 수 있게 됩니다.
해당 Instance의 상태는 Application 코드 전체 중 어디선가에서 변경 될 수 있기에 상당히 위험한 상황이라 볼 수 있습니다.
일단 IPhone Class를 캡슐화하겠습니다.
public class IPhone {
private int modelNumber;
private int price;
private List<String> contactList;
public IPhone(int modelNumber, int price, List<String> contactList) {
this.modelNumber = modelNumber;
this.price = price;
this.contactList = contactList;
}
public int getModelNumber() {
return modelNumber;
}
public int getPrice() {
return price;
}
public List<String> getContactList() {
return contactList;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
IPhone iPhone = (IPhone) o;
return modelNumber == iPhone.modelNumber
&& price == iPhone.price
&& Objects.equals(contactList, iPhone.contactList);
}
@Override
public int hashCode() {
return Objects.hash(modelNumber, price, contactList);
}
}
이제 IPhone Class는 캡슐화 되어서 공개된 인터페이스를 통해서만 상태에 접근할 수 있게 됩니다.
이때 Getter만 열려있는 상태이기에 수정자를 통한 멤버 수정이나, 직접 객체의 멤버에 접근해서 수정할 수 있는 방법은 막혔습니다.
이제 이 객체는 불변성을 가지게 된 걸까요?
아쉽게도 상속을 이용하면 생각보다 쉽게 불변성을 깨트릴 수 있습니다.
public class ExtendIPhone extends IPhone{
private int modelNumber;
private int price;
private List<String> contactList;
public ExtendIPhone(int modelNumber, int price, List<String> contactList) {
super(modelNumber, price, contactList);
this.modelNumber = modelNumber;
this.price = price;
this.contactList = contactList;
}
public void setModelNumber(int modelNumber) {
this.modelNumber = modelNumber;
}
public void setPrice(int price) {
this.price = price;
}
public void setContactList(List<String> contactList) {
this.contactList = contactList;
}
@Override
public boolean equals(Object o) {
return this == o;
}
@Override
public int hashCode() {
return Objects.hash(this);
}
}
악의적 혹은 부주의하게 IPhone Class를 상속받아 SubType Class를 만들면 손쉽게 불변성을 깨트릴 수가 있습니다.
Setter가 추가되므로 ExtendIPhone Instance는 Thread Safety를 보장받을 수 없습니다.
심지어 IPhone Class에서는 정상적이던 동작도(euqals, hashCode) ExtendIPhone Class가 메소드 오버라이딩을 통해 오작동하게 만들 수 있습니다.
이는 상속을 통한 변경의 여지가 열려있기 때문에 발생하는 일입니다.
다형성을 통해서 IPhone 객체로 제공 받는다면 이 객체가 IPhone객체인지, 상속을 받은 신뢰할수 없는 객체인지 알기 쉽지 않습니다.
이는 final class로 선언하므로 방어할 수 있습니다.
하지만 IPhone Class를 final Class로 선언하는 것으로 IPhone Class는 불변성을 지킬 수 있을까요?
아래 코드를 보면 또 새로운 변경의 여지를 볼 수 있습니다.
final public class IPhone {
private final int modelNumber;
private final int price;
private final List<String> contactList;
public IPhone(int modelNumber, int price, List<String> contactList) {
this.modelNumber = modelNumber;
this.price = price;
this.contactList = contactList;
}
public int getModelNumber() {
return modelNumber;
}
public int getPrice() {
return price;
}
public List<String> getContactList() {
return contactList;
}
}
public class IPhoneUser {
public static void main(String[] args) {
//연락처목록 생성
final ArrayList<String> contacts = new ArrayList<>();
contacts.add("010-1111-1111");
contacts.add("010-2222-2222");
contacts.add("010-3333-3333");
System.out.println("contacts = " + contacts);
//아이폰 생성
IPhone iPhone = new IPhone(395848, 1_200_000, contacts);
final List<String> receivedContacts = iPhone.getContacts();
//아이폰 내부에 있는 연락처를 꺼내옴
System.out.println("receivedContacts = " + receivedContacts);
//생성때 쓴 외부의 연락처를 변경
contacts.add("010-4444-4444");
System.out.println("receivedContacts = " + receivedContacts);
// 외부에 있는 contacts를 변경했는데 IPhone 객체 내부에 있는 값이 변했다?!
// 심지어 이는 외부로 꺼낸 contacts를 변경해도 IPhone 객체 내부의 contacts도 변한다!!
}
}
IPhone 클래스는 Reference Type을 멤버로 가집니다. 이때 Getter를 통해서 이 Reference Type 멤버를 넘겨주면 객체 내부의 참조를 그대로 건내주게 됩니다. 그리고 객체 외부에서는 그 참조값을 통해서 객체 내부의 맴버를 변경할 수 있게 됩니다. 이는 객체의 불변성을 무너지게 합니다.
위와같은 상황을 막는 방법은 간단합니다. 우선 생성자에 전달받는 Reference Type에 대해서는 Deep Copy하여 상태로 가져야하며, Getter를 호출할 때는 해당 Reference Type의 멤버를 Deep Copy해서 반환하면 됩니다.
아래의 수정된 코드를 살펴봅시다.
final public class IPhone {
private final int modelNumber;
private final int price;
private final List<String> contactList;
public IPhone(int modelNumber, int price, List<String> contactList) {
this.modelNumber = modelNumber;
this.price = price;
this.contactList = new ArrayList<>(contactList); //새로운 객체 생성
}
public int getModelNumber() {
return modelNumber;
}
public int getPrice() {
return price;
}
public List<String> getContacts() {
return new ArrayList<>(contactList); //새로운 객체 생성 후 반환
}
}
public class IPhoneUser {
public static void main(String[] args) {
// 연락처 생성
final ArrayList<String> contacts = new ArrayList<>();
contacts.add("010-1111-1111");
contacts.add("010-2222-2222");
contacts.add("010-3333-3333");
System.out.println("contacts = " + contacts);
//아이폰 생성 시 연락처를 넣는다.
IPhone iPhone = new IPhone(395848, 1_200_000, contacts);
//아이폰 내의 연락처를 얻는다.
final List<String> receivedContacts = iPhone.getContacts();
//생성 시 넣었던 연락처를 수정한다.
contacts.add("010-4444-4444");
System.out.println("contacts = " + contacts);
System.out.println("receivedContacts = " + receivedContacts);
//객체에 요청해서 얻은 연락처를 수정한다
receivedContacts.add("010-5555-5555");
System.out.println("receivedContacts = " + receivedContacts);
//객체 내부에 있는 연락처를 출력해본다.
iPhone.printContacts();
// 모든 연락처는 서로 다른 값을 가진다.
// 즉 IPhone 객체는 자신의 상태의 불변성을 지키고 있다.
}
}
IPhone class를 final class로 만들어 상속을 막는 방법 외에도 정적 팩토리 메소드와 private 생성자를 통해서 객체의 생성을 제어하는 방법도 있습니다.
이 방식의 장점은 IPhone를 유연하게 확장하면서도 객체의 불변성을 지킬 수 있다는 것입니다.
public class IPhone {
private final int modelNumber;
private final int price;
private final List<String> contactList;
private IPhone(int modelNumber, int price, List<String> contactList) {
this.modelNumber = modelNumber;
this.price = price;
this.contactList = new ArrayList<>(contactList); //새로운 객체 생성
}
public static IPhone valueOf(int modelNumber, int price, List<String> contactList){
return new IPhone(modelNumber, price, contactList);
}
public int getModelNumber() {
return modelNumber;
}
public int getPrice() {
return price;
}
public List<String> getContacts() {
return new ArrayList<>(contactList);
} //새로운 객체 생성 후 반환
public void printContacts(){
System.out.printf("IPhone 내부의 연락처 = %s",this.contactList);
}
}
public class IPhoneUser {
public static void main(String[] args) {
//Mutable Ojbect
final ArrayList<String> contacts = new ArrayList<>();
contacts.add("010-1111-1111");
contacts.add("010-2222-2222");
contacts.add("010-3333-3333");
System.out.println("contacts = " + contacts); // 연락처 생성
//정적 팩토리 메소드를 통해서 IPhone를 생성한다.
IPhone iPhone = IPhone.valueOf(395848, 1_200_000, contacts);
.
.
.
}
- 맴버변수에 private final을 붙인다.
- getter가 있다고 setter를 무조건 만들지 말자.
- final class로 선언하여 상속을 막자.
- 혹은 모든 생성자를 private로 제한하고, 정적 팩토리 메소드를 통해서만 객체를 생성할 수 있게 하자.
- 생성자의 인자로 Reference Type을 받을 때는 Deep Copy를 해서 맴버변수에 저장하자.
- getter를 통해서 Reference Type의 맴버를 반환할 때는 Deep Copy후 반환하자.
불변객체를 만들거나, 값을 수정하거나, 내부의 값을 밖으로 보낼 때는 항상 새로운 객체를 생성하게 됩니다
그러므로 불변객체의 유일한 단점은 특정한 상황에서의 성능저하 뿐입니다.
이 특정한 상황이란 Stirng 객체의 문자열 연산에 대한 이슈와 비슷한 상황을 의미합니다.
Stirng Class의 불변성에 대해서는 👉 해당 아티클 👈을 참고 바랍니다.
Effective Java - ITEM 17
Immutable Ojbect - wikipedia
Why final keyword is necessary for immutable class? - Stack Overflow