본문 바로가기

Java

[Java] 불변 객체, 불변 클래스

기본형과 참조형의 공유

자바의 데이터 타입을 가장 크게 보면 기본형(Primitive Type), 참조형(Reference Type) 으로 나눌 수 있습니다.

  • 기본형: 하나의 값을 여러 변수에서 절대로 공유하지 않는다.
  • 참조형: 하나의 객체를 참조값을 통해 여러 변수에 공유할 수 있다.

"기본형은 하나의 값을 여러 변수에서 절대로 공유하지 않는다"  이 말은 무슨 뜻 일까요?

public class PrimitiveMain {

    public static void main(String[] args) {
        // 기본형은 절대로 같은 값을 공유하지 않는다.
        int a = 10;
        int b = a; // a => b 값 복사 후 대입
        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 = 10
b = 10
  • 기본형 변수 a와 b는 절대로 하나의 값을 공유하지 않는다.
  • b = a 라고 하면 자바는 항상 값을 복사해서 대입한다.
  • a 가 가지는 10과 b가 가지는 10은 복사된 완전히 다른 10이다.
  • 메모리 상에서도 a에 속하는 10과 b에 속하는 10이 각각 별로도 존재한다.
b -> 20
a = 10
b = 20
  • b = 20 이라고 하면 b의 값만 20으로 변경된다.
  • a의 값은 10으로 그대로 유지된다.
  • 기본형 변수는 하나의 값을 절대로 공유하지 않는다.
  • 값을 변경해도 변수 하나의 값만 변경된다.

 

"그렇다면 참조형은 어떨까요?"

public class RefMain1_1 {

    public static void main(String[] args) {
        // 참조형 변수는 하나의 인스턴스를 공유할 수 있다.
        Address a = new Address("서울");
        Address b = a;
        System.out.println("a = " + a);
        System.out.println("b = " + b);

        b.setValue("부산"); // b의 값을 부산으로 변경해야 함
        System.out.println("부산 -> b");
        System.out.println("a = " + a); // 사이드 이펙트 발생
        System.out.println("b = " + b);
    }
}
a = Address{value='서울'}
b = Address{value='서울'}
부산 -> b
a = Address{value='부산'}
b = Address{value='부산'}
  • 참조형 변수들은 같은 참조값을 통해 같은 인스턴스를 참조할 수 있다.
  • b = a 라고 하면 a에 있는 참조값(x001) 을 복사해서 b에 전달한다.
  • 참조값을 복사해서 전달하므로 결과적으로 a, b는 같은 x001 인스턴스를 참조한다.
  • 참조형 변수는 참조값을 통해 같은 객체(인스턴스)를 공유할 수 있다.

b의 주소만 변경했지만, a의 주소도 함께 부산으로 변경되어 버렸습니다.

개발을 하다 보면 누구나 이런 실수를 할 수 있을 것 같다는 생각도 들 수 있습니다.

 

공유 참조와 사이드 이펙트

사이드 이펙트(Side Effect)는 프로그래밍에서 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 말합니다.

b.setValue("부산"); // b의 값을 부산으로 변경해야 함
System.out.println("부산 -> b");
System.out.println("a = " + a); // 사이드 이펙트 발생
System.out.println("b = " + b);
  • 개발자는 b의 주소값을 서울에서 부산으로 변경할 의도로 값 변경을 시도했다.
  • 하지만 a, b는 같은 인스턴스를 참조한다. 따라서 a의 값도 함께 부산으로 변경되어 버린다.

이렇게 주된 작업 외에 추가적인 부수 효과를 일으킨느 것을 사이드 이펙트 라고 합니다.

프로그래밍에서 사이드 이펙트는 프로그램의 특정 부분에서 발생한 변경이 의도치 않게 다른 부분에

영향을 미치는 경우에 발생합니다. 이로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있습니다.

 

사이드 이펙트 해결 방안

 

생각해보면 문제의 해결방안은 아주 단순합니다. 처음부터 a와 b가 서로 다른 인스턴스를 참조하면 됩니다.

Address a = new Address("서울");
Address b = new Address("서울");
public class RefMain1_2 {

    public static void main(String[] args) {
        
        Address a = new Address("서울");
        Address b = new Address("서울");
        System.out.println("a = " + a);
        System.out.println("b = " + b);

        b.setValue("부산"); 
        System.out.println("부산 -> b");
        System.out.println("a = " + a);
        System.out.println("b = " + b);
    }
}
a = Address{value='서울'}
b = Address{value='서울'}
부산 -> b
a = Address{value='서울'}
b = Address{value='부산'}
  • a와 b는 서로 다른 Address 인스턴스를 참조한다.
  • 따라서 b가 참조하는 인스턴스의 값을 변경해도 a에는 영향을 주지 않는다.

 

여러 변수가 하나의 객체를 공유하는 것을 막을 방법은 없다

 

지금까지 발생한 모든 문제는 같은 객체(인스턴스)를 변수 a, b가 함께 공유하기 때문에 발생했습니다.

따라서 객체를 공유하지 않으면 문제가 해결됩니다. 여기서 변수 a, b가 서로 각각 다른 주소지로 변경하려면

서로 다른 객체를 참조하면 됩니다.

 

객체를 공유

Address a = new Address("서울");
Address b = a;
  • a, b 둘다 같은 Address 인스턴스를 바라보기 때문에 한쪽의 주소만 부산으로 변경하는 것이 불가능하다.

객체를 공유 하지 않음

Address a = new Address("서울");
Address b = new Address("서울");
  • a, b 는 서로 다른 Address 인스턴스를 바라보기 때문에 한쪽의 주소만 부산으로 변경하는 것이 가능하다.

 

여러 변수가 하나의 객체를 공유하지 않으면 지금까지 설명한 문제들이 발생하지 않습니다.

그런데 여기서 문제가 있는데, 하나의 객체를 여러 변수가 공유하지 않도록 강제로 막을 수 있는 방법이 없다는 것입니다.

Address a = new Address("서울");
Address b = a; // 참조값 대입을 막을 수 있는 방법이 없다.

 

왜냐하면 자바 문법상 Address b = a 와 같은 참조형 변수의 대입은 아무런 문제가 없기 때문입니다.

 

기본형은 항상 값을 복사해서 대입하기 떄문에 값이 절대로 공유하지 않습니다.

하지만 참조형의 경우 참조값을 복사해서 대입하기 때문에 여러 변수에서 얼마든지 같은 객체를 공유할 수 있습니다.

객체의 공유가 꼭 필요할 때도 있지만, 때로는 공유하는 것이 지금과 같은 사이드 이펙트를 만들 수 있습니다.

 

복잡한 코드에서는 공유된 참조를 찾기 더 힘들 것입니다. 

public class RefMain1_3 {

    public static void main(String[] args) {
        // 참조형 변수는 하나의 인스턴스를 공유할 수 있다.
        Address a = new Address("서울");
        Address b = a;
        System.out.println("a = " + 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);
    }
}
a = Address{value='서울'}
b = Address{value='서울'}
주소 값을 변경합니다 -> 부산
a = Address{value='부산'}
b = Address{value='부산'}

 

여러 변수가 하나의 객체를 참조하는 공유 참조를 막을 수 있는 방법은 없습니다.

그럼 공유 참조로 인해 발생하는 문제를 어떻게 해결해 갈 수 있을까요?

 

불변 객체의 필요성

 

사이드 이펙트의 더 근본적인 원인을 고려해보면, 객체를 공유하는 것 자체는 문제가 아닙니다.

객체를 공유한다고 바로 사이드 이펙트는 발생하지 않습니다.

 

"공유된 객체의 값을 변경하기 전 까지는"

 

처음에는 b = a 같이 "서울" 이라는 Address 인스턴스를 a, b가 함께 사용하는 것이, 메모리와 성능성 더 효율적입니다.

인스턴스가 하나이기 때문에 메모리가 절약되고, 인스턴스를 하나 생성하지 않아도 되니 생성 시간이 줄어서 그렇습니다.

문제의 직접적인 원인은 공유된 객체의 값을 변경한 것에 있습니다.

쉽게 말하자면 이후에 b가 공유 참조하는 인스턴스의 값을 변경하기 때문에 발생합니다.

b.setValue("부산"); // b의 값을 부산으로 변경해야함
System.out.println("부산 -> b");
System.out.println("a = " + a); // 사이드 이펙트 발생
System.out.println("b = " + b);

 

 

문제의 직접적인 원인은 공유 될 수 있는 Address 객체의 값을 어디선가 변경했기 때문입니다.

만약 Address 객체의 값을 변경하지 못하게 설계했다면 어떻게 됐을까요?

 

불변 객체 도입

객체의 상태(객체 내부의 값, 필드, 멤버 변수) 가 변하지 않는 객체를 불변 객체(ImmutableObject)라고 합니다.

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 class RefMain2 {

    public static void main(String[] args) {

        ImmutableAddress a = new ImmutableAddress("서울");
        ImmutableAddress b = a; // 참조값 대입을 막을 수 있는 방법은 없다.
        System.out.println("a = " + a);
        System.out.println("b = " + b);

        //b.setValue("부산"); // b의 값을 부산으로 변경해야 함
        b = new ImmutableAddress("부산");
        System.out.println("부산 -> b");
        System.out.println("a = " + a); 
        System.out.println("b = " + b);
    }
}
  • ImmutableAddress의 경우 값을 변경할 수 있는 b.setValue( ) 메서드 자체가 제거되었다.
  • 이제 ImmutableAddress 인스턴스 값을 변경할 수 있는 방법은 없다.
  • ImmutableAddress를 사용하는 개발자는 값을 변경하려고 시도하지만 불가능한 것을 알자 객체가 불변 객체인 것을 깨달을 수 있다.
  • 어쩔수 없이 새로운 ImmutableAddress("부산") 인스턴스를 생성해서 b에 대입한다.
  • 결과적으로 a, b는 서로 다른 인스턴스를 참조하게 되고, a가 참조하던 ImmutableAddress는 그대로 유지된다.

정리

 

불변이라는 단순한 제약을 사용해서 사이드 이펙트라는 큰 문제를 막을 수 있다.

  • 객체의 공유 참조는 막을 수 없다. 그래서 객체의 값을 변경하면 다른 곳에서 참조하는 변수의 값도 함께 변경되는 사이드 이펙트가 발생한다. 사이드 이펙트가 발생하면 안되는 상황이라면 불변 객체를 만들어서 사용하면 된다. 불변 객체는 값을 변경할 수 없기 때문에 사이드 이펙트가 원천 차단된다.
  • 불변 객체는 값을 변경할 수 없다. 따라서 불변 객체의 값을 변경하고 싶다면 변경하고 싶은 값으로 새로운 불변 객체를 생성해야 한다. 이렇게 하면 기존 변수들이 참조하는 값에는 영향을 주지 않는다.

필드의 값이 바뀌어 공유 참조된 필드의 값도 동시에 바뀌지 않도록 하는 것 -> 불변 객체

반대 용어로는 가변 객체가 있다.

 

가변 클래스: 이 클래스로 객체를 생성하면 가변 객체가 된다. (Address)

불변 클래스: 이 클래스로 객체를 생성하면 불변 객체가 된다. (ImmutableAddress)

 

예제

public class MemberMainV1 {

    public static void main(String[] args) {
        
        Address address = new 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의 주소를 부산으로 변경해야 함.
        // Address address1 = memberB.getAddress();
        // address1.setValue("부산");
        
        memberB.getAddress().setValue("부산");
        System.out.println("부산 -> memberB.address");
        System.out.println("memberA = " + memberA);
        System.out.println("memberB = " + memberB);
    }
}
  • 회원A와 회원B는 둘다 서울에 살고 있다.
  • 중간에 회원B의 주소를 부산으로 변경해야 한다.
  • 그런데 회원A와 회원B는 같은 Address 인스턴스를 참조하고 있다.
  • 회원B의 주소를 부산으로 변경하는 순간 회원A의 주소도 부산으로 변경된다.
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='부산'}}

 

사이드 이펙트가 발생해서 회원B 뿐만 아니라 회원A의 주소도 부산으로 변경된다.

 

불변 클래스 사용

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 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);
        
        //memberB.getAddress().setValue("부산"); 컴파일 오류
        memberB.setAddress(new ImmutableAddress("부산"));
        System.out.println("부산 -> memberB.address");
        System.out.println("memberA = " + memberA);
        System.out.println("memberB = " + memberB);
    }
}
  • 회원B의 주소를 중간에 부산으로 변경하려고 시도한다. 하지만 ImmutableAddress에는 값을 변경할 수 있는 메서드가 없다. 따라서 컴파일 오류가 발생한다.
  • 결국 memberB.setAddress(new ImmutableAddress("부산")) 와 같이 새로운 주소 객체를 만들어서 전달한다.
memberA = MemberV2{name='회원A', address=Address{value='서울'}}
memberB = MemberV2{name='회원B', address=Address{value='서울'}}
부산 -> memberB.address
memberA = MemberV2{name='회원A', address=Address{value='서울'}}
memberB = MemberV2{name='회원B', address=Address{value='부산'}}

 

 

불변 객체 값 변경

불변 객체를 사용하지만 그래도 값을 변경해야 하는 메서드가 필요하면 어떻게 해야할까요?

public class MutableMain {

    public static void main(String[] args) {

        MutableObj obj = new MutableObj(10);
        obj.add(20);
        // 계산 이후의 기존 값(10)은 사라짐
        System.out.println("obj = " + obj.getValue());
    }
}
  • MutableObj을 10이라는 값으로 생성한다.
  • 이후에 obj.add(20)을 통해서 10 + 20 을 수행한다.
  • obj.getValue( )를 호출하면 30이 출력된다.

이번에는 불변 객체에서 add( ) 메서드를 어떻게 구현하는지 보겠습니다.

public class ImmutableObj {

    private final int value;

    public ImmutableObj(int value) {
        this.value = value;
    }

    public ImmutableObj add(int addValue) {
        //int result = addValue + value;
        //ImmutableObj immutableObj = new ImmutableObj(result);
        //return new ImmutableObj(result);
        return new ImmutableObj(addValue + value);
    }
    public int getValue() {
        return value;
    }
}
  • 불변 객체는 값을 변경하면 안된다.
  • 하지만 여기서는 기존 값에 새로운 값을 더해야 한다.
  • 불변 객체는 기존 값은 변경하지 않고 대신에 계산 결과를 바탕으로 새로운 객체를 만들어서 반환한다.
  • 이렇게 하면 불변도 유지하면서 새로운 결과도 만들 수 있다.
public class ImmutableMain {

    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

 

불변 객체를 설계할 떄 기존 값을 변경해야 하는 메서드가 필요할 수 있습니다. 이때는 기존 객체의 값을 그대로 두고

대신에 변경된 결과를 새로운 객체에 담아서 반환하면 됩니다. 결과를 보면 기존 값이 그대로 유지되고 있습니다.

 

실행 순서

  1. add(20)을 호출한다.
  2. 기존 객체 있는 10과 인수로 입력한 20을 더한다. 이때 기존 객체의 값을 변경하면 안되므로 계산 결과를 기반으로 새로운 객체를 만들어서 반환한다.
  3. 새로운 객체는 x002 참조를 가진다. 새로운 객체의 참조값을 obj2에 대입한다.

불변 객체에서 변경과 관련된 메서드들은 보통 객체를 새로 만들어서 반환하기 때문에 꼭! 반환 값을 받아야 합니다.