🤲 기본형과 참조형의 공유

자바의 데이터 타입을 크게 보면 기본형, 참조형으로 나눌 수 있다고 했다. 기본형은 하나의 값을 여러 변수에서 절대로 공유하지 않는 특징을 가졌고, 참조형은 하나의 객체를 참조값을 통해 여러 변수에서 공유할 수 있다는 특징을 가졌다.

 

하나의 값을 공유하거나 공유하지 않는다는 말은 무슨 의미일까? 복습 차원에서 한번 들여다보자.

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에 전달한다. 결과적으로는 ab 모두 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

사이드 이펙트(Side Effect)란… 뭔가 내가 한 게 아니라 다른 데서 효과가 터지는 상황인 것 같다. 프로그래밍에서는 어떤 계산이 주된 작업 외에 추가적인 부수 효과를 일으키는 것을 뜻한다. 위의 부산으로 인스턴스의 값을 변경한 코드를 다시 한번 들여다보자.

address2.setValue("부산");
System.out.println("address1 = " + address1);
System.out.println("address2 = " + address2);

address2 값만 부산으로 바꾸려고 의도한 건데, 알고 보니 address1의 값도 같은 참조이기 때문에 바뀐 상황인 것이다. 프로그래밍에서 이런 상황이 발생하면 보통 부정적인 의미로 해석된다. 왜냐하면 프로그램의 특정 부분에서 발생한 변경이 의도치 않게 다른 부분에 영향을 미치기 때문이다. 이로 인해 디버깅이 어려워지고 코드의 안정성이 저하될 수 있다.

 

그럼 이 문제를 어떻게 해결할 수 있을까? 생각보다 아주 단순하다. 그냥 처음부터 address1address2가 서로 다른 인스턴스를 참조하게 하면 된다.

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; // 이것처럼 참조값 대입을 막을 수 있는 방법이 없다.

기본형은 항상 값을 복사해서 대입하기 때문에 값이 절대로 공유될 일이 없지만, 이처럼 참조형의 경우, 참조값을 복사해서 대입하기 때문에 여러 변수에서 얼마든지 같은 객체를 공유할 수 있다. 객체의 공유가 꼭 필요할 때도 있지만, 때로는 공유하는 것이 위와 같은 의도하지 않은 사이드 이펙트를 만드는 경우가 있다.


🐦 불변 객체 도입

위에서 발생한 문제를 간단하게 정리하면, "공유하면 안 되는 객체를 공유해서" 문제가 발생한 것이다. 사이드 이펙트가 발생한 근본적인 원인을 생각해보면, 객체를 공유하는 것 자체가 문제가 아니라 “공유된 객체의 값을 변경” 했기 때문이다.

 

이제 불변 객체에 대해 얘기해보자. 불변 객체(Immutable Object)란, 객체의 상태(객체 내부의 값, 필드, 멤버 변수)가 변하지 않는 객체를 말한다. 앞서 만들었던 Address 클래스를 불변 객체로 다시 설계해보자.

package lang.immutable.address;

public class ImmutableAddress {

	// value 값을 바꿀 수 없도록 수정
    private final String 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() 메서드가 없다고 컴파일 오류가 발생한다. 어떤 개발자가 이렇게 실수를 한다면, 이렇게는 값을 바꾸지 못한다고 깨닫게 된다. 그럼 개발자는 값을 바꾸지 못하므로 자연스럽게 새로운 객체를 생성해서 담으려고 생각할 것이다.

다시 정리하자면, 불변 객체는 값을 변경할 수 없기 때문에, 불변 객체의 값을 변경하고 싶으면 변경하고 싶은 값으로 새로운 불변 객체를 생성해야 한다.


🔄 불변 객체 - 값 변경

불변 객체를 사용해도 값을 변경해야 하는 메서드가 필요할 수도 있다. 예를 들어, 기존 값에 새로운 값을 더하는 add() 메서드가 있다. 먼저 변경 가능한 객체에서 값을 변경하는 간단한 예제를 만들어보자.

package lang.immutable.change;

public class MutableObj {

    private int value;

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

	// add() 메서드
    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
*/

처음에 MutableObj10으로 값을 설정하고 생성한다. 이후에 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)은 그대로 유지된다.

만약 새로 생성된 반환 값을 사용하지 않는다면 아무 일도 일어나지 않은 것처럼 보인다. 그렇기 때문에 불변 객체에서 변경과 관련된 메서드들은 보통 객체를 새로 만들어서 반환하기 때문에 꼭 반환 값을 받아야 한다.

0개의 댓글