개발은 아름다워

[ Java ] 불변객체는 왜 필요할까? 본문

자바

[ Java ] 불변객체는 왜 필요할까?

do_it_zero 2024. 10. 24. 16:48

동일성 : == 연산자를 이용해 식별값으로 같은지 확인할 수 있음

== 연산자의 기본데이터 타입, 참조 타입의 경우 작동 방식을 보자면

  1. 기본 데이터 타입
int x = 5;
int y = 5;
System.out.println(x == y);  // true

값이 직접 비교하므로 true가 나옴

  1. 참조 타입
Integer obj1 = new Integer(5);
Integer obj2 = new Integer(5);
System.out.println(obj1 == obj2);  // false (다른 메모리 주소)

객체가 생성된 heap 메모리 주소를 비교하기 때문에 false가 나옴

불변객체

객체의 상태가 변하지 않는 객체를 의미한다. wrapper클래스로 만들어진 객체들은 불변객체이다. 객체가 만들어지면 상태를 바꿀 수 없음을 의미한다.

내가 헷갈렸던 것은 두가지이다.
두 가지 차이를 확실하게 알기 위해서는 기반지식이 필요하다.

동등성 : 상태의 값을 이용해 객체가 같은지 판단할 수 있는 성질
동일성 : 식별자를 이용해 객체가 같은지 판단할 수 있는 성질

  1. 참조 타입 변수와 객체를 혼동함
Integer a = new Integer(3);
a = new Integer(4);

이렇게 되면 a라는 객체의 상태값이 변한거 아닌가? 라고 생각했다.
아니다. a는 참조변수일 뿐이고 참조변수가 가리키는 객체의 주소값만 바뀌었던 것이다. 즉, new Integer(3);로 3의 상태 값을 갖는 객체는 heap 메모리에 존재하나 더 이상 참조 되지 않을 뿐이다. 이렇게 참조 되지 않는 객체는 GC가 수거해간다.

  1. 값과 객체를 혼동함
 Integer x = new Integer(3);
 Integer y = 3;
 int z = 3;
System.out.println(x==y); // 참조타입을 비교하기 때문에 객체의 주소값을 비교함 그래서 false
System.out.println(x==z); // 기본데이터 타입이므로 값을 비교하기 때문에 자동으로 언박싱한 값을 비교하기 때문에 true
System.out.println(y==z); // 기본데이터 타입이므로 값을 비교하기 때문에 자동으로 언박싱한 값을 비교하기 때문에 true

일단 기본 데이터 타입과 참조 타입의 차이점은 객체가 생성되냐 안되냐이다.
기본 데이터 타입의 변수인 z에는 3이라는 값이 할당된다.
참조 타입 변수인 x에는 Integer 객체의 상태에 3이라는 값이 할당된 객체의 주소값을 가리킨다.

z는 3이라는 값이고 x는 객체를 가리키는 주소값인데
z == x 는 왜 true가 나오지?
이는 == 연산자가 기본 데이터 타입을 비교할 때 값을 비교하기 때문에 x가 가리키는 객체를 자동으로 언박싱하여 값을 비교하기 때문이다.

y == z는?
Integer y = 3; 이렇게 보여서 y가 객체가 아닌 값을 할당받았는지 착각할 수 있지만, Integer는 기본 데이터 타입이 아닌 wrapper 클래스이며 그 말은 곧 참조 타입이라는 것이다.
Integer y = new Integer(3); 이렇게 하지 않을 수 있는 이유는 자동으로 3이라는 값을 박싱해주기 때문이다.
그래서 참조 타입 변수 y는 실제로는 new Integer(3);인 객체의 주소값을 가리킨다.

근데 왜 y == z는 true인가? 위와 같다. == 연산자는 기본 데이터타입을 비교시 값을 비교하기 때문에 y가 가리키는 객체의 상태를 언박싱하여 값을 비교했기 때문에 true가 나오는 것이다.

x == y 는?
== 연산자는 참조 타입의 경우 참조 타입이 가리키는 객체의 주소값을 비교한다. 객체의 상태의 값이 아니라, 객체 자체의 주소값을 비교한다. 이는 동일성 비교를 의미한다. 상태의 값이 같더라도 객체는 x가 가리키는 객체, y가 가리키는 객체 각각 따로 존재한다.

그렇기 x == y 객체의 상태의 값은 같지만, 객체 자체는 다르므로, 메모리 상으로 볼 때, 각각 다른 주소값을 갖는 객체이며
== 연산자는 참조 타입인 경우 주소값을 비교하기 때문에 false가 나오는 것이다.

== 연산자는 식별값이 같은지를 확인하기 때문에 동일성 비교에 쓰인다.

그렇다면 x와 y가 가진 값은 같으니, 객체가 가진 값이 같은지 비교하고 싶다면? 동등성 비교를 하면 되는 것이다.
어떻게 동등성을 비교하는가? 바로 equals를 사용해 비교하는 것이다. 하지만 문제가 있다. Object 클래스에서 equals를 오버라이딩하여 재정의하지 않는 경우이다.

Object 클래스의 equals

 public boolean equals(Object obj) {
        return (this == obj);
    }

equals를 오버라이딩하여 재정의하지 않을 경우, 객체의 상태 값이 비교가 아닌, 객체가 가리키는 주소 값을 비교하게 된다. == 연산과 똑같이 되는 것이다. 즉, 재정의하지 않을 경우 동일성 비교가 된다는 것이다.

객체의 상태가 가진 값이 같은지를 확인하기 위한 동등성 비교가 목적이기 때문에 그래서 wrapper 클래스인 Integer 클래스에는 equals가 재정의 되어있다.

Integer 클래스의 equals

 public boolean equals(Object obj) {
        if (obj instanceof Integer) {
            return value == ((Integer)obj).intValue();
        }
        return false;
    }
    

public int intValue() {
        return value;
    }

매개변수로 받은 참조 타입 변수가 가리키는 객체의 값을 비교하도록 재정의 되어 있는 것이다.

재정의된 equals를 해석하자면
1. 비교하려는 참조 타입 변수(obj)가 가리키는 객체가 Integer 타입이고

  • 내가 가진 value의 값과 obj가 가리키는 객체의 값을 가져와서 비교해서 값이 같으면 true고
  • 다르면 false다.
  1. 비교하려는 참조 타입 변수(obj)가 가리키는 객체가 Integer 타입이 아니면 false다.

이렇게 재정의가 되어있는 것이다.

그래서 불변객체란 무엇인가?
객체의 상태의 값들이 변하지 않는 객체인 것이다.

String 클래스로 확인하는 불변 객체가 필요한 이유와 단점

객체의 상태 값이 변하지 않는다는 것이다. 상태의 값이 변하지 않기 때문에 동일한 값을 반환해야하는 경우에 코드 변경 없이 쓰일 수 있다. 예를 들어 10번의 인사를 해야하는 코드가 있다고 생각해보자.

for(int i = 0; i <10; i++){
            String stringTest = new String("안녕하세요");
            System.out.println(i + " " + stringTest);
        }
        
결과
0 안녕하세요
1 안녕하세요
2 안녕하세요
3 안녕하세요
4 안녕하세요
5 안녕하세요
6 안녕하세요
7 안녕하세요
8 안녕하세요
9 안녕하세요

String 객체가 가진 "안녕하세요" 라는 값은 불변객체이므로 반복문에서 동일한 값을 print 해준다. 하지만 여기에는 치명적인 문제가 생긴다. 바로 반복때마다 새로운 객체가 생성되기 때문에 heap 메모리를 차지한다는 것이다. 지금은 단순히 10번이였지만, 10000명에게 인사를 해야된다고 한다면?? 엄청난 메모리 소비가 될 것이다. 자칫 잘못하면 메모리 누수가 생길 수 밖에 없다.

String은 이러한 단점을 어떻게 극복했을까?

리터럴 방식으로 해결했다. 리터럴 방식을 쓰게 되면 heap메모리 constant pool에 객체가 생성된다. 리터럴 방식으로 생성된 객체의 값이 같다면, 어떤 참조 변수든 같은 객체를 바라보게 된다.

String a = "안녕하세요";
String b = "안녕하세요";

참조변수 a와 b는 같은 객체를 바라본다! 즉, 리터럴 방식은 객체 상태의 값이 같은 경우 객체를 새롭게 만들지 않는다는 것이다!

for(int i = 0; i <10; i++){
String stringTest ="안녕하세요";
System.out.println(i + " " + stringTest);
}

결과는 같지만, 매 반복시 객체가 새로 생기지 않는다! 왜냐하면 리터럴 방식으로 객체를 생성하여 constant pool에 객체가 생겼기 때문이다. 반복시 constant pool 생성된 객체 상태의 값만 참조한다. 따라서 heap 메모리에 객체를 새롭게 만들지 않으므로 메모리에 영향을 주지 않는다.

공부하며 느낀점

이 부분을 알기 위해서 꽤 오랜시간이 걸렸다.
불변객체라는 것을 알기 위해서 객체의 상태를 알아야했다. 객체의 상태는 같을지라도 생성되는 객체는 다름을 알아야했고, 이것이 정말 맞는 것인지 알기위해 동등성과 동일성에 대해서 공부해야했다. 박싱과 언박싱에 대해서 이해를 해야했으며 heap메모리의 constant pool 이 생긴 이유에 대해서 알아야했다. 이 모든 것들이 한 번에 이해된 것이 아니라 하나씩 하나씩 자료를 찾아가며 답을 찾기 위해 몇일을 고민하며 공부하였다. 남의 것이 아닌 내 지식이 되기 위해서는 정말 많은 노력과 고된 시간이 필요함을 뼈저리게 느꼈다.