자바에서 제공하는 굉장히 많은 클래스들이 불변 객체로 설계되어 있다.
자바의 데이터 타입을 가장 크게 보면 기본형(Primitive Type)과 참조형(Reference Type)으로 나눌 수 있다.
기본형 : 하나의 값을 여러 변수에서 절대로 공유하지 않는다.
참조형 : 하나의 객체를 참조값을 통해 여러 변수에서 공유할 수 있다.
기본형 변수는 하나의 값을 공유하거나 공유하지 않는다는 뜻은 다음 예제와 같다.
public static void main(String[] args) {
int a = 10;
int b = a; // a-> 값 복사 후 대입
System.out.println("a = " + a);
System.out.println("b = " + b);
b = 20;
System.out.println("20 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
}
너무 당연한 예제이다.
a의 값을 복사해서 b에 넣는 것일뿐 하나의 값을 공유하지 않는다.
a가 가진 10과 b가 가진 10이 당연히 별도의 메모리 공간에 점유되어있다.
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 + '\'' +
'}';
}
}
public static void main(String[] args) {
Address a = new Address("서울");
Address b = a;
System.out.println("address = " + a);
System.out.println("b = " + b);
b.setValue("부산"); //b의 값을 부산으로 변경해야함
System.out.println("부산 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
}
이번엔 a의 값도 부산으로 변경되는 것을 볼 수 있다.
참조형 변수들은 같은 참조값을 통해 같은 인스턴스를 참조할 수 있다.
a, b는 같은 참조값을 가지고 있기 때문에 같은 인스턴스를 참조하고 있다.
사이드 이펙트
사이드 이펙트(Side Effect)는 프로그래밍에서 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 말한다. 앞서 b의 값을 부산으로 변경한 코드를 다시 분석해보자.
b.setValue("부산"); //b의 값을 부산으로 변경해야함
System.out.println("부산 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
개발자는 b의 주소값을 서울에서 부산으로 변경할 의도로 값 변경을 시도했다.
하지만 a, b는 같은 인스턴스를 참조해서 a의 값도 함께 부산으로 변경되어 버렸다.
이렇게 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 사이드 이펙트라 한다.
프로그래밍에서 사이드 이펙트는 보통 부정적인 의미로 사용되는데, 사이드 이펙트는 프로그램의 특정 부분에서 발생한 변경이 의도치 않게 다른 부분에 영향을 미치는 경우에 발생한다.
이로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있다.
이런 문제를 해결하기 위해선 어떻게 해야할까?
public static void main(String[] args) {
Address a = new Address("서울");
Address b = new Address("서울");
System.out.println("address = " + a);
System.out.println("b = " + b);
b.setValue("부산"); //b의 값을 부산으로 변경해야함
System.out.println("부산 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
}
그냥 참조를 다르게 해버리면 그만이다.
인스턴스를 하나 더 만들어버리는 것이다.
그런데 여기서 문제는, 하나의 객체를 여러 변수가 공유하지 않도록 강제로 막을 수 있는 방법이 없다는 것이다.
Address a = new Address("서울");
Address b = a;
b = a와 같은 코드는 오류가 발생하지 않는 코드이다.
자바 문법 상 Address b = a와 같은 참조형 변수의 대입은 아무런 문제가 없기 때문이다.
기본형은 항상 값을 복사해서 대입하기 때문에 값이 절대로 공유되지 않지만 참조형은 공유가 된다.
객체의 공유가 꼭 필요할 때도 있지만, 때로는 공유하는 것이 지금과 같은 사이드 이펙트를 만드는 경우도 있다.
public static void main(String[] args) {
Address a = new Address("서울");
Address b = a;
System.out.println("address = " + a);
System.out.println("b = " + b);
change(b, "부산");
System.out.println("a = " + a);
System.out.println("b = " + b);
}
private static void change(Address address, String changeAddress){
System.out.println("주소 값을 변경합니다 -> " + changeAddress);
address.setValue(changeAddress);
}
이런 경우 개발자가 헷갈리기 쉽다.
이렇게 공유하면 안되는 객체를 여러 변수에서 공유했기 때문에 발생한 문제들을 볼 수 있다.
하지만 앞서 살펴보았듯이 객체의 공유를 막을 수 있는 방법은 없다.
그런데 사이트 이펙트의 더 근본적인 원인을 고려해보면, 객체를 공유하는 것 자체는 문제가 아니다.
문제의 직접적인 원인은 공유된 객체의 값을 변경한 것에 있다.
Address a = new Address("서울");
Address b = a;
Address a = new Address("서울");
Address b = new Address("서울");
코드 두개를 비교해보자.
위의 코드는 인스턴스 하나만 생성하면 되는 반면에 아래 코드는 인스턴스를 두개 생성해야 하고 메모리도 더 많이 쓰이게 된다. 그렇기 때문에 사실상 인스턴스를 하나 생성하는 위의 코드가 훨씬 효율적이다.
여기까지는 Address b = a와 같이 공유 참조를 사용해도 아무런 문제가 없다. 오히려 더 효율적이다.
진짜 문제는 이후에 b가 공유 참조하는 인스턴스의 값을 변경하기 때문에 발생한다.
b.setValue("부산");
만약 Address 객체의 값을 변경하지 못하게 설계했다면 이런 사이드 이펙트 이펙트 자체가 발생하지 않을 것이다.
불변객체
객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 불변 객체(Immutable Object)라 한다.
앞서 만들었던 Address 클래스를 상태가 변하지 않는 불변 클래스로 다시 만들어보자.
public class ImmutableAddress {
private final String value;
public ImmutableAddress(String value) {
this.value = value;
}
public String getValue() {
return value;
}
@Override
public String toString() {
return "Address{" +
"value='" + value + '\'' +
'}';
}
}
내부 값이 변경되면 안되기 때문에 value의 필드를 final로 선언하였고 값을 변경할 수 있는 setValue()를 제거했다.
이 클래스는 생성할때 생성자를 통해서만 값을 설정할 수 있고, 이후 값을 변경하는 것이 불가능해진다
public static void main(String[] args) {
ImmutableAddress a = new ImmutableAddress("서울");
ImmutableAddress b = a; //참조값 대입을 막을 수 있는 방법은 없다.
System.out.println("address = " + a);
System.out.println("b = " + b);
b.setValue("부산"); //하지만 b 값의 변경을 막을 수 있다.
System.out.println("부산 -> b");
System.out.println("a = " + a);
System.out.println("b = " + b);
}
ImmutableAddress의 경우 값을 변경할 수 있는 setValue()메서드도 없거니와 value가 final로 선언이 되어있어 생성자로 한번 초기화를 진행해주면 값의 변경이 불가능하다.
그렇기에 ImmutableAddress 인스턴스의 값을 변경할 수 있는 방법은 없다.
불변이라는 단순한 제약을 사용해서 사이드 이펙트라는 큰 문제를 막을 수 있다.
객체의 공유 참조는 막을 수 없다. 객체의 값을 변경하면 다른 곳에서 참조하는 변수의 값도 함께 변경되는 사이드 이펙트가 발생한다. 사이드 이펙트가 발생하면 안되는 상황이라면 불변 객체를 만들어서 사용하면 된다. 불변 객체는 값을 변경할 수 없기 때문에 사이드 이펙트가 원천 차단된다.
불변 객체는 값을 변경할 수 없다. 따라서 불변 객체의 값을 변경하고 싶다면 변경하고 싶은 값으로 새로운 불변 객체를 생성해야 한다. 이렇게 하면 기존 변수들이 참조하는 값에는 영향을 주지 않는다.
가변 vs 불변
가변 : 이름 그대로 처음 만든 이후 상태가 변할 수 있다는 뜻이다.
불변 : 이름 그대로 처음 만든 이후 상태가 변하지 않는다는 뜻이다.
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 + '\'' +
'}';
}
}
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 +
'}';
}
}
public static void main(String[] args) {
Address address = new Address("서울");
MemberV1 memberA = new MemberV1("회원A",address);
MemberV1 memberB = new MemberV1("회원A",address);
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
//회원B의 주소를 부산으로 변경해야함
// Address address1 = memberB.getAddress();
// address1.setValue("부산");
memberB.getAddress().setValue("부산");
System.out.println("부산 -> memberB.address");
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
}
다음 예제를 언뜻 보면 회원B의 주소만 변경한 것 같지만 회원A도 같이 참조하고 있는 address의 value가 부산으로 변경되었기에 memberA, memberB 모두 부산으로 변경이 되었다.
public class ImmutableAddress {
private final String value;
public ImmutableAddress(String value) {
this.value = value;
}
//생성자로 무조건 초기화 한번 해줘야하니까 setValue가 없어야함
public String getValue() {
return value;
}
@Override
public String toString() {
return "Address{" +
"value='" + value + '\'' +
'}';
}
}
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 "MemberV2{" +
"name='" + name + '\'' +
", address=" + address +
'}';
}
}
public static void main(String[] args) {
ImmutableAddress address = new ImmutableAddress("서울");
MemberV2 memberA = new MemberV2("회원A",address);
MemberV2 memberB = new MemberV2("회원A",address);
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
//회원B의 주소를 부산으로 변경해야함
// Address address1 = memberB.getAddress();
// address1.setValue("부산");
memberB.setAddress(new ImmutableAddress("부산"));
System.out.println("부산 -> memberB.address");
System.out.println("memberA = " + memberA);
System.out.println("memberB = " + memberB);
// memberB.getAddress().setValue("부산");
// System.out.println("memberA = " + memberA);
// System.out.println("memberB = " + memberB);
}
다음은 불변객체를 활용한 코드 예시이다.
ImmutableAddress를 사용했기 때문에 setValue로 값을 변경할 수 없고
memberB.setAddress(new ImmutableAddress("부산"))와 같이 새로운 주소 객체를 만들어서 전달한다.
불변 객체-값 변경
불변 객체를 사용하지만 그래도 값을 변경해야 하는 메서드가 필요하다면 어떻게 해야할까?
public class MutableObj {
private int value;
public MutableObj(int value) {
this.value = value;
}
public void add(int addValue){
value = value +addValue;
}
public int getValue() {
return value;
}
public void setValue(int value) {
this.value = value;
}
}
public static void main(String[] args) {
MutableObj obj = new MutableObj(10);
obj.add(20);
//계산 이후의 기존 값은 사라짐
System.out.println("obj = " + obj.getValue());
}
위와 같은 코드는 MutableObj를 10이라는 값으로 생성한다.
이후에 obj.add(20)을 통해서 10+20을 수행한다.
계산 이후에 기존 10이라는 값은 사라지고 MutableObj의 상태가 30으로 변경된다.
obj.getValue()를 호출하면 30이 출력되는 예제이다.
불변 객체에서 add()메서드를 어떻게 구현할까
public class ImmutableObj {
private final int value;
public ImmutableObj(int value) {
this.value = value;
}
public ImmutableObj add(int addVaue){
int result = value + addVaue;
return new ImmutableObj(result);
}
public int getValue() {
return value;
}
}
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());
}
add를 할 때 새로운 객체를 생성해서 리턴하여
기존 obj의 value값은 그대로 유지하도록 한다.(결국 객체가 두개 생성 되는 것)
메모리 구조를 간단하게 그려가며 공부하니 조금 더 수월하게 공부할 수 있었다.
메모리 구조를 그려가며 공부하는 것을 추천한다
참고 - withXxx()
불변 객체에서 값을 변경하는 경우 withYear()처럼 'with'로 시작하는 경우가 많다
예를 들어 "coffee with sugar"커피에 설탕이 추가되어 원래의 상태를 변경하여 새로운 변형을 만든다는 것을 의미한다.
이 개념을 프로그래밍에 적용하면, 불변 객체의 메서드가 "with"로 이름 지어진 경우, 그 메서드가 지정된 수정사항을 포함하는 객체의 새 인스턴스를 반환한다는 사실을 뜻한다.
정리하면 "with"는 관례처럼 사용되는데, 원본 객체의 상태가 그대로 유지됨을 강조하면서 변경사항을 새 복사본에 포함하는 과정을 간결하게 포함한다.
자바에서 가장 많이 사용하는 String 클래스가 불변객체이다. Integer, LocalDate 등 수 많은 클래스가 불변으로 설계되어 있다.
하지만 모든 클래스를 불면으로 만드는 것은 아니다.
클래스를 불변으로 설계하는 이유는 아래와 같다
1. 캐시 안정성
2. 멀티 쓰레드 안정성
3. 엔티티의 값 타입
'공부 > Java' 카테고리의 다른 글
자바 래퍼 클래스(wrapper class) (0) | 2024.09.10 |
---|---|
자바 String 클래스 (1) | 2024.09.10 |
자바 Object 클래스 (1) | 2024.09.08 |
자바 다형성 3편 (Polymorphism) (1) | 2024.09.05 |
자바 좋은 객체 지향 프로그래밍이란? (1) | 2024.09.05 |