
불변 객체를 알아보기 전에 기본형과 참조형의 공유라는 개념부터 알아보도록 하자. 자바의 데이터 타입을 크게 보면 “기본형”, “참조형” 으로 나눌 수 있다고 배웠다. 기본형은 하나의 값을 여러 변수에서 절대로 공유하지 않는 특징을 가졌고, 참조형은 하나의 객체를 참조값을 통해 여러 변수에서 공유할 수 있다는 특징을 가졌다.
하나의 값을 공유하거나 공유하지 않는다는 말은 무슨 의미일까? 복습 차원에서 한번 들여다보자.
package lang.immutable.address;
public class PrimitiveMain {
public static void main(String[] args) {
// 기본형은 절대 같은 값을 공유하지 않는다.
int a = 10;
int b = a;
System.out.println("a = " + a + ", b = " + b);
b = 20;
System.out.println("a = " + a + ", b = " + b);
}
}
/*
a = 10, b = 10
a = 10, b = 20
*/
기본형 변수는 위와 같이 절대 하나의 값을 공유하지 않는다. 코드에서 b = a라고 했을 때, 자바는 항상 값을 복사해서 대입하기 때문에 a에 있는 값 10을 복사해서 b에 전달한다. 결과적으로는 a와 b 모두 10이라는 똑같은 숫자를 가지고 있지만, 전혀 다른 10이다. 별도의 메모리 공간에 존재하는 것이다. 그렇기 때문에 b의 값을 변경하면 a에는 전혀 영향이 가지 않는 것이다.
이번엔 참조형 변수 예제를 살펴보자.
package lang.immutable.address;
public class Address {
private String value;
public Address(String value) {
this.value = value;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
@Override
public String toString() {
return "Address{" +
"value='" + value + '\'' +
'}';
}
}
package lang.immutable.address;
public class RefMain1_1 {
public static void main(String[] args) {
// 참조형 변수는 하나의 인스턴스를 공유할 수 있다.
Address address1 = new Address("서울");
Address address2 = address1;
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
address2.setValue("부산");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
}
}
/*
address1 = Address{value='서울'}
address2 = Address{value='서울'}
address1 = Address{value='부산'}
address2 = Address{value='부산'}
*/
당연히 address1 참조값을 복사해서 address2에 대입했기 때문에 둘은 같은 인스턴스 객체를 바라본다. 그렇기 때문에 참조값을 통해 인스턴스의 값을 바꾸면 당연히 다른 녀석이 그 인스턴스를 찾아 갔을 때도 변경된 값을 보게 되는 것이다. 너무 쉽고 당연한 얘기다. 하지만 지금은 예제가 쉬워서 그렇지, 실제 개발을 하다보면 메모리 구조를 모른다면 충분히 실수를 할 수도 있다.
사이드 이펙트(Side Effect)란… 뭔가 내가 한 게 아니라 다른 데서 효과가 터지는 상황인 것 같다. 프로그래밍에서는 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 뜻한다. 위의 부산으로 인스턴스의 값을 변경한 코드를 다시 한번 들여다보자.
address2.setValue("부산");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
address2 값만 부산으로 바꾸려고 의도한 건데, 알고 보니 address1의 값도 같은 참조이기 때문에 바뀐 상황인 것이다. 이런 걸 “Side Effect” 라고 한다. 프로그래밍에서 이런 상황이 발생하면 보통 부정적인 의미로 해석된다. 왜냐하면 프로그램의 특정 부분에서 발생한 변경이 의도치 않게 다른 부분에 영향을 미치기 때문이다. 이로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있다.
그럼 이 문제를 어떻게 해결할 수 있을까? 생각보다 아주 단순하다. 그냥 처음부터 address1과 address2가 서로 다른 인스턴스를 참조하게 하면 된다.
package lang.immutable.address;
public class RefMain1_1 {
public static void main(String[] args) {
// 이런 식으로 처음부터 다른 인스턴스를 참조하도록
Address address1 = new Address("서울");
Address address2 = new Address("서울");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
address2.setValue("부산");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
}
}
/*
address1 = Address{value='서울'}
address2 = Address{value='서울'}
address1 = Address{value='서울'}
address2 = Address{value='부산'}
*/
지금까지 발생한 모든 문제는 같은 인스턴스를 여러 개의 참조값 변수가 바라보고 있었기 때문이다. 이럴 때 서로 다른 객체를 참조하도록 했다. 하지만, 문제가 하나 있다. 여러 개의 변수가 하나의 객체를 공유하는 것은 막을 수 없다는 점이다.
Address address1 = new Address("서울");
Address address2 = address1; // 이것처럼 참조값 대입을 막을 수 있는 방법이 없다.
기본형은 항상 값을 복사해서 대입하기 때문에 값이 절대로 공유될 일이 없지만, 이처럼 참조형의 경우, 참조값을 복사해서 대입하기 때문에 여러 변수에서 얼마든지 같은 객체를 공유할 수 있는 것이다. 객체의 공유가 꼭 필요할 때도 있지만, 때로는 공유하는 것이 위와 같은 Side Effect를 만드는 경우도 있다.
그래서 어쩌란 말이냐? 단순히 개발자가 공유 참조 문제가 발생하지 않도록 매우 조심해서 코드를 작성해야 하는 걸까?
한마디로 정리하면, 공유하면 안 되는 객체를 공유해서 문제가 발생한 것이다. Side Effect가 발생한 근본적인 원인을 생각해보면, 객체를 공유하는 것 자체가 문제가 아니라 “공유된 객체의 값을 변경” 했기 때문이다.
위의 코드에서 address1, address2 모두 “서울” 이라는 주소를 사용해야 했다. 그리고 이후에 address2의 주소만 “부산” 으로 변경하려 한 것이다.
Address address1 = new Address("서울");
Address address2 = address1;
분명히 이처럼 Address 인스턴스를 하나만 생성하는 것이 메모리와 성능적으로 더 효율적이다. 애초에 참조형인 객체는 여러 참조형 변수에서 공유될 수 있도록 설계되었다. 진짜 문제는 이후에 address2가 공유 참조하는 인스턴스의 값을 변경했기 때문에 발생한다.
address2.setValue("부산");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);
이제 “불변 객체” 에 대해 얘기해보자. 불변 객체(Immutable Object)란, 객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 말한다. 앞서 만들었던 Address 클래스를 불변 객체로 다시 설계해보자.
package lang.immutable.address;
public class ImmutableAddress {
private final String value; // value 값을 바꿀 수 없도록 수정
public ImmutableAddress(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return "ImmutableAddress{" +
"value='" + value + '\'' +
'}';
}
}
보다시피 value 필드를 final로 막고, 외부에서 값을 변경할 수 있는 setValue() 메서드도 제거한다. 이 ImmutableAddress 클래스는 생성자를 통해서만 값을 설정할 수 있고, 이후에는 값을 변경하는 것이 불가능하다. 이처럼 불변 클래스를 만드는 것은 생각보다 쉽다. 그냥 어떻게든 필드 값을 변경할 수 없도록 설계하면 그만인 것이다.

이처럼 setValue() 메서드가 없다고 컴파일 오류가 발생한다. 어떤 개발자가 이렇게 실수를 한다면, 이렇게는 값을 바꾸지 못한다고 깨닫게 된다. 그럼 개발자는 값을 바꾸지 못하므로 새로운 객체를 생성해서 담으려고 자연스럽게 생각할 것이다.

다시 정리하자면, 불변 객체는 값을 변경할 수 없기 때문에, 불변 객체의 값을 변경하고 싶으면 변경하고 싶은 값으로 새로운 불변 객체를 생성해야 한다. 이렇게 해야 기존 변수들이 참조하는 값에 영향을 주지 않는다.
먼저 변경 가능한 클래스를 하나 만들어보자.
package lang.immutable.address;
public class MemberV1 {
private String name;
private Address address;
public MemberV1(String name, Address address) {
this.name = name;
this.address = address;
}
public Address getAddress() {
return address;
}
public void setAddress(Address address) {
this.address = address;
}
@Override
public String toString() {
return "MemberV1 [name=" + name + ", address=" + address + "]";
}
}
package lang.immutable.address;
public class MemberMainV1 {
public static void main(String[] args) {
Address address = new Address("서울");
// 회원A와 회원B는 같은 Address 인스턴스를 참조
MemberV1 memberA = new MemberV1("회원A", address);
MemberV1 memberB = new MemberV1("회원B", address);
// 처음 회원A, 회원B의 주소는 모두 서울이다.
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
// 요구 사항 변경 (회원B의 주소가 부산으로 변경해야 함)
memberB.getAddress().setValue("부산");
System.out.println("부산 -> memberB.address");
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
}
}
/*
memberA = MemberV1 [name=회원A, address=Address{value='서울'}]
memberB = MemberV1 [name=회원B, address=Address{value='서울'}]
부산 -> memberB.address
memberA = MemberV1 [name=회원A, address=Address{value='부산'}]
memberB = MemberV1 [name=회원B, address=Address{value='부산'}]
*/
위의 MemberV1 클래스는 변경 가능한 Address 클래스를 사용한다. 처음에는 회원A와 회원B는 모두 서울에 살고 있다. 하지만 요구 사항이 변경되어 회원B의 주소만 부산으로 옮겨야 하는데, 회원B의 주소를 변경하는 순간 회원A의 주소도 변경되는 문제가 발생한다. 왜냐하면 회원A와 회원B는 같은 Address 인스턴스를 참조하고 있기 때문이다.
이제 Address에서 ImmutableAddress로 바꿔보자.
package lang.immutable.address;
public class MemberV2 {
private String name;
private ImmutableAddress address;
public MemberV2(String name, ImmutableAddress address) {
this.name = name;
this.address = address;
}
public ImmutableAddress getAddress() {
return address;
}
public void setAddress(ImmutableAddress address) {
this.address = address;
}
@Override
public String toString() {
return "MemberV1 [name=" + name + ", address=" + address + "]";
}
}
package lang.immutable.address;
public class MemberMainV2 {
public static void main(String[] args) {
ImmutableAddress address = new ImmutableAddress("서울");
MemberV2 memberA = new MemberV2("회원A", address);
MemberV2 memberB = new MemberV2("회원B", address);
// 처음 회원A, 회원B의 주소는 모두 서울이다.
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
// 요구 사항 변경 (회원B의 주소가 부산으로 변경해야 함)
// memberB.getAddress().setValue("부산"); // Cannot resolve method 'setValue' in 'ImmutableAddress'
memberB.setAddress(new ImmutableAddress("부산"));
System.out.println("부산 -> memberB.address");
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
}
}
/*
memberA = MemberV1 [name=회원A, address=ImmutableAddress{value='서울'}]
memberB = MemberV1 [name=회원B, address=ImmutableAddress{value='서울'}]
부산 -> memberB.address
memberA = MemberV1 [name=회원A, address=ImmutableAddress{value='서울'}]
memberB = MemberV1 [name=회원B, address=ImmutableAddress{value='부산'}]
*/
코드 중간을 보면, 회원B의 주소를 부산으로 변경하려는 시도가 있었다. 하지만 ImmutableAddress에는 값을 변경할 수 있는 메서드가 없기 때문에 컴파일 오류가 터진다. 결국 memberB.setAddress(new ImmutableAddress("부산"))와 같이 새로운 주소 객체를 만들어서 전달해야 한다. 이러면 Side Effect가 발생하지 않아 회원A는 기존 주소를 그대로 유지하게 된다.
불변 객체를 사용해도 값을 변경해야 하는 메서드가 필요할 수도 있다. 예를 들어, 기존 값에 새로운 값을 더하는 add() 메서드가 있다. 먼저 변경 가능한 객체에서 값을 변경하는 간단한 예제를 만들어보자.
package lang.immutable.change;
public class MutableObj {
private int value;
public MutableObj(int value) {
this.value = value;
}
public void add(int value) {
this.value += value;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
package lang.immutable.change;
public class MutableMain {
public static void main(String[] args) {
MutableObj obj = new MutableObj(10);
obj.add(20);
// 계산 이후의 기존 값은 사라짐
System.out.println("obj = " + obj.getValue());
}
}
/*
obj = 30
*/
처음에 MutableObj을 10으로 값을 설정하고 생성한다. 이후에 obj.add(20)을 통해서 10 + 20을 수행한다. 그럼 계산 이후에 기존에 있던 10이라는 값은 사라지고, MutableObj의 상태가 10에서 30으로 변경된다. 따라서 obj.getValue()를 호출하면 30이 출력되는 것이다.
그럼 이렇게 값이 바뀌는 상황을 어떻게 불변 객체에서 구현한다는 거지? 불변 객체들에도 값을 바꾸는 메서드들이 많이 있다. 예제를 살펴보자.
package lang.immutable.change;
public class ImmutableObj {
private final int value; // value는 절대 바뀌면 안 된다
public ImmutableObj(int value) {
this.value = value;
}
// 본인(value)의 값은 절대 바꾸지 않는 대신, 새로운 객체를 만들어서 반환
public ImmutableObj add(int addValue) {
int result = value + addValue;
return new ImmutableObj(result);
}
public int getValue() {
return value;
}
}
package lang.immutable.change;
public class ImmutableMain1 {
public static void main(String[] args) {
ImmutableObj obj1 = new ImmutableObj(10);
ImmutableObj obj2 = obj1.add(20);
// 계산 이후에도 기존값과 신규값 모두 확인 가능
System.out.println("obj1 = " + obj1.getValue());
System.out.println("obj2 = " + obj2.getValue());
}
}
/*
obj1 = 10
obj2 = 30
*/
이처럼 불변 객체를 설계할 때 기존 값을 변경해야 하는 상황이 생길 수 있다. 이때 기존 객체의 값은 놔두고 변경된 결과를 새로운 객체에 담아서 반환하면 되는 것이다. 보다시피 기존값(obj1)은 그대로 유지된다.

만약 새로 생성된 반환 값을 사용하지 않는다면 아무 일도 일어나지 않은 것처럼 보인다. 그렇기 때문에 불변 객체에서 변경과 관련된 메서드들은 보통 객체를 새로 만들어서 반환하기 때문에 “꼭 반환 값을 받아야 한다.”
값을 분명히 바꿨는데 아무 변화가 없다? “이거 불변인데 내가 반환값을 안 받았구나…” 라고 생각하면 된다.