불변 클래스

후추·2023년 3월 20일
0

들어가기

객체 지향 프로그래밍에 있어서 불변객체(immutable object)는 생성 후 그 상태를 바꿀 수 없는 객체를 말한다. 가변(mutable) 객체는 반대 개념으로 생성 후에도 상태를 변경할 수 있다. - wiki

다시 말하자면, 불변 객체는 생성 후 내부 값을 바꿀 수 없는 객체이다.

불변 객체에 들어있는 정보는 객체가 파괴되는 순간까지 고정된다.

자바에서는 String, 기본 타입을 박싱한 Wrapper Class, BigInteger, BigDecimal 등을 불변 클래스로 제공한다.

그렇다면 불변 클래스를 어떻게 만들 수 있는지, 불변 클래스의 장단점은 무엇인지 알아보자.

불변 클래스 만들기

불변 클래스를 만들기 위해서는 다음과 같은 규칙을 따라야 한다.

    1. 객체의 상태를 변경할 수 없도록 한다.
    1. 클래스의 확장을 막는다.
    1. 방어적 복사를 수행한다.

예시

자동차 클래스를 예시로 보자.

public class Car {

    private String name;
    private String brand;

    public Car(final String name, final String brand) {
        this.name = name;
        this.brand = brand;
    }

    public void setName(final String name) { //객체 상태를 변경하는 메서드
        this.name = name;
    }

    public void setBrand(final String brand) {
        this.brand = brand;
    }

    // ...
}

현재 자동차 클래스는 name, brand를 상태로 가지며, setter를 제공하고 있다.

이 자동차 클래스를 사용하는 클라이언트는 setter 메서드를 통해 자유롭게 상태를 변경할 수 있다.

따라서 자동차 클래스는 가변이다.

자동차 클래스를 불변으로 바꾸어보자.

1. 상태를 변경할 수 없도록 한다

public class Car {

    private final String name;	//final로 필드 선언하기
    private final String brand;

    public Car(final String name, final String brand) {
        this.name = name;
        this.brand = brand;
    }
    
	// setter 메서드 삭제
    
    // ...
}

자동차 클래스의 상태를 변경할 수 없도록 하기 위해setter 메서드를 삭제한다.

또한 필드를 final로 선언한다.

2. 클래스의 확장을 막는다

클래스가 확장 가능하다면 하위 클래스에서 예기치 않게 객체 상태를 변화시킬 수 있다.

이러한 사태를 예방하기 위해 클래스 확장을 막는 것을 고려할 수 있다.

클래스를 final로 선언하면 확장을 막는다.

public final class Car { //final 로 클래스 선언하기

    private final String name;
    private final String brand;

    public Car(final String name, final String brand) {
        this.name = name;
        this.brand = brand;
    }

    // ...
}

클래스를 final로 선언하는 것 외에도 정적 팩터리 메서드를 사용하는 방법이 있다.

생성자가 private로 두고 객체를 반환하는 정적 팩터리 메서드를 제공하면 확장을 막으면서도 유연한 설계가 가능하다.

public class Car {

    private final String name;
    private final String brand;

    private Car(final String name, final String brand) {
        this.name = name;
        this.brand = brand;
    }
    
    public Car valueOf(final String name, final String brand) { // 정적 팩터리 메서드
    	return new Car(name, brand);
    }
   
    // ...
}

3. 방어적 복사를 수행한다

클래스의 상태가 가변 객체일 경우 방어적 복사가 필요하다.

가령 자동차 객체의 상태가 다음과 같이 변경되었다고 가정하자.

public final class Car {

    private final Name name;
    
    public Car(final Name name) {
        this.name = name;
    }

    public Name getName() {
        return name;
    }
    
    //...
}

자동차 객체는 상태로 Name 객체를 갖는다.

Name 객체의 구현은 아래와 같다.

public class Name {
    private String name;

    public Name(final String name) {
        this.name = name;
    }

    @Override
    public boolean equals(final Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        final Name name1 = (Name) o;
        return Objects.equals(name, name1.name);
    }

    @Override
    public int hashCode() {
        return Objects.hash(name);
    }
    
    public void setName(final String name) {
        this.name = name;
    }
    
    public String getName() {
        return name;
    }
}

자동차 객체는 객체의 상태가 변경되지 않도록 상태를 private final로 선언하고 있다.

또한 클래스를 final로 선언해 확장을 막았다. 따라서 불변이라고 착각할 수 있다.

그러나 자동차 클래스가 사용하는 Name 클래스가 불변이 아니기 때문에 자동차 클래스 역시 불변이라 할 수 없다.

가령 다음의 테스트 코드가 실패한다.

class CarTest {

    @Test
    void 자동차_이름이_바뀐다() {
        final Car car = new Car(new Name("후추 자동차"));

		// 자동차 객체에서 이름을 꺼내 바꾼다.
        final Name name = car.getName();
        name.setName("찬민 자동차");
        
		// 테스트 실패
        assertThat(car.getName()).isEqualTo(new Name("후추 자동차"));
    }
}

따라서 자동차 객체의 상태가 불변이 아닐 경우 변경되지 않도록 조치해야 한다.

이때 사용할 수 있는 것이 방어적 복사이다.

방어적 복사란 외부와 상태를 주고 받을 때 객체를 복사해 사용하는 방식이다. 방어적 복사를 통해 상태가 객체 외부에서 변경되어도 객체 내부는 영향을 받지 않는다.

방어적 복사는 상태를 초기화하는 생성자상태를 반환하는 getter에서 사용할 수 있다.

자동차 객체에 방어적 복사를 적용하면 다음과 같다.

public final class Car {

    private final Name name;

    public Car(final Name name) {
        this.name = new Name(name.getName());
    }

    public Name getName() {
        return new Name(name.getName());
    }
}

상태를 초기화하는 생성자와 상태를 반환하는 getter에서 Name 객체를 그대로 사용하지 않고

새로운 Name 객체를 생성해서 사용한다.

방어적 복사를 수행하면 상기한 테스트를 통과하게 된다.

class CarTest {

    @Test
    void 자동차_이름이_바뀐다() {
        final Car car = new Car(new Name("후추 자동차"));
        
        final Name name = car.getName();
        name.setName("찬민 자동차");
		
        // 테스트 성공
        assertThat(car.getName()).isEqualTo(new Name("후추 자동차"));
    }
}

불변 클래스의 장단점

장점

1. 불변 클래스는 Thread-safe 하다.

멀티 스레드 환경을 생각해보자. 멀티 스레드 환경에서는 여러 스레드가 같은 자원을 공유해서 사용한다. 따라서 한 자원에 여러 스레드가 동시에 접근한다면 의도와 다른 결과를 얻을 수 있다. 이러한 동시성 이슈를 피하기 위해 락을 거는 등 추가적인 조치가 필요하다. 반면 불변 클래스는 언제나 동일한 상태를 가지므로 동시성을 고려할 필요가 없다. 불변 객체는 항상 Thread-safe하다.

2. 실패 원자성을 만족한다.

실패 원자성(failure atomicity)이란 어떤 메서드에서 예외가 발생하더라도 해당 객체가 메서드 호출 전과 똑같은 상태를 유지하는 성질이다. 불변 클래스는 예외가 발생한 것과 관계없이 상태가 변하지 않는다. 따라서 실패 원자성을 항상 만족한다.

3. 부수 효과(Side Effect)를 피해 오류 가능성을 최소화할 수 있다.

부수 효과란 다음과 같다.

컴퓨터 과학에서 함수가 반환값 이외에 다른 상태를 변경시킬 때 부수 효과가 있다고 말한다. 예를 들어, 함수가 전역변수나 정적변수를 수정하거나, 인자로 넘어온 것들 중 하나를 변경하거나 화면이나 파일에 데이터를 쓰거나, 부수 효과가 있는 다른 함수에서 데이터를 읽어오는 경우가 있다. 부수 효과는 프로그램의 동작을 이해하기 어렵게 한다. - wiki

간단히 말하자면 메서드 내부에서 반환값 외에 다른 것이 변경된다면 부수 효과가 있는 것이다. 부수 효과는 프로그램의 동작을 이해하거나 예측하기 어렵게 만든다. 불변 클래스는 기본적으로 상태의 수정이 불가능해 변경 가능성이 적다. 따라서 부수 효과가 없는 메서드로 객체를 구성하기 쉽다.

4. 방어적 복사를 할 필요가 없다.

불변 객체가 다른 클래스의 상태로 쓰이는 경우, 방어적 복사를 할 이유가 사라진다. 해당 객체를 변경시키는 것이 애초에 불가능하기 때문이다. 따라서 불변 클래스는 방어적 복사에서 자유롭다.

단점

메모리 누수 및 성능 저하의 우려가 있다.

불변 객체는 값이 다르면 반드시 독립된 객체로 만들어야 한다. 따라서 사용해야 하는 값의 가짓수가 많다면, 그만큼 많은 객체가 생성되어야 한다. 이 과정에서 사용되지 않고 버려지는 객체는 메모리 관리와 성능에 부담을 끼칠 수 있다.

0개의 댓글