본문 바로가기
Language/자바&코틀린

불변(Immutable)객체와 가변(mutable) 객체

by 코딩공장공장장 2023. 11. 15.

불변 객체란

객체의 속성을 초기화 이후 변경이 불가능한 객체로 상태 변경이 불가능한 객체이다.

ex) String, 원시타입의 래퍼타입(Boolean, Integer, Float, Long, Double), ResultSet

 

불변객체 생성 방법

1. private final 선언

2. 수정자 메서드 제공 금지

외부에서 속성에 접근하여 함부로 값을 변경하지 못하도록 private으로 선언한다.

클래스의 모든 필드값에 final을 선언하여 값이 최초 한번 할당된 이후 변경되지 않도록 한다.

수정자 메서드를 제공하지 않음으로써 값이 변경되지 않음을 보장한다.

 

아래 클래스를 보면 세터 메서드는 존재하지 않고 게터 메서드만 제공되고 있다.

public class Member {

   private final String email;

   private final String passwd;

   private final MemberPrivate memberPrivate;

 

   public Member(String email, String passwd, MemberPrivate memberPrivate) {

       this.email = email;

       this.passwd = passwd;

       this.memberPrivate = memberPrivate;

   }

 

   public String getEmail() {

       return email;

   }

 

   public String getPasswd() {

       return passwd;

   }

 

   public MemberPrivate getMemberPrivate() {

       return memberPrivate;

   }

}

 

[주의] 인스턴스 변수에는 static은 선언??

객체 생성이란 인스턴스화를 의미하고 클래스 레빌인 static과는 연관이 없다.

static을 선언하여 final과 함께 사용하면 모든 객체가 공유하는 상수 값이 된다.

따라서 인스턴스마다 고유한 값을 가지도록 사용하려면 static을 제외해야한다.

 

3. 참조타입 속성은 복사본 제공 

만일 Member클래스의 참조 타입 속성이 아래와 같은 가변성을 갖는 경우를 생각해보자.

public class MemberPrivate {

   private int age;

   private Phone phone;

 

   public MemberPrivate(int age, Phone phone) {

       this.age = age;

       this.phone = phone;

   }

 

   public int getAge() {

       return age;

   }

 

   public void setAge(int age) {

       this.age = age;

   }

 

   public Phone getPhone() {

       return phone;

   }

 

   public void setPhone(Phone phone) {

       this.phone = phone;

   }

}

 

우리는 아래와 같은 MemberPrivate의 수정자 메서드로 memberPrivate 객체의 상태를 변경시킬 수이다.

MemberPrivate memberPrivate = member.getMemberPrivate();

memberPrivate.setAge(20);

 

Member객체의 참조타입인 속성인 memberPrivate이 불변객체가 아니므로 참조타입의 속성을 변경시킬 수 있다.

이와 같은 상황을 대비하여 사용할 수 있는 방식이 원본객체가 아닌 복사본을 제공하여
원본객체에 대한 접근을 막아 상태를 변경하지 못하도록 하는 것이다.

 

따라서 아래와 같이 MemberPrivate 클래스에 인스턴스 변수의 참조 주소를 새로운 객체에 생성하여 반환할 수 있다.  

public class MemberPrivate {

   . . .

   public MemberPrivate defensiveCopy() {

      return new MemberPrivate(age, phone);

   }

   . . . 

}

 

public class Member {

      . . . 

   public MemberPrivate getMemberPrivate() {

       return memberPrivate.defensiveCopy();

   }

}

 

위와 같은 방식을 방어적 복사라고 한다.

여기서 의문이 들 수 있는 점이 MemberPrivate도 모두 final로 선언하면 되지 않냐라고 의문이 들 수 있는데

그렇게 할수 있다면 그렇게 하는 방법이 가장 베스트이다.

허나, 이미 개발되어 사용중인 클래스의 필드를 함부로 수정할 수 없는 상황이라면 위와 같은 방법을 사용할 수 있다.

 

[참고] class에 final 사용

class에 final을 사용하는 것이 필수는 아니지만 불변성을 더욱 확실하게 표현할 수는 있다.

아래와 같이 Member를 상속 받은 TeamMember 예제를 보자.

public class TeamMember extends Member {

  

   private int teamCode;

 

   public TeamMember(String email, String passwd, MemberPrivate memberPrivate, int teamCode) {

       super(email, passwd, memberPrivate);

       this.teamCode = teamCode;

   }

 

   public void setTeamCode(int teamCode) {

       this.teamCode =  teamCode;

   }

}

 

하위 클래스에서 선언한 teamCode 속성에 대한 세터메서드가 제공되어 불변 클래스의 하위 클래스가 가변하게 설계되었다.

불변과 가변이라는 특징이 상속관계에서 반드시 지켜져야 하는 것은 아니지만 

객체 지향적 설계에서 불변/가변성도 일관되게 유지될 것이라고 일반적으로 기대할 것이라고 생각하기에 구조적 모순처럼 보일 수 있다.

 

따라서 불변성을 확실하게 표현하기 위해 class에 final을 선언하여 상속을 통한 하위 타입의 불변성이 깨지지 않게 설계할 수 있다.

하지만, 개인적으로 이는 필수는 아니고 선택이라고 생각한다.

 

불변한 상하위 계층 구조가 필요하다면 final을 선언하지 않고,

상하위 계층 구조가 필요없다면 불변성을 확실하게 표현하기 위해 final을 선언하는 것이 좋은 방법이라고 생각한다.

 

[참고] 불변객체 생성을 위한 정적 팩토리 메서드 사용

불변객체 생성을 위해 정적 팩토리 메서드를 사용하는 것을 권장하는 경우는 공유객체로 사용하는 경우이다.

정적 팩토리 메서드가 갖는 이름을 갖는 장점이나 상황에 따라 필요한 하위 타입 객체를 반환해주는 장점은 

불변/가변을 구분하지 않고 공통적인 장점이므로 논하지 않겠다.

 

Boolean 클래스의 정적 팩토리 메서드인 valueOf 메서드를 보자.

public final class Boolean implements java.io.Serializable,

                                     Comparable<Boolean>, Constable

{

 

    public static final Boolean TRUE = new Boolean(true);

 

    public static final Boolean FALSE = new Boolean(false);

 

    public static Boolean valueOf(boolean b) {

       return (b ? TRUE : FALSE);

    }

}

 

true와 false에 따라 객체를 계속 생성하는 것이 아닌 static final로 선언되어있고 미리 생성한 Boolean 객체를 반환해주고 있다.

Boolean이 갖는 값은 true와 false 뿐이므로 사용할 때마다 새롭게 생성하여 메모리 자원을 할당하는 것이 아니라
미리 생성한 객체를 반환하여 사용하는 구조이다.

 

위와 같이 불변객체를 공유 객체로 사용하는 경우에는 사용할 때마다 똑같은 값을 갖는 객체의 메모리 자원을 할당하는 것은 

비효율적이므로 정적 팩토리 메서드를 사용함으로써 메모리 자원을 효율적으로 사용할 수 있다.

 

불변객체의 장점

1. 공유자원 동시성 문제 탈피

동시성 문제란 공유자원이 가변성을 갖는 경우 read-write 작업을 하는 사이 값이 변경되어 기대한 값으로 write하지 못하는 상황을 말한다.

이런 상황에서 synchronized 키워드와 같은 방식으로 동시성 문제를 해결하는데 이는 일반적인 작업에 비해 많은 시스템 처리를 요구한다.

허나, 불변객체는 값이 변경될 일이 없기에 동시성 문제에서 자유로워질 수 있다.

 

2. 실패 원자적인 메서드 

가변 객체를 통해 작업을 하는 도중 예외가 발생하면 객체가 불안정한 상태에 빠질 수 있다.

예를 들어 수정자 메서드가 정상적으로 실행되지 않고 객체가 넘어온 경우 의도하지 않은 객체를 가지고 작업을 진행할 수 있다.

허나, 불변객체는 객체의 상태는 어느 곳에서 사용되더라도 늘 같은 상태를 유지하므로 실패를 포함한 외부의 영향을 받지 않고 사용할 수 있다.

 

3. 예측 가능한 객체

객체의 생성시점에 할당된 값이 변하지 않고 유지되므로 우리는 로직을 분석할 때, 

객체 생성 시점에 할당된 값 이외에 이 객체가 중간에 값이 변경되었는지 추적할 필요가 없다.

또한 객체의 상태가 변하는 부수효과가 없기에 메서드 실행이나 메서드 구현 중에 값이 변경되어 예상치 못한 동작이 발생할 수 도 있는 상황에서 안전하게 사용할 수 있다.

 

4. 가비지 컬렉션 성능 향상

참조타입 속성이 final로 선언한 경우 처음 할당한 객체를 변경하지 않고 그대로 사용하고 있다는 것을 의미하므로

GC가 참조 연관성을 체크하는 마킹 작업시 해당 객체를 건너뛸 수 있다.



불변객체의 단점

1. 값이 변할 때마다 새로운 객체를 생성해야함

구현의 복잡성, 메모리 자원의 비효율성

 

2. 직렬화 및 역직렬화(라이브러리로 해결 가능)

일반적으로 직렬화에는 getter 메서드가 필요하고 역직렬화에는 인자값 없는 기본 생성자와 setter 메서드가 필요하다.

불변객체도 참조타입에 방어적 복사를 사용한다면 getter 메서드를 가져도 충분히 불변성을 유지할 수 있기에 직렬화는 문제되지 않지만

역직렬화가 문제다.

허나, 최근 라이브러리에서 인자값 없는 기본 생성자와 세터 메서드가 없어도 역직렬화가 가능하도록 기능 제공을 해주고 있다.

Jackson 라이브러리가 직렬화와 역직렬화에 많이 사용되는 리플렉션을 통해 처리해주고 있다고 한다.



얕은복사, 방어적 복사, 깊은복사

얕은복사

원본객체의 주소 값만 복사

Member a = new Member();

Member b = a; // 얕은 복사

방어적 복사

참조 타입의 원본객체의 주소를 반환하지 않고, 원본 객체가 참조하는 주소를 복사하여 새로운 객체로 반환하는 방법

복사본은 원본과 다른 객체 참조하지만, 내부에 있는 객체는 원본과 동일한 주소 참조 -> 통만 갈아끼우는 구조


 

위와 같은 구조가 방어적 복사의 구조이다.
member객체의 memberPrivate 타입은 원본객체를 참조하고 있지만, getter를 통해 반환되는 객체는 복사본이다.
허나 이 복사본의 참조타입은 다시 원본의 참조 주소를 그대로 들고 있다.

따라서 아래와 같이 값을 변경하더라도 원본객체의 값은 변경되지 않는다.

MemberPrivate memberPrivate = member.getMemberPrivate();

memberPrivate.setPhone(new Phone("홍길동", "01012345678"));


허나, 방어적 복사가 완벽한 불변성을 보장하는 것은 아니다.
아래와 같이 참조타입의 참조타입까지 가져왔을 때, 해당 타입이 불변객체가 아니라면 값 변경을 막을 수 없다.

Phone phone = memberPrivate.getPhone();

phone.setOwner("임꺽정");

 

위 구조에서 불변성을 유지하려면 참조타입의 참조타입까지 방어적 복사를 구현해야 한다.
즉, 참조타입의 참조타입 depth가 깊어질수록 해당하는 참조타입을 모두 방어적 복사로 구현해야한다.
(기본타입의 경우 값 복사이므로 방어적 복사와 관련 없다.)

깊은복사 

객체의 참조타입 내의 속성들까지 모두 복사하여 새로운 객체로 반환하는 방법

ex) 직렬화와 역직렬화에 사용(깊은 복사는 불변성을 보장하고, 호환성)

 

 

방어적 복사와 깊은 복사 장단점

방어적 복사 (Defensive Copy)

장점

  • 객체의 불변성 보장: 방어적 복사를 사용하면 원본 객체와 복사본 사이의 상태 변경을 방지할 수 있어, 객체의 불변성을 강화할 수 있습니다.
  • 메모리 효율성: 모든 내부 객체를 깊게 복사하는 대신, 필요한 경우에만 내부 객체를 복사하는 방어적 복사는 일반적으로 메모리 사용량을 줄일 수 있습니다.
  • 객체 공유 가능: 방어적 복사는 객체 그래프 내에서 일부 객체를 공유할 수 있으며, 이로 인해 복사 과정의 성능 향상을 가져올 수 있습니다.

단점

  • 구현의 복잡성 증가: 객체와 내부 객체를 복사하는 복잡한 복사 로직을 관리해야 합니다.

깊은 복사 (Deep Copy)

장점

  • 무결성 보장: 객체 그래프 내의 모든 객체가 독립적으로 복제되므로 불변성과 데이터 무결성을 강화할 수 있습니다.
  • 예측 가능성: 복사본은 변경되지 않으므로 예측 가능한 상태를 제공합니다.

단점

  • 메모리 비효율성: 모든 객체를 깊게 복사해야 하므로 메모리 사용량이 증가합
  • 복잡성: 객체 그래프가 깊고 복잡한 경우 깊은 복사 구현이 복잡할 수 있습니다.



직렬화와 역직렬화에 깊은 복사 사용하는 이유

  • 데이터의 불변성을 방지하여 일관성을 유지
  • 직렬화와 역직렬화 할 때, 통신을 하기 위해 객체의 내부구조나 데이터 형식이 변경될 수 있음.
    (자바 class와 json타입이 구조가 다른 것처럼) 깊은 복사를 통해 원본객체의 일관성을 유지하고 직렬화나 역직렬화 과정에서 원본객체의 수정으로 인한 오류 방지




반응형