불변 클래스란 인스턴스의 내부 값을 수정할 수 없는 클래스를 말한다.

불변 클래스는 가변 클래스보다 설계 및 구현이 쉬우며, 오류가 생길 여지도 적고 훨씬 안전하다.

클래스를 불변으로 만들기 위해서는 아래 규칙을 따라야 한다.

1) 객체의 상태를 변경하는 메서드(변경자)를 제공하지 않는다.

 

2) 클래스를 확장할 수 없도록 한다. 

하위 클래스에서 부주의하게 혹은 나쁜 의도로 객체의 상태를 변하게 만드는 사태를 막아준다. 

상속을 막는 대표적인 방법은 클래스를 final로 선언하는 것이다.

또는 모든 생성자를 private 혹은 package-private으로 만들고 public 정적 팩터리를 제공하는 방법도 있다.

 

3) 모든 필드를 final로 선언한다. 

새로 생성된 인스턴스를 동기화 없이 다른 스레드로 건네도 문제없이 동작하게끔 보장하기도 한다.

 

4) 모든 필드를 private으로 선언한다. 

필드가 참조하는 가변 객체를 클라이언트에서 직접 접근해 수정하는 일을 막아준다.

 

5) 자신 외에는 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

클래스에 가변 객체를 참조하는 필드가 하나라도 있다면 클라이언트에서 그 객체의 참조를 얻을 수 없도록 해야 한다. => 방어적 복사로 제공

이런 필드는 절대 클라이언트가 제공한 객체 참조를 가리키게 해서는 안 되며, 접근자 메서드가 그 필드를 그대로 반환해서도 안 된다.

생성자, 접근자, readobject 메서드(아이템 88) 모두에서 방어적 복사를 수행해야 한다.

 

 

 

자바 플랫폼 라이브러리에도 다양한 불변 클래스가 있다. String, 기본 타입의 박싱된 클래스들, Biginteger, BigDecimal이 여기 속한다.

거의 아래와 같이 연산 후에 인스턴스 자신은 수정하지 않고 새로운 인스턴스를 만들어 반환하게 된다.

이처럼 피 연산자에 함수를 적용해 그 결과를 반환하지만, 피 연산자 자체는 그대로인 프로그래밍 패턴을 함수형 프로그래밍이라 한다.

이와 달리, 절차적 혹은 명령형 프로그래밍에서는 메서드에서 피 연산자인 자신을 수정해 자신의 상태가 변하게 된다.

 

 

 

불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요 없다.

불변 객체에 대해서는 그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니 불변 객체는 안심하고 공유할 수 있다.

따라서 불변 클래스라면 한번 만든 인스턴스를 최대한 재활용하기를 권한다.

가장 쉬운 재활용 방법은 자주 쓰이는 값들을 상수(public static final)로 제공 하는 것이다.

 

이 방식을 조금 더 활용할 수 있는 방법은 캐싱이다.

불변 클래스는 자주 사용되는 인스턴스를 캐싱하여 같은 인스턴스를 중복 생성하지 않게 해 주는 적정 팩터리(아이템 1)를 제공할 수 있다.

이 외에도 박싱된 기본타입 Integer/Long(-128~127) 등도 특정 값들을 캐싱해 두고 있다.

이런 정적 팩터리를 사용하면 여러 클라이언트가 인스턴스를 공유하여 메모리 사용량과 가비지 컬렉션 비용이 줄어든다.

새로운 클래스를 설계할 때 public 생성자 대신 정적 팩터리를 만들어두면, 클라이언트를 수정하지 않고도 필요에 따라 캐시 기능을 나중에 덧붙일 수 있다.

 

불변 객체를 자유롭게 공유할 수 있다는 점은 방어적 복사(아이템 50)도 필요 없다는 결론으로 자연스럽게 이어진다.

아무리 복사해봐야 원본과 똑같으니 복사 자체가 의미가 없고, 굳이 clone 메서드나 복사 생성자(아이템 13)를 제공하지 않는게 좋다.

그러니까 String 클래스의 복사 생성자는 굳이 사용하지 말자(아이템 6)

 

 

불변 객체는의 또 하나의 장점은 불변 객체끼리 내부 데이터를 공유할 수 있다는 점이다.

예를 들어 Biginteger 클래스는 내부에서 값의 부호 sign(int) 와 크기 magnitude(int[])를 따로 표현한다.

이때, negate 메서드는 크기가 같고 부호만 반 대인 새로운 Biginteger를 생성하는데, 이때 배열은 비록 가변이지만 복사하지 않고 원본 인스턴스와 공유해도 된다. 그 결과 새로 만든 Biginteger 인스턴스도 원본 인스턴스가 가리키는 내부 배열을 그대로 가리킨다.

 

 

객체를 만들 때 다른 불변 객체들을 구성요소로 사용하면 불변식을 유지하기 쉬워진다.

예를 들어, 불변 객체는 맵의 키와 집합(Set)의 원소로 쓰기 좋다. 맵이나 집합은 안에 담긴 값이 바뀌면 불변식이 허물어지는데, 불변 객체를 사용하면 그런 걱정은 하지 않아 도 된다.

불변 객체는 그 자체로 실패 원자성을 제공한다(아이템 76). 즉, 메서드에서 예외가 발생한 후에도 그 객체는 여전히 (메서드 호출 전과 똑같은) 유효한 상태라는 것이다. 불변 객체의 메서드는 내부 상태를 바꾸지 않으므로 상태가 절대 변하지 않는다.

불변 클래스에도 단점은 있다. 값이 다르면 반드시 독립된 객체로 만들어야 한다는 것이다.

아래 BigInteger.flipBit를 보자. 이 메서드에서는 원본과 한 비트만 다른 새로운 인스턴스를 반환한다.

이 연산은 Biginteger의 크기에 비례해 시간과 공간을 잡아먹는다.

이와 달리 BitSet.filt도 임의 길이의 비트 순열을 표현하지만, Biginteger와는 달리 가변이고, 이 때문에 원하는 비트 하나만 상수 시간 안에 바꿔줄 수 있다.

 

불변 객체는 원하는 객체를 완성하기까지의 단계가 많고, 중간 단계에서 만들어진 객체들이 모두 버려진다면 성능 문제가 발생할 수 있다. 

이 문제를 해결하기 위해서는, 흔히 쓰일 다단계 연산(multistep operation)들을 예측하여 기본 기능으로 제공하는 방법이 있다.

(다단계 연산을 기본으로 제공한다면 더 이상 각 단계마다 객체를 생성하지 않아도 된다)

 

클라이언트들이 원하는 복잡한 연산들을 정확히 예측할 수 있다면 package-private의 가변 동반 클래스만으로 충분하지만,

그렇지 않다면 이 클래스를 public으로 제공하는 게 최선이다. (String 클래스의 가변 동반 클래스 StringBuilder/StringBuffer 처럼)

 

 

 

Biginteger와 BigDecimal의 경우 불변 객체가 final이어야 하지만 그렇게 설계되지 못하여 클래스의 메서드들은 모두 재정의할 수 있게 설계되었다. 따라서 만약 신뢰할 수 없는 클라이언트로부터 Biginteger나 BigDecimal의 인스턴스를 인수로 받는다면 주의해야 한다.

이 값들이 불변이어야 클래스의 보안을 지킬 수 있다면, 인수로 받은 객체가 `진짜` Biginteger/BigDecimal 인지 반드시 확인해고, 신뢰할 수 없다고 확인되면 이 인수들은 가변이라 가정하고 아래와 같이 방어적으로 복사해 사용해야 한다(아이템 50).

public static Biginteger safeInstance(BigInteger val) { 
	return val.getClass() == Biginteger.class ? val : new BigInteger(val.toByteArray());
}

 

 

불변 클래스의 규칙에 따르면 모든 필드가 final이고 어떤 메서드도 그 객체를 수정할 수 없어야 하긴 하지만, 성능을 위해 "어떤 메서드도 객체의 상태 중 외부에 비치는 값을 변경할 수 없다" 로 살짝 완화할 수 있다.

단, 직렬화할 때는 추가로 주의할 점이 있다.

Serializable을 구현하는 불변 클래스의 내부에 가변 객체를 참조하는 필드가 있다면
readObject나 readResolve 메서드를 반드시 제공하거나
ObjectOutputStream.writeUnshared와 ObjectInputstream.readUnshared 메서드를 사용해야 한다.
그렇지 않으면 공격자가 이 클래스로부터 가변 인스턴스를 만들어낼 수 있다(아이템 88)

 


 

정리하자면, 클래스는 꼭 필요한 경우가 아니라면 불변이어야 한다. 그러니까 setter의 접근 제어자를 잘 설정하자.

String과 Biginteger처럼 무거운 값 객체도 불변으로 만들 수 있는지 생각 해 보고,

성능 때문에 어쩔 수 없다면(아이템 67) 불변 클래스와 쌍을 이루는 가변 동반 클래스를 public 클래스로 제공하도록 하자.

불변으로 만들 수 없는 클래스의 경우는 변경할 수 있는 부분을 최소한으로 줄이고, 그 외에는 모두 final로 선언하자.

 

생성자는 불변식 설정이 모두 완료된, 초기화가 완벽히 끝난 상태의 객체를 생성해야 한다. 

확실한 이유가 없다면 생성자와 정적 팩터리 외에는 그 어떤 초기화 메서드도 public으로 제공해서는 안 된다.

객체를 재활용할 목적으로 상태를 다시 초기화하는 메서드도 안 된다. (복잡성만 커지고 성능 이점은 거의 없다 ex. java.util.concurrent.CountDownLatch)


요 내용과 비슷한 것이 일급 컬렉션이라는 것이다. 이번 과제는 일급 컬렉션을 아래 조건에 맞게 구현하는 것이었다.

github.com/dolly0920/Effective_Java_Study/issues/35

 

ITEM 17. 변경 가능성을 최소화하라 · Issue #35 · dolly0920/Effective_Java_Study

지난 스터디에서도 이야기했지만, 일급 컬렉션이라고 하는 것을 활용하면 불변성을 보장하면서도 상태와 행위를 한 군데서 관리할 수 있는 이점이 있다는 이야기가 잠시 나왔습니다. item 17은

github.com

 

먼저 일급 컬렉션에 대해 알아보자면, 아래와 같다.

규칙 8: 일급 콜렉션 사용
이 규칙의 적용은 간단하다.
콜렉션을 포함한 클래스는 반드시 다른 멤버 변수가 없어야 한다.
각 콜렉션은 그 자체로 포장돼 있으므로 이제 콜렉션과 관련된 동작은 근거지가 마련된셈이다.
필터가 이 새 클래스의 일부가 됨을 알 수 있다.

필터는 또한 스스로 함수 객체가 될 수 있다.
또한 새 클래스는 두 그룹을 같이 묶는다든가 그룹의 각 원소에 규칙을 적용하는 등의 동작을 처리할 수 있다.
이는 인스턴스 변수에 대한 규칙의 확실한 확장이지만 그 자체를 위해서도 중요하다.
콜렉션은 실로 매우 유용한 원시 타입이다.
많은 동작이 있지만 후임 프로그래머나 유지보수 담당자에 의미적 의도나 단초는 거의 없다. - 소트웍스 앤솔로지 객체지향 생활체조편

출처: https://jojoldu.tistory.com/412

 

쉽게 말하면, 컬렉션을 가지고 있는 클래스를 하나 만들어서 클래스가 멤버 변수를 verify 하도록 하고, 컬렉션은 외부에서 바꿀 수 없도록 하자는 것이다. => 위에서 봤듯이, 컬렉션은 외부에서 참조가능 할 경우 final을 붙여도 안에 들어있는 값들이 변경될 수 있으니까

 

먼저 학교의 경우 enum으로 관리할 수 있도록 했다. (학과도 만들면 좋긴 했지만... 너무 귀찮았음) 

package com.example.sypark9646.item17.model;

public enum University {
    CATHOLIC("가톨릭대"),
    KONKUK("건국대"),
    KUONGGI("경기대"),
    KYUNGHEE("경희대"),
    KOREA("고려대"),
    KWANGWOON("광운대"),
    KOOKMIN("국민대"),
    SEOUL("서울대"),
    SOGANG("서강대"),
    SUNGKYUNKWAN("성균관대");


    private String name;

    University(String name) {
        this.name = name;
    }

    public String getName() {
        return name;
    }
}

 

Admission 클래스는 지원하는 학교 1개에 대한 객체인데,

좀 더 재밌는 요소를 부여해 보고 싶어서 RandomUtils를 이용해서 지원 후 랜덤으로 합격 결과가 정해질 수 있도록 했다.

이 때, equals와 hashcode를 재정의 하여 학과가 같으면 같은 Admission -> duplicate 를 검출할 수 있도록 했다.

package com.example.sypark9646.item17.model;

import com.example.sypark9646.item17.utils.Messages;
import com.example.sypark9646.item17.utils.RandomUtils;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Admission {

    private University university;
    private String department;
    @Getter
    private boolean isAccepted;

    public Admission(University university, String department) {
        this.university = university;
        this.department = department;
        this.isAccepted = RandomUtils.getRandomBoolean();
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }

        if (o == null) {
            return false;
        }

        if (!(o instanceof Admission)) {
            return false;
        }

        Admission admission = (Admission) o;
        return this.university == admission.university;
    }

    @Override
    public int hashCode() {
        return university.hashCode();
    }

    @Override
    public String toString() {
        String acceptance = isAccepted ? Messages.PASS.getMessage() : Messages.FAIL.getMessage();

        return new StringBuilder("")
            .append(university.getName())
            .append("학교 ")
            .append(this.department)
            .append(" 지원 결과")
            .append(acceptance)
            .append("입니다.")
            .toString(); // string 으로 하는게 더 좋을까...?
    }
}

 

Util 클래스의 경우 item04에서 배웠듯이 생성자를 private으로 만들어 두어서 인스턴스화 되는 것을 막았다.

package com.example.sypark9646.item17.utils;

import java.util.Random;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class RandomUtils {

    private static Random random = new Random();

    public static boolean getRandomBoolean() {
        return random.nextBoolean();
    }
}

 

그 다음으로는 Admission을 담고 있는 리스트만을 필드로 가지고 있는 일급컬렉션 Admissions을 정의해 보았다.

Admission 클래스의 필드는 외부에서 접근 불가능하도록 private으로 둠으로써 불변 클래스로 만들었고,

객체를 생성할 때 validation을 진행하며 조건에 맞지 않을 경우 IllegalArgumentException을 반환하도록 했다.

여기서 사용하는 contains는 equals, HashSet은 equals와 hashCode의 재정의에 따라 동작하게 된다.

package com.example.sypark9646.item17.model;

import java.util.HashSet;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
import com.example.sypark9646.item17.utils.Messages;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class Admissions {

    public static final int MAX_APPLY_SIZE = 6;
    private List<Admission> admissions;

    public Admissions(List<Admission> admissions) {
        validateSize(admissions);
        validateDuplicate(admissions);
        this.admissions = admissions;
    }

    private void validateSize(List<Admission> admissions) {
        if (admissions.size() > MAX_APPLY_SIZE) {
            throw new IllegalArgumentException(Messages.SIZE_ERROR_MESSAGE.getMessage());
        }
    }

    private void validateDuplicate(List<Admission> admissions) {
        HashSet<Admission> noDuplicateAdmissions = new HashSet<>(admissions);
        if (admissions.size() != noDuplicateAdmissions.size()) {
            throw new IllegalArgumentException(Messages.DUPLICATE_ERROR_MESSAGE.getMessage());
        }
    }

    public void showResult() {
        boolean isAccepted = admissions.stream().anyMatch(Admission::isAccepted);

        getAcceptedResult();
        String message =
            isAccepted ? Messages.SHOW_RESULT_PASS_MESSAGE.getMessage()
                : Messages.SHOW_RESULT_NO_PASS_MESSAGE.getMessage();
        System.out.println(message);
    }

    public void getAcceptedResult() {
        System.out.println(Messages.PASS_LIST.getMessage());
        System.out.println(Messages.DELIMITER.getMessage());
        AtomicBoolean isAccepted = new AtomicBoolean(false);

        admissions.forEach(
            admission -> {
                if (admission.isAccepted()) {
                    System.out.println(admission);
                    isAccepted.set(true);
                }
            }
        );

        if (!isAccepted.get()) {
            System.out.println(Messages.NO_PASS_LIST.getMessage());
        }
        System.out.println(Messages.DELIMITER.getMessage());
    }

    public void getDeclinedResult() {
        System.out.println(Messages.FAIL_LIST.getMessage());
        System.out.println(Messages.DELIMITER.getMessage());
        AtomicBoolean isDeclined = new AtomicBoolean(false);

        admissions.forEach(
            admission -> {
                if (!admission.isAccepted()) {
                    System.out.println(admission);
                    isDeclined.set(true);
                }
            }
        );

        if (!isDeclined.get()) {
            System.out.println(Messages.NO_FAIL_LIST.getMessage());
        }
        System.out.println(Messages.DELIMITER.getMessage());
    }
}

 

리턴되는 메세지들은 미리 static으로 하나만 올려두고 싶어서 enum으로 정의해뒀다.

package com.example.sypark9646.item17.utils;

public enum Messages {
    PASS("합격"),
    FAIL("불합격"),
    PASS_LIST("합격한 대학 목록입니다"),
    FAIL_LIST("불합격한 대학 목록입니다"),
    DELIMITER("- - -"),
    NO_PASS_LIST("합격한 대학이 없습니다."),
    NO_FAIL_LIST("불합격한 대학이 없습니다."),
    SHOW_RESULT_NO_PASS_MESSAGE("지원한 학교에 모두 떨어졌습니다. 재수 학원을 추천합니다..."),
    SHOW_RESULT_PASS_MESSAGE("합격을 축하합니다! 반수는 어떨까요...?"),
    DUPLICATE_ERROR_MESSAGE("같은 대학의 같은 학과를 다른 전형으로 지원하는 것은 불가능합니다."),
    SIZE_ERROR_MESSAGE("지원할 수 있는 학교 및 학과는 0개 이상 6개 이하입니다.")
    ;


    private String message;

    Messages(String message) {
        this.message = message;
    }

    public String getMessage() {
        return message;
    }
}

 

EarlyAdmission에서는 최종적으로 결과를 리턴할 수 있도록 했다.

Admission을 컴포지션으로 넣음으로써 결과 리턴 시 admission의 메소드들을 사용하게 했다.

package com.example.sypark9646.item17;

import java.time.LocalDate;
import java.util.List;
import com.example.sypark9646.item17.model.Admission;
import com.example.sypark9646.item17.model.Admissions;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class EarlyAdmission {

    private String name;
    private LocalDate birth;
    private Admissions admissions;

    public EarlyAdmission(String name, LocalDate birth, List<Admission> admissionList) {
        this.name = name;
        this.birth = birth;
        this.admissions = new Admissions(admissionList);
    }

    public void showResult() {
        System.out.println(name+"님의 지원 결과");
        admissions.showResult();
    }

    public void getAcceptedResult() {
        System.out.println(name+"님의 지원 결과");
        admissions.getAcceptedResult();
    }

    public void getDeclinedResult() {
        System.out.println(name+"님의 지원 결과");
        admissions.getDeclinedResult();
    }
}

 

테스트 결과는 아래와 같다

package com.example.sypark9646.item17;

import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.List;
import com.example.sypark9646.item17.model.Admission;
import com.example.sypark9646.item17.model.University;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

public class EarlyAdmissionTest {

    @DisplayName("정상 동작 테스트")
    @Test
    void testEarlyAdmission() {
        List<Admission> admissions = new ArrayList<>();
        admissions.add(new Admission(University.CATHOLIC, "컴퓨터공학과"));
        admissions.add(new Admission(University.KONKUK, "컴퓨터공학과"));
        admissions.add(new Admission(University.KOREA, "컴퓨터공학과"));
        admissions.add(new Admission(University.KYUNGHEE, "컴퓨터공학과"));
        admissions.add(new Admission(University.SEOUL, "컴퓨터공학과"));
        admissions.add(new Admission(University.SOGANG, "컴퓨터공학과"));

        LocalDate birth = LocalDate.of(1996, 4, 6);

        EarlyAdmission earlyAdmission = new EarlyAdmission("박소연", birth, admissions);

        System.out.println("[Show Result]");
        earlyAdmission.showResult();
        System.out.println();

        System.out.println("[Get Accepted Result]");
        earlyAdmission.getAcceptedResult();

        System.out.println("[Get Declined Result]");
        earlyAdmission.getDeclinedResult();
    }

    @DisplayName("invalid size 테스트")
    @Test
    void testEarlyAdmission_WhenInvalidSize_ThrowException() {
        List<Admission> admissions = new ArrayList<>();
        admissions.add(new Admission(University.CATHOLIC, "컴퓨터공학과"));
        admissions.add(new Admission(University.KONKUK, "컴퓨터공학과"));
        admissions.add(new Admission(University.KOREA, "컴퓨터공학과"));
        admissions.add(new Admission(University.KYUNGHEE, "컴퓨터공학과"));
        admissions.add(new Admission(University.SEOUL, "컴퓨터공학과"));
        admissions.add(new Admission(University.SOGANG, "컴퓨터공학과"));
        admissions.add(new Admission(University.SUNGKYUNKWAN, "컴퓨터공학과"));

        LocalDate birth = LocalDate.of(1996, 4, 6);

        assertThatThrownBy(() -> {
            EarlyAdmission earlyAdmission = new EarlyAdmission("박소연", birth, admissions);
        }).isInstanceOf(IllegalArgumentException.class);
    }

    @DisplayName("duplicate admission 테스트")
    @Test
    void testEarlyAdmission_WhenDuplicateAdmission_ThrowException() {
        List<Admission> admissions = new ArrayList<>();
        admissions.add(new Admission(University.CATHOLIC, "컴퓨터공학과"));
        admissions.add(new Admission(University.KONKUK, "컴퓨터공학과"));
        admissions.add(new Admission(University.KOREA, "컴퓨터공학과"));
        admissions.add(new Admission(University.KYUNGHEE, "컴퓨터공학과"));
        admissions.add(new Admission(University.SEOUL, "컴퓨터공학과"));
        admissions.add(new Admission(University.SEOUL, "수학과"));

        LocalDate birth = LocalDate.of(1996, 4, 6);

        assertThatThrownBy(() -> {
            EarlyAdmission earlyAdmission = new EarlyAdmission("박소연", birth, admissions);
        }).isInstanceOf(IllegalArgumentException.class);
    }
}

+ Recent posts