이펙티브 자바 완벽 공략 2부

아이템 17.변경 가능성을 최소화 하라.

아이템 17.변경 가능성을 최소화 하라.


아이템 17. 핵심 정리 - 불변 클래스

아이템 17. 변경 가능성을 최소화하라

이번 아이템에서는 불변 클래스가 무엇인지, 불변 클래스를 어떻게 만드는지, 그리고 왜 불변 객체가 안전하고 사용하기 쉬운지를 살펴본다.

불변 클래스란 인스턴스를 생성한 이후부터 소멸될 때까지 내부 상태가 바뀌지 않는 클래스를 의미한다.

즉, 객체를 한 번 만들면 그 객체가 가지고 있는 값은 끝까지 유지된다.

대표적인 불변 클래스로는 다음과 같은 것들이 있다.

  • String
  • BigInteger
  • BigDecimal
  • 기본 타입의 박싱 클래스
  • LocalDate
  • LocalDateTime

불변 객체는 상태가 바뀌지 않기 때문에 가변 객체보다 이해하기 쉽고, 오류가 발생할 가능성이 낮으며, 여러 스레드가 동시에 사용해도 안전하다.


불변 클래스란?

불변 클래스(Immutable Class)는 생성 이후 인스턴스의 상태를 변경할 수 없는 클래스다.

다음과 같은 객체가 있다고 가정해보자.

public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNum;

    public PhoneNumber(
            short areaCode,
            short prefix,
            short lineNum
    ) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }
}

이 객체는 생성자에서 값을 한 번 설정한 이후에는 내부 값을 변경할 수 없다.

객체의 생명주기 동안 다음 값들이 그대로 유지된다.

areaCode
prefix
lineNum

이처럼 생성 이후 상태가 변하지 않는 객체를 불변 객체라고 한다.


불변 객체를 사용하는 이유

불변 객체의 가장 큰 장점은 단순함과 안전성이다.

가변 객체는 언제 어디서 상태가 변경될지 추적해야 한다.

반면 불변 객체는 처음 생성된 상태가 끝까지 유지되므로, 객체를 사용하는 동안 상태 변경을 걱정할 필요가 없다.

가변 객체를 사용할 때의 고민

가변 객체를 사용하면 다음과 같은 질문을 계속해야 한다.

이 객체는 현재 어떤 상태인가?

다른 코드가 값을 변경하지 않았는가?

메서드 호출 이후 상태가 달라지는가?

다른 스레드에서 값을 수정하지 않았는가?

불변 객체를 사용할 때

불변 객체는 상태가 변하지 않으므로 다음처럼 단순하게 생각할 수 있다.

처음 생성된 값이 현재 값이다.

이 차이가 프로그램의 복잡도를 크게 줄인다.


불변 클래스의 장점

불변 클래스는 다음과 같은 장점을 제공한다.

  • 이해하기 쉽다.
  • 상태 변경으로 인한 오류가 적다.
  • 여러 코드에서 안전하게 공유할 수 있다.
  • 방어적 복사가 줄어든다.
  • 멀티스레드 환경에서 안전하다.
  • 캐싱하기 쉽다.
  • 값 객체로 사용하기 적합하다.

특히 여러 스레드가 동일한 인스턴스를 공유하더라도 상태가 바뀌지 않기 때문에 동기화 없이 안전하게 사용할 수 있다.


불변 클래스를 만드는 다섯 가지 규칙

불변 클래스를 만들기 위해서는 다음 다섯 가지 규칙을 지켜야 한다.

  1. 객체 상태를 변경하는 메서드를 제공하지 않는다.
  2. 클래스를 확장할 수 없도록 만든다.
  3. 모든 필드를 final로 선언한다.
  4. 모든 필드를 private으로 선언한다.
  5. 내부의 가변 컴포넌트에 접근할 수 없도록 한다.

각 규칙을 자세히 살펴보자.


객체의 상태를 변경하는 메서드를 제공하지 않는다

불변 객체를 만들려면 객체의 내부 상태를 변경하는 메서드를 제공해서는 안 된다.

대표적인 상태 변경 메서드는 setter다.

잘못된 예시

public class PhoneNumber {

    private short areaCode;

    public void setAreaCode(short areaCode) {
        this.areaCode = areaCode;
    }
}

이 클래스는 생성 이후에도 areaCode를 변경할 수 있다.

phoneNumber.setAreaCode((short) 2);

따라서 불변 객체가 아니다.


setter를 제공하지 않는다

불변 클래스에서는 생성자를 통해 필요한 값을 모두 전달받고, 이후에는 값을 변경할 수 없도록 만든다.

public final class PhoneNumber {

    private final short areaCode;

    public PhoneNumber(short areaCode) {
        this.areaCode = areaCode;
    }

    public short getAreaCode() {
        return areaCode;
    }
}

getter는 값을 읽기만 하므로 불변성을 깨뜨리지 않는다.

반대로 setter는 상태를 변경하므로 제공하지 않는다.


setter 외의 변경 메서드도 주의해야 한다

setter 이름이 아니더라도 내부 상태를 변경한다면 불변성이 깨진다.

public void changeAreaCode(short areaCode) {
    this.areaCode = areaCode;
}

또는 다음과 같은 메서드도 상태를 변경한다.

public void reset() {
    this.areaCode = 0;
}

중요한 것은 메서드 이름이 아니라, 객체의 내부 상태를 실제로 변경하는지 여부다.


클래스를 확장할 수 없도록 만든다

불변 클래스를 만들었더라도 상속을 허용하면 하위 클래스가 불변성을 깨뜨릴 수 있다.

상속으로 불변성이 깨지는 예시

다음 클래스가 불변으로 설계되었다고 가정해보자.

public class PhoneNumber {

    private final String number;

    public PhoneNumber(String number) {
        this.number = number;
    }

    public String getNumber() {
        return number;
    }
}

하지만 클래스가 상속 가능하다면 다음과 같은 하위 클래스를 만들 수 있다.

public class MyPhoneNumber extends PhoneNumber {

    private String mutableValue;

    public MyPhoneNumber(
            String number,
            String mutableValue
    ) {
        super(number);
        this.mutableValue = mutableValue;
    }

    public void setMutableValue(String mutableValue) {
        this.mutableValue = mutableValue;
    }
}

MyPhoneNumberPhoneNumber 타입으로 사용할 수 있지만 상태가 변경될 수 있다.

PhoneNumber phoneNumber =
        new MyPhoneNumber("02-1234-5678", "A");

겉으로는 PhoneNumber지만 실제 객체는 가변 객체다.


final 클래스로 만든다

가장 간단한 해결 방법은 클래스에 final을 붙이는 것이다.

public final class PhoneNumber {

    private final String number;

    public PhoneNumber(String number) {
        this.number = number;
    }
}

이제 다른 클래스가 PhoneNumber를 상속할 수 없다.

public class MyPhoneNumber extends PhoneNumber {
}

위 코드는 컴파일되지 않는다.


생성자를 제한하는 방법도 있다

반드시 클래스를 final로 선언해야만 상속을 막을 수 있는 것은 아니다.

생성자를 private 또는 package-private으로 제한하고 정적 팩터리 메서드를 제공하는 방법도 있다.

public class PhoneNumber {

    private final String number;

    private PhoneNumber(String number) {
        this.number = number;
    }

    public static PhoneNumber of(String number) {
        return new PhoneNumber(number);
    }
}

생성자가 private이므로 외부 클래스가 상속할 수 없다.

이 방식은 다음과 같은 장점도 제공한다.

  • 구현 클래스를 숨길 수 있다.
  • 인스턴스 캐싱이 가능하다.
  • 하위 구현체를 반환할 수 있다.
  • 생성 로직을 통제할 수 있다.

모든 필드를 final로 선언한다

불변 클래스의 모든 필드는 가능하면 final로 선언해야 한다.

private final String number;

final 필드는 생성자에서 한 번 초기화한 이후 다른 값을 대입할 수 없다.

final을 사용하지 않은 경우

private String number;

현재 setter가 없더라도 클래스 내부의 다른 메서드에서 실수로 값을 변경할 수 있다.

private void normalize() {
    number = number.replace("-", "");
}

final을 사용한 경우

private final String number;

다시 대입하려고 하면 컴파일 오류가 발생한다.

number = "new value";

final은 개발자의 실수를 컴파일 시점에 방지한다.


final 필드와 메모리 가시성

final 필드는 단순히 값을 다시 대입하지 못하게 하는 것 이상의 의미를 가진다.

자바 메모리 모델은 생성자가 정상적으로 종료된 객체의 final 필드가 다른 스레드에서도 올바른 값으로 보이도록 특별한 보장을 제공한다.

따라서 불변 객체를 안전하게 생성한 뒤 다른 스레드와 공유하면, 별도의 동기화 없이도 초기화된 값을 안정적으로 읽을 수 있다.

이러한 특성 때문에 불변 객체는 멀티스레드 환경에서 특히 유리하다.


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

필드가 final이라고 하더라도 외부에 직접 공개하면 내부 표현이 API에 포함된다.

잘못된 예시

public final class PhoneNumber {

    public final short areaCode;
    public final short prefix;
    public final short lineNum;
}

필드 값은 변경할 수 없지만, 클라이언트 코드는 필드 이름과 타입에 직접 의존하게 된다.

phoneNumber.areaCode;
phoneNumber.prefix;

나중에 내부 표현을 바꾸기 어려워진다.


private으로 감춘다

public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNum;
}

외부에는 필요한 메서드만 공개한다.

public short getAreaCode() {
    return areaCode;
}

필드를 감추면 내부 표현을 자유롭게 변경할 수 있다.

예를 들어 내부적으로 전화번호를 하나의 문자열로 저장하도록 변경해도 외부 API를 유지할 수 있다.

private final String number;
public short getAreaCode() {
    return parseAreaCode(number);
}

클라이언트 코드는 변경하지 않아도 된다.


내부의 가변 컴포넌트에 접근할 수 없도록 한다

불변 클래스 내부에 가변 객체가 포함되어 있다면 주의해야 한다.

필드 자체가 private final이더라도 참조 대상 객체의 상태는 변경될 수 있기 때문이다.

가변 Address를 가진 Person

다음과 같은 가변 Address 클래스가 있다고 가정해보자.

public class Address {

    private String city;
    private String street;

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }
}

이제 Person을 불변처럼 만들어보자.

public final class Person {

    private final String name;
    private final Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = address;
    }

    public Address getAddress() {
        return address;
    }
}

겉으로 보면 다음 조건을 만족한다.

  • 클래스가 final이다.
  • 필드가 private이다.
  • 필드가 final이다.
  • setter가 없다.

하지만 실제로는 불변 클래스가 아니다.


생성자에서 전달받은 가변 객체가 문제다

다음 코드를 살펴보자.

Address address =
        new Address("Seattle", "1st Street");

Person person =
        new Person("Yoon", address);

Person을 생성한 이후 원본 Address를 변경할 수 있다.

address.setCity("Redmond");

그러면 Person이 가지고 있는 주소도 바뀐다.

System.out.println(
        person.getAddress().getCity()
);

결과는 다음과 같다.

Redmond

Personaddress 참조 자체는 바뀌지 않았다.

하지만 address가 가리키는 객체의 내부 상태가 바뀌었다.

따라서 Person의 논리적인 상태도 변경된 것이다.


getter로 가변 객체를 그대로 반환해도 문제가 된다

생성자에서 복사했다고 하더라도 getter가 내부 객체를 그대로 반환하면 불변성이 깨질 수 있다.

Address personAddress = person.getAddress();

personAddress.setCity("Redmond");

외부 코드가 내부 가변 객체를 직접 변경할 수 있다.


방어적 복사를 사용한다

가변 객체를 내부에 보관해야 한다면 생성자와 getter에서 방어적 복사를 수행해야 한다.

생성자에서 방어적 복사

public final class Person {

    private final String name;
    private final Address address;

    public Person(String name, Address address) {
        this.name = name;
        this.address = new Address(
                address.getCity(),
                address.getStreet()
        );
    }
}

외부에서 전달한 원본 AddressPerson 내부의 Address가 서로 다른 객체가 된다.

원본 주소를 변경해도 Person에는 영향을 주지 않는다.


getter에서도 방어적 복사

public Address getAddress() {
    return new Address(
            address.getCity(),
            address.getStreet()
    );
}

외부 코드가 반환받은 주소를 변경하더라도 Person 내부 상태는 바뀌지 않는다.

Address copiedAddress = person.getAddress();
copiedAddress.setCity("Redmond");

Person이 가진 주소는 그대로 유지된다.


더 좋은 방법은 내부 객체도 불변으로 만드는 것이다

방어적 복사는 안전하지만 매번 새로운 객체를 만들어야 한다.

가능하다면 Address 자체를 불변으로 만드는 것이 더 좋다.

public final class Address {

    private final String city;
    private final String street;

    public Address(String city, String street) {
        this.city = city;
        this.street = street;
    }

    public String getCity() {
        return city;
    }

    public String getStreet() {
        return street;
    }
}

이제 Address도 상태가 변경되지 않는다.

Person은 내부 객체를 안전하게 공유할 수 있다.

public Address getAddress() {
    return address;
}

불변 객체는 외부에 그대로 반환해도 상태가 바뀌지 않으므로 안전하다.


final은 참조 대상까지 불변으로 만들지 않는다

다음 선언을 주의해서 이해해야 한다.

private final Address address;

여기서 final이 보장하는 것은 다음뿐이다.

address가 다른 객체를 참조하도록 바꿀 수 없다.

다음은 불가능하다.

this.address = anotherAddress;

하지만 참조하고 있는 객체 내부의 값은 변경될 수 있다.

address.setCity("Redmond");

final 참조와 객체의 불변성은 서로 다른 개념이다.


변경 작업은 새로운 객체를 반환한다

불변 객체의 값을 변경해야 하는 상황에서는 기존 객체를 수정하지 않는다.

대신 변경된 값을 가진 새로운 객체를 만들어 반환한다.

대표적인 예가 String이다.

String original = "Hello";

String changed = original.concat(" Java");

original은 그대로 유지된다.

original = "Hello"
changed  = "Hello Java"

숫자형 불변 객체도 새로운 객체를 반환한다

BigIntegerBigDecimal도 동일한 방식으로 동작한다.

BigInteger number =
        new BigInteger("10");

BigInteger result =
        number.add(BigInteger.ONE);

원본 객체는 변경되지 않는다.

number = 10
result = 11

불변 값 객체의 변경 메서드

PhoneNumber에서 지역번호를 바꾸는 기능이 필요하다고 가정해보자.

기존 객체를 변경하지 않고 새로운 객체를 반환한다.

public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNum;

    public PhoneNumber(
            short areaCode,
            short prefix,
            short lineNum
    ) {
        this.areaCode = areaCode;
        this.prefix = prefix;
        this.lineNum = lineNum;
    }

    public PhoneNumber withAreaCode(
            short newAreaCode
    ) {
        return new PhoneNumber(
                newAreaCode,
                prefix,
                lineNum
        );
    }
}

사용 방법은 다음과 같다.

PhoneNumber seoul =
        new PhoneNumber(
                (short) 2,
                (short) 1234,
                (short) 5678
        );

PhoneNumber busan =
        seoul.withAreaCode((short) 51);

seoul은 변경되지 않고 새로운 busan 객체가 만들어진다.


함수형 접근 방식

기존 객체를 변경하지 않고 새로운 객체를 반환하는 방식을 함수형 접근이라고 부르기도 한다.

절차적 또는 명령형 방식

phoneNumber.setAreaCode((short) 51);

기존 객체의 상태를 변경한다.

함수형 방식

PhoneNumber changed =
        phoneNumber.withAreaCode((short) 51);

원본을 유지하고 새로운 값을 반환한다.

이 방식은 상태 변경으로 인한 부작용을 줄인다.


불변 클래스 설계 예시

다섯 가지 규칙을 모두 적용하면 다음과 같은 구조가 된다.

public final class Money {

    private final BigDecimal amount;
    private final Currency currency;

    public Money(
            BigDecimal amount,
            Currency currency
    ) {
        if (amount == null) {
            throw new IllegalArgumentException(
                    "금액은 null일 수 없습니다."
            );
        }

        if (currency == null) {
            throw new IllegalArgumentException(
                    "통화는 null일 수 없습니다."
            );
        }

        this.amount = amount;
        this.currency = currency;
    }

    public BigDecimal getAmount() {
        return amount;
    }

    public Currency getCurrency() {
        return currency;
    }

    public Money add(Money other) {
        validateCurrency(other);

        return new Money(
                amount.add(other.amount),
                currency
        );
    }

    private void validateCurrency(Money other) {
        if (!currency.equals(other.currency)) {
            throw new IllegalArgumentException(
                    "통화가 일치하지 않습니다."
            );
        }
    }
}

이 클래스는 다음 특징을 가진다.

  • 상태 변경 메서드가 없다.
  • 클래스가 final이다.
  • 모든 필드가 private final이다.
  • 내부 값이 불변 객체다.
  • 연산 결과로 새로운 객체를 반환한다.

불변 클래스 설계 체크리스트

상태 변경 차단

  • setter가 없는가?
  • 내부 상태를 수정하는 메서드가 없는가?
  • 메서드가 기존 객체 대신 새로운 객체를 반환하는가?

상속 차단

  • 클래스가 final인가?
  • 또는 생성자가 외부 상속을 막도록 제한되어 있는가?

필드 보호

  • 모든 필드가 private인가?
  • 모든 필드가 가능한 한 final인가?

가변 컴포넌트 보호

  • 생성자에서 가변 객체를 방어적으로 복사하는가?
  • getter에서 내부 가변 객체를 그대로 반환하지 않는가?
  • 가능하다면 내부 컴포넌트도 불변으로 만들었는가?

핵심 정리

  • 불변 클래스는 생성 이후 소멸할 때까지 상태가 바뀌지 않는다.
  • 객체 상태를 변경하는 메서드를 제공하지 않는다.
  • 클래스를 final로 만들거나 생성자를 제한해 상속을 막는다.
  • 모든 필드는 가능한 한 final로 선언한다.
  • 모든 필드는 private으로 숨긴다.
  • 가변 객체를 내부에 보관한다면 방어적 복사를 사용한다.
  • 생성자와 getter 양쪽 모두 가변 객체 노출을 주의해야 한다.
  • final 참조가 참조 대상 객체의 불변성을 보장하지는 않는다.
  • 상태 변경이 필요하면 기존 객체를 수정하지 않고 새로운 객체를 반환한다.
  • 가능하다면 내부 컴포넌트도 불변 객체로 설계한다.

한 줄 정리

불변 클래스는 상태 변경 경로를 모두 차단하고, 변경이 필요할 때 기존 객체를 수정하는 대신 새로운 객체를 반환하도록 설계해야 한다.

아이템 17. 핵심 정리 - 불변 클래스의 장점과 단점

아이템 17. 변경 가능성을 최소화하라: 불변 클래스의 장점과 단점

이번에는 불변 클래스가 가지는 장점과 단점을 살펴본다.

불변 클래스는 생성 이후 상태가 바뀌지 않는 클래스다. 내부 값이 한 번 정해지면 객체가 소멸할 때까지 그대로 유지된다.

대표적인 불변 클래스로는 다음과 같은 것들이 있다.

  • String
  • BigInteger
  • BigDecimal
  • LocalDate
  • LocalDateTime
  • 기본 타입의 박싱 클래스

불변 클래스는 단점보다 장점이 훨씬 많다.

특히 다음과 같은 특성을 가진다.

  • 함수형 프로그래밍에 적합하다.
  • 프로그램을 단순하게 만든다.
  • 근본적으로 스레드 안전하다.
  • 자유롭게 공유할 수 있다.
  • 내부 데이터도 다른 불변 객체와 공유할 수 있다.
  • 다른 객체를 구성하는 요소로 사용하기 좋다.
  • 실패 원자성을 자연스럽게 제공한다.

반면 값이 달라질 때마다 새로운 객체를 만들어야 한다는 단점이 있다.


불변 복소수 클래스

불변 클래스의 특징을 살펴보기 위해 복소수를 표현하는 Complex 클래스를 생각해보자.

public final class Complex {

    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public double realPart() {
        return re;
    }

    public double imaginaryPart() {
        return im;
    }
}

re는 실수부를, im은 허수부를 나타낸다.

모든 필드는 private final이고, 값을 변경하는 setter는 제공하지 않는다.

이 클래스에서 덧셈 연산을 구현하면 다음과 같다.

public Complex plus(Complex complex) {
    return new Complex(
            re + complex.re,
            im + complex.im
    );
}

기존 객체의 값을 변경하지 않고 새로운 결과 객체를 만들어 반환한다.


기존 객체를 변경하지 않고 새로운 객체를 반환한다

두 개의 복소수를 더한다고 가정해보자.

Complex first = new Complex(1.0, 2.0);
Complex second = new Complex(3.0, 4.0);

Complex result = first.plus(second);

연산 이후에도 firstsecond는 변경되지 않는다.

first  = 1.0 + 2.0i
second = 3.0 + 4.0i
result = 4.0 + 6.0i

이처럼 피연산자를 변경하지 않고 새로운 결과를 반환하는 방식을 함수형 접근 방식이라고 한다.


함수형 프로그래밍에 적합하다

불변 객체는 함수형 프로그래밍과 잘 어울린다.

함수형 프로그래밍에서는 일반적으로 입력값을 변경하지 않고, 입력값을 이용해 새로운 결과를 만들어낸다.

public Complex minus(Complex complex) {
    return new Complex(
            re - complex.re,
            im - complex.im
    );
}
public Complex times(Complex complex) {
    return new Complex(
            re * complex.re - im * complex.im,
            re * complex.im + im * complex.re
    );
}
public Complex dividedBy(Complex complex) {
    double denominator =
            complex.re * complex.re
                    + complex.im * complex.im;

    return new Complex(
            (re * complex.re + im * complex.im)
                    / denominator,
            (im * complex.re - re * complex.im)
                    / denominator
    );
}

모든 연산은 기존 객체를 변경하지 않는다.


동일한 입력에는 동일한 결과가 나온다

불변 객체는 내부 상태가 바뀌지 않으므로 동일한 객체와 동일한 매개변수로 연산하면 항상 동일한 결과가 나온다.

Complex value = new Complex(1.0, 2.0);
Complex other = new Complex(3.0, 4.0);

Complex firstResult = value.plus(other);
Complex secondResult = value.plus(other);

두 결과는 같은 값을 가진다.

firstResult  = 4.0 + 6.0i
secondResult = 4.0 + 6.0i

반대로 기존 객체의 상태를 변경하는 방식이라면 결과가 달라질 수 있다.

public void plus(Complex complex) {
    this.re += complex.re;
    this.im += complex.im;
}

첫 번째 호출 이후 객체의 상태가 달라지기 때문에 같은 값을 더해도 결과가 계속 달라진다.

첫 번째 호출 결과: 4.0 + 6.0i
두 번째 호출 결과: 7.0 + 10.0i

입력 외에 객체의 현재 상태가 결과에 영향을 주므로 코드의 흐름을 추적하기 어려워진다.


불변 객체는 프로그램을 단순하게 만든다

불변 객체를 사용하면 객체의 현재 상태를 계속 추적할 필요가 없다.

객체가 처음 만들어졌을 때의 값이 언제나 현재 값이기 때문이다.

Complex complex = new Complex(1.0, 2.0);

이 객체는 어디에 전달하더라도 계속 다음 값을 유지한다.

1.0 + 2.0i

클라이언트는 다음과 같은 고민을 하지 않아도 된다.

이 객체가 다른 메서드에서 변경되었는가?

다른 클래스가 값을 수정했는가?

메서드를 호출한 뒤 다시 값을 읽어야 하는가?

원본을 보호하기 위해 복사해야 하는가?

가변 객체는 상태 추적이 필요하다

가변 객체는 메서드가 호출될 때마다 상태가 달라질 수 있다.

order.changeStatus();
entity.setName("changed");
point.setX(100);

객체를 사용하는 코드는 다음 사항을 이해해야 한다.

  • 현재 객체가 어떤 상태인지
  • 어떤 메서드가 상태를 변경하는지
  • 상태 변경이 다른 객체에 영향을 주는지
  • 변경된 값이 데이터베이스에 언제 반영되는지

객체의 상태가 많아질수록 프로그램의 복잡도도 증가한다.


가변 상태를 관리하는 기술은 복잡하다

JPA 엔티티는 가변 객체의 복잡성을 보여주는 대표적인 사례다.

JPA 엔티티는 영속성 컨텍스트 안에서 여러 상태를 가진다.

비영속
영속
준영속
삭제

영속 상태의 엔티티가 변경되면 JPA는 변경된 값을 감지해 데이터베이스에 반영한다.

Member member = entityManager.find(
        Member.class,
        memberId
);

member.changeName("new name");

개발자는 다음 사항을 이해해야 한다.

  • 엔티티가 현재 영속 상태인지
  • 변경 감지가 동작하는 시점은 언제인지
  • 트랜잭션이 커밋되는 시점은 언제인지
  • 변경한 값이 데이터베이스에 반영되는지
  • 준영속 상태에서는 어떤 일이 발생하는지

가변 객체가 항상 나쁘다는 의미는 아니다. 다만 상태가 변경되는 객체는 불변 객체보다 더 많은 관리 비용과 이해가 필요하다.


불변 객체는 근본적으로 스레드 안전하다

불변 객체는 생성 이후 상태가 바뀌지 않는다.

따라서 여러 스레드가 동시에 같은 인스턴스를 사용해도 객체 상태가 훼손되지 않는다.

Complex complex = new Complex(1.0, 2.0);

두 스레드가 동시에 이 객체를 사용한다고 가정해보자.

Thread A ─┐
          ├─ Complex
Thread B ─┘

어느 스레드도 Complex의 값을 변경할 수 없기 때문에 동기화가 필요하지 않다.


가변 객체의 경쟁 상태

가변 객체를 여러 스레드가 공유하면 경쟁 상태가 발생할 수 있다.

public class Counter {

    private int count;

    public void increment() {
        count++;
    }
}

count++는 하나의 원자적 연산이 아니다.

내부적으로는 다음 과정으로 동작할 수 있다.

현재 값 읽기
값에 1 더하기
새로운 값 저장하기

두 스레드가 동시에 실행하면 일부 증가 연산이 사라질 수 있다.

Thread A: count 읽기 → 0
Thread B: count 읽기 → 0

Thread A: 1 저장
Thread B: 1 저장

두 번 증가했지만 최종 결과는 1이다.

불변 객체는 애초에 상태 변경 자체가 없으므로 이런 경쟁 상태가 발생하지 않는다.


불변 객체는 안심하고 공유할 수 있다

불변 객체는 여러 스레드뿐만 아니라 여러 클래스와 여러 인스턴스가 함께 사용해도 안전하다.

같은 값을 반복해서 사용하는 경우 하나의 인스턴스를 상수로 선언할 수 있다.

public static final Complex ZERO =
        new Complex(0.0, 0.0);

public static final Complex ONE =
        new Complex(1.0, 0.0);

public static final Complex I =
        new Complex(0.0, 1.0);

어떤 코드에서도 값을 변경할 수 없으므로 하나의 인스턴스를 재사용해도 문제가 없다.

Complex first = Complex.ZERO;
Complex second = Complex.ZERO;

firstsecond는 동일한 인스턴스를 참조해도 안전하다.


final 참조만으로는 안전하지 않다

다음 선언에서 final은 참조 변경만 막는다.

public static final MutablePoint POINT =
        new MutablePoint(10, 20);

다른 객체를 대입할 수는 없다.

POINT = new MutablePoint(30, 40);

하지만 객체 자체가 가변이라면 내부 상태는 변경할 수 있다.

POINT.setX(100);

따라서 public static final로 안전하게 공유하려면 참조 대상 객체도 불변이어야 한다.


불변 객체는 내부 데이터를 공유할 수 있다

불변 객체끼리는 내부 데이터도 안전하게 공유할 수 있다.

대표적인 사례가 BigInteger다.

BigInteger는 개념적으로 다음과 같은 정보를 가진다.

  • 부호
  • 숫자의 크기를 표현하는 배열
private final int signum;
private final int[] magnitude;

배열은 원래 가변 객체다.

하지만 BigInteger는 외부에서 배열을 변경할 수 없도록 철저히 통제하며, 내부 코드에서도 배열을 변경하지 않는 방식으로 불변성을 유지한다.


부호만 바꿀 때 내부 배열을 재사용할 수 있다

BigIntegernegate()는 숫자의 부호를 반대로 만든다.

10 → -10
-10 → 10

숫자의 크기는 그대로이고 부호만 달라진다.

개념적으로 다음과 같은 방식으로 구현할 수 있다.

public BigInteger negate() {
    return new BigInteger(
            -signum,
            magnitude
    );
}

새로운 BigInteger 객체를 만들지만 magnitude 배열은 기존 객체와 공유할 수 있다.

BigInteger 10
 ├─ signum: 1
 └─ magnitude ─────┐
                   │
BigInteger -10     │
 ├─ signum: -1     │
 └─ magnitude ─────┘

두 객체가 같은 배열을 참조하지만 어느 객체도 배열을 변경하지 않으므로 안전하다.


내부 데이터 공유 조건

내부 데이터를 공유하려면 다음 조건이 필요하다.

  • 데이터를 외부로 직접 노출하지 않아야 한다.
  • 공유하는 객체들도 불변이어야 한다.
  • 어떤 코드도 공유된 데이터를 변경하지 않아야 한다.
  • 생성 과정에서 외부 가변 객체의 참조를 그대로 저장하지 않아야 한다.

공유 대상 중 하나라도 가변이라면 불변성이 깨질 수 있다.


가변 객체와 내부 데이터를 공유하면 안 된다

불변 객체가 자신의 내부 배열을 가변 객체에 전달한다고 가정해보자.

MutableNumber mutableNumber =
        new MutableNumber(magnitude);

MutableNumber가 배열을 변경하면 원래 불변 객체의 상태도 달라진다.

mutableNumber.changeMagnitude();

따라서 내부 데이터 공유는 통제된 불변 객체끼리만 해야 한다.

안전 여부를 확신할 수 없다면 방어적 복사를 수행해야 한다.

int[] copy = magnitude.clone();

불변 객체는 다른 객체의 좋은 구성요소다

다른 객체를 구성하는 요소가 불변이면 그 객체의 상태를 관리하기 쉬워진다.

예를 들어 불변 Point를 사용하는 도형을 생각해보자.

public final class Point {

    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}
public final class Line {

    private final Point start;
    private final Point end;

    public Line(Point start, Point end) {
        this.start = start;
        this.end = end;
    }
}

Point가 불변이므로 Line은 전달받은 좌표 객체를 그대로 보관해도 안전하다.


컬렉션 요소도 불변이어야 안전하다

불변 컬렉션을 만들었다고 해서 그 안의 요소까지 자동으로 불변이 되는 것은 아니다.

Set<MutablePoint> points =
        Set.of(
                new MutablePoint(1, 1),
                new MutablePoint(2, 2)
        );

Set.of()로 만든 집합은 요소를 추가하거나 제거할 수 없다.

points.add(new MutablePoint(3, 3));

하지만 내부 요소가 가변이라면 요소의 상태는 변경할 수 있다.

MutablePoint first =
        points.iterator().next();

first.setX(100);

컬렉션 자체는 불변이지만 컬렉션이 나타내는 논리적인 값은 달라진다.


불변 컬렉션과 깊은 불변성

다음 두 개념을 구분해야 한다.

얕은 불변성

컬렉션 구조만 변경할 수 없다.

요소 추가 불가
요소 제거 불가
요소 교체 불가
깊은 불변성

컬렉션 구조뿐만 아니라 내부 요소의 상태도 변경할 수 없다.

깊은 불변성을 얻으려면 컬렉션에 들어가는 요소도 불변이어야 한다.

Set<Point> points =
        Set.of(
                new Point(1, 1),
                new Point(2, 2)
        );

불변 객체를 구성 요소로 많이 사용할수록 상위 객체도 견고하게 만들기 쉬워진다.


불변 객체는 실패 원자성을 제공한다

실패 원자성이란 메서드 실행 중 예외가 발생하더라도 객체가 호출 전 상태를 유지하는 성질이다.

다음 연산을 생각해보자.

Complex result = first.dividedBy(second);

나눗셈 과정에서 문제가 발생해 예외가 발생하더라도 firstsecond는 변경되지 않는다.

연산 전 first 상태
=
연산 실패 후 first 상태

기존 객체를 수정하지 않고 새로운 객체를 만드는 방식이기 때문이다.


연산 중 실패해도 기존 값은 유지된다

다음과 같은 메서드가 있다고 가정해보자.

public Complex dividedBy(Complex complex) {
    double denominator =
            complex.re * complex.re
                    + complex.im * complex.im;

    if (denominator == 0.0) {
        throw new ArithmeticException(
                "0으로 나눌 수 없습니다."
        );
    }

    return new Complex(
            (re * complex.re + im * complex.im)
                    / denominator,
            (im * complex.re - re * complex.im)
                    / denominator
    );
}

예외가 발생해도 다음 값들은 그대로 유지된다.

  • 현재 객체의 re
  • 현재 객체의 im
  • 매개변수 객체의 re
  • 매개변수 객체의 im

연산 결과 객체가 만들어지기 전에 실패했을 뿐, 기존 객체에는 아무런 변화가 없다.


가변 객체에서도 실패 원자성을 만들 수 있다

불변 객체만 실패 원자성을 제공할 수 있는 것은 아니다.

가변 객체도 값을 변경하기 전에 필요한 검증을 모두 수행하면 실패 원자성을 제공할 수 있다.

public void withdraw(BigDecimal amount) {
    if (amount.signum() <= 0) {
        throw new IllegalArgumentException();
    }

    if (balance.compareTo(amount) < 0) {
        throw new IllegalStateException();
    }

    balance = balance.subtract(amount);
}

상태를 변경하기 전에 모든 검증을 완료한다.

하지만 불변 객체는 기존 상태를 수정하지 않기 때문에 더 자연스럽게 실패 원자성을 제공한다.


불변 클래스의 단점

불변 클래스의 가장 큰 단점은 값이 달라질 때마다 새로운 객체를 만들어야 한다는 것이다.

Complex first = new Complex(1.0, 2.0);
Complex second = new Complex(3.0, 4.0);

Complex result = first.plus(second);

plus()는 반드시 새로운 객체를 생성한다.

연산이 여러 번 이어지면 중간 객체도 계속 만들어진다.

Complex result = first
        .plus(second)
        .minus(third)
        .times(fourth);

개념적으로 다음 객체들이 만들어질 수 있다.

first.plus(second)
        ↓
중간 객체 1

중간 객체 1.minus(third)
        ↓
중간 객체 2

중간 객체 2.times(fourth)
        ↓
최종 객체

최종 결과 하나를 얻기 위해 여러 개의 중간 객체가 생성된다.


객체 생성 비용이 클 수 있다

Complex처럼 필드가 두 개뿐인 작은 객체는 생성 비용이 크지 않다.

하지만 객체가 매우 많은 데이터를 가지고 있다면 복사 비용이 커질 수 있다.

예를 들어 백만 개의 비트를 가진 숫자 객체를 생각해보자.

1,000,000비트

불변 객체에서 하나의 비트만 변경하더라도 전체 데이터를 복사한 새로운 객체를 만들어야 할 수 있다.

가변 객체라면 해당 비트만 변경하면 된다.

가변 객체
특정 비트 변경 → O(1)

불변 객체
새 데이터 구조 생성 → O(n) 가능

실제 시간 복잡도는 내부 구현과 자료구조에 따라 달라지지만, 핵심은 변경 범위보다 훨씬 큰 복사가 필요할 수 있다는 점이다.


단점 보완 방법 1: 다단계 연산을 제공한다

여러 연산이 자주 함께 수행된다면 중간 객체 생성을 줄이는 전용 메서드를 제공할 수 있다.

기존 방식은 다음과 같다.

Complex result = first
        .plus(second)
        .minus(third)
        .times(fourth);

각 연산마다 새로운 객체를 생성한다.

이를 하나의 연산으로 합칠 수 있다.

public Complex plusMinusTimes(
        Complex plus,
        Complex minus,
        Complex times
) {
    double calculatedReal =
            re + plus.re - minus.re;

    double calculatedImaginary =
            im + plus.im - minus.im;

    return new Complex(
            calculatedReal * times.re
                    - calculatedImaginary * times.im,
            calculatedReal * times.im
                    + calculatedImaginary * times.re
    );
}

중간 객체를 만들지 않고 마지막에 결과 객체 하나만 생성할 수 있다.


다단계 연산의 장단점

다단계 연산은 성능을 높일 수 있지만 메서드 수가 증가할 수 있다.

plus
minus
times
plusAndTimes
minusAndTimes
plusMinusTimes

모든 연산 조합에 메서드를 제공하는 것은 현실적이지 않다.

따라서 자주 사용되고 성능상 의미가 있는 연산 조합에만 제한적으로 제공하는 것이 좋다.


단점 보완 방법 2: 가변 동반 클래스를 제공한다

값을 반복해서 변경해야 한다면 불변 클래스와 함께 가변 동반 클래스를 제공할 수 있다.

대표적인 사례가 StringStringBuilder다.

String은 불변이다.

String text = "Hello";
text = text + " ";
text = text + "Java";

문자열을 연결할 때마다 새로운 String 객체가 만들어질 수 있다.

반복문 안에서 많은 문자열을 연결하면 중간 객체가 계속 생성된다.

String result = "";

for (int i = 0; i < 10_000; i++) {
    result += i;
}

StringBuilder 사용

문자열을 반복적으로 변경해야 한다면 StringBuilder를 사용한다.

StringBuilder builder =
        new StringBuilder();

for (int i = 0; i < 10_000; i++) {
    builder.append(i);
}

String result = builder.toString();

StringBuilder는 내부 상태를 변경하면서 문자열을 조립한다.

최종 결과가 필요할 때만 불변 String으로 변환한다.

가변 StringBuilder
        ↓ 여러 번 변경
최종 불변 String

불변 클래스와 가변 동반 클래스

불변 클래스가변 동반 클래스
StringStringBuilder
안전한 공유에 적합반복 변경에 적합
상태 변경 불가내부 상태 변경 가능
연산마다 새 객체 생성 가능하나의 객체 안에서 연산 수행
스레드 안전성이 높음별도 동기화 고려 필요
결과 값 표현에 적합결과 생성 과정에 적합

가변 동반 클래스는 결과를 안전하게 보관하는 용도보다, 결과를 효율적으로 만들어내는 작업 공간에 가깝다.


불변 객체를 기본 선택으로 고려한다

새로운 클래스를 설계할 때는 먼저 불변으로 만들 수 있는지 검토하는 것이 좋다.

특히 다음과 같은 값 객체는 불변 클래스에 적합하다.

  • 금액
  • 전화번호
  • 이메일 주소
  • 좌표
  • 기간
  • 날짜 범위
  • 식별자
  • 복소수
  • URL
  • 주소
  • 주문 번호
  • 상품 코드

불변으로 만드는 비용이 지나치게 크거나 상태 변경 자체가 객체의 핵심 역할이라면 가변 객체를 사용할 수 있다.

중요한 것은 습관적으로 setter를 추가하는 것이 아니라, 변경 가능성이 정말 필요한지 먼저 검토하는 것이다.


불변 클래스의 장점 정리

함수형 프로그래밍에 적합하다

기존 피연산자를 변경하지 않고 새로운 결과를 반환한다.

프로그램이 단순해진다

객체의 상태 변경을 추적할 필요가 없다.

근본적으로 스레드 안전하다

상태가 바뀌지 않으므로 여러 스레드가 동시에 사용해도 안전하다.

자유롭게 공유할 수 있다

상수, 캐시, 여러 객체의 공통 값으로 재사용할 수 있다.

내부 데이터를 공유할 수 있다

변경되지 않는다는 사실이 보장되면 다른 불변 객체와 내부 데이터를 공유할 수 있다.

다른 객체의 좋은 구성요소다

내부 요소가 불변이면 상위 객체의 상태 관리도 쉬워진다.

실패 원자성을 제공한다

연산이 실패해도 기존 객체의 상태는 변하지 않는다.


불변 클래스의 단점과 대안

단점대안
값이 달라질 때마다 새 객체 생성객체가 작다면 그대로 사용
중간 객체가 많이 생성될 수 있음다단계 연산 메서드 제공
큰 객체는 복사 비용이 클 수 있음가변 동반 클래스 제공
반복 변경 작업에 비효율적빌더 또는 전용 가변 클래스 사용
메모리와 GC 비용 증가 가능프로파일링 후 필요한 부분만 최적화

실무에서의 적용 기준

불변 클래스를 설계할 때는 다음 기준을 적용할 수 있다.

불변 객체가 적합한 경우

  • 값 자체를 표현하는 객체
  • 여러 곳에서 공유되는 객체
  • 멀티스레드 환경에서 사용되는 객체
  • 캐싱해야 하는 객체
  • 키나 식별자로 사용되는 객체
  • 생성 이후 변경이 거의 없는 객체

가변 객체가 적합할 수 있는 경우

  • 객체의 핵심 역할이 상태 변화인 경우
  • 데이터가 매우 크고 일부 값이 자주 변경되는 경우
  • 반복적인 수정 작업에서 새 객체 생성 비용이 큰 경우
  • 생명주기와 상태 전이가 중요한 엔티티
  • 성능 측정 결과 불변 객체 생성이 실제 병목인 경우

핵심 정리

  • 불변 객체는 기존 값을 변경하지 않고 새로운 결과 객체를 반환한다.
  • 동일한 입력과 동일한 객체 상태에서는 동일한 결과를 얻을 수 있다.
  • 상태 변경을 추적할 필요가 없어 프로그램이 단순해진다.
  • 불변 객체는 근본적으로 스레드 안전하다.
  • 여러 스레드와 여러 객체가 하나의 불변 인스턴스를 안전하게 공유할 수 있다.
  • 불변 객체끼리는 조건에 따라 내부 데이터도 공유할 수 있다.
  • 컬렉션을 완전히 불변으로 만들려면 내부 요소도 불변이어야 한다.
  • 불변 객체는 연산 실패 시 기존 상태가 유지되는 실패 원자성을 제공한다.
  • 값이 변경될 때마다 새로운 객체를 만들어야 한다는 단점이 있다.
  • 중간 객체가 많아지면 다단계 연산이나 가변 동반 클래스로 보완할 수 있다.
  • 대표적인 가변 동반 클래스는 StringBuilder다.
  • 특별한 이유가 없다면 값 객체는 불변으로 설계하는 것이 좋다.

한 줄 정리

불변 객체는 상태 변경을 제거해 프로그램을 단순하고 안전하게 만들지만, 반복적인 변경으로 객체 생성 비용이 커질 때는 다단계 연산이나 가변 동반 클래스로 보완해야 한다.

아이템 17. 핵심 정리 - 불변 클래스 만들 때 고려할 것

아이템 17. 변경 가능성을 최소화하라: 불변 클래스 설계의 고급 기법

불변 클래스를 만드는 기본 규칙은 비교적 명확하다.

  • 상태를 변경하는 메서드를 제공하지 않는다.
  • 클래스를 확장할 수 없도록 한다.
  • 모든 필드를 가능한 한 final로 만든다.
  • 모든 필드를 private으로 만든다.
  • 내부의 가변 객체를 외부에 노출하지 않는다.

이번에는 여기에서 한 단계 더 나아가, 불변 클래스를 더 유연하게 설계하는 방법을 살펴본다.

핵심은 다음 세 가지다.

  • final 클래스 대신 생성자 접근을 제한하는 방법
  • 상속 가능한 불변 클래스를 안전하게 사용하는 방법
  • 모든 필드를 반드시 final로 만들지 않아도 되는 예외

상속을 막는 가장 단순한 방법은 final 클래스다

불변 클래스는 일반적으로 상속을 허용하면 안 된다.

상위 클래스가 불변으로 설계되어 있더라도 하위 클래스가 가변 상태를 추가할 수 있기 때문이다.

public class Complex {

    private final double re;
    private final double im;

    public Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
}

이 클래스는 필드가 모두 final이고 setter도 없지만 상속이 가능하다.

public class MutableComplex extends Complex {

    private double extra;

    public MutableComplex(
            double re,
            double im,
            double extra
    ) {
        super(re, im);
        this.extra = extra;
    }

    public void setExtra(double extra) {
        this.extra = extra;
    }
}

MutableComplexComplex 타입으로 사용할 수 있지만 가변 객체다.

Complex complex =
        new MutableComplex(1.0, 2.0, 3.0);

클라이언트는 Complex 타입만 보고 객체가 불변이라고 생각할 수 있지만 실제 객체는 그렇지 않을 수 있다.

이 문제를 가장 간단하게 막는 방법은 클래스에 final을 붙이는 것이다.

public final class Complex {
}

이제 어떤 클래스도 Complex를 상속할 수 없다.


생성자 접근을 제한해 상속을 막을 수 있다

불변 클래스를 반드시 final로만 만들어야 하는 것은 아니다.

생성자의 접근 범위를 제한해 외부 상속을 막을 수도 있다.

public class Complex {

    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
}

하위 클래스의 생성자는 반드시 상위 클래스의 생성자를 호출해야 한다.

하지만 상위 클래스 생성자가 private이면 외부 클래스에서는 호출할 수 없다.

따라서 외부 상속도 불가능해진다.


private 생성자

생성자가 private이면 해당 클래스 내부에서만 호출할 수 있다.

private Complex(double re, double im) {
    this.re = re;
    this.im = im;
}

외부 클래스는 생성자를 호출할 수 없고 상속도 할 수 없다.

다만 클래스 내부에 선언된 중첩 클래스는 private 생성자에 접근할 수 있다.

public class Complex {

    private Complex(double re, double im) {
    }

    private static final class SpecialComplex
            extends Complex {

        private SpecialComplex(
                double re,
                double im
        ) {
            super(re, im);
        }
    }
}

즉 외부 상속은 막으면서 내부 구현에서는 필요에 따라 하위 클래스를 만들 수 있다.


package-private 생성자

생성자에 접근 제어자를 작성하지 않으면 package-private이 된다.

Complex(double re, double im) {
    this.re = re;
    this.im = im;
}

이 경우 같은 패키지에 있는 클래스만 상속할 수 있다.

같은 패키지
 ├─ 상속 가능
 └─ 생성자 호출 가능

다른 패키지
 ├─ 상속 불가
 └─ 생성자 호출 불가

외부 사용자에게는 상속을 막으면서 라이브러리 내부에서는 구현을 확장할 수 있다.


private 생성자와 정적 팩터리 메서드

생성자를 비공개로 만들면 외부에서는 직접 인스턴스를 생성할 수 없다.

new Complex(1.0, 2.0);

따라서 public 정적 팩터리 메서드를 제공한다.

public class Complex {

    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }

    public static Complex valueOf(
            double re,
            double im
    ) {
        return new Complex(re, im);
    }
}

클라이언트는 다음과 같이 사용한다.

Complex complex =
        Complex.valueOf(1.0, 2.0);

생성자를 직접 노출하지 않고 객체 생성 과정을 클래스가 통제한다.


정적 팩터리는 구현체를 숨길 수 있다

정적 팩터리 메서드의 중요한 장점은 반환할 구현체를 자유롭게 선택할 수 있다는 점이다.

public static Complex valueOf(
        double re,
        double im
) {
    return new Complex(re, im);
}

내부 구현을 나중에 다른 하위 클래스로 바꿀 수도 있다.

public static Complex valueOf(
        double re,
        double im
) {
    return new CartesianComplex(re, im);
}

클라이언트 코드는 바뀌지 않는다.

Complex complex =
        Complex.valueOf(1.0, 2.0);

클라이언트가 의존하는 것은 구체적인 구현 클래스가 아니라 Complex라는 반환 타입뿐이다.


구현 교체가 가능한 구조

클라이언트
   ↓
Complex.valueOf(...)
   ↓
Complex 또는 내부 하위 구현체 반환

클라이언트는 실제로 어떤 구현체가 반환되는지 알 필요가 없다.

이 방식은 다음과 같은 장점을 제공한다.

  • 구현 클래스를 숨길 수 있다.
  • 내부 구현을 교체할 수 있다.
  • 성능에 따라 다른 구현체를 반환할 수 있다.
  • 입력값에 따라 서로 다른 구현체를 반환할 수 있다.
  • API를 유지하면서 내부 구조를 변경할 수 있다.

정적 팩터리는 인스턴스를 캐싱할 수 있다

불변 객체는 상태가 바뀌지 않으므로 동일한 값을 가진 인스턴스를 재사용할 수 있다.

예를 들어 자주 사용하는 복소수를 상수로 만들어둘 수 있다.

public class Complex {

    public static final Complex ZERO =
            new Complex(0.0, 0.0);

    public static final Complex ONE =
            new Complex(1.0, 0.0);

    public static final Complex I =
            new Complex(0.0, 1.0);

    private final double re;
    private final double im;

    private Complex(double re, double im) {
        this.re = re;
        this.im = im;
    }
}

정적 팩터리에서 캐시된 값을 반환할 수 있다.

public static Complex valueOf(
        double re,
        double im
) {
    if (re == 0.0 && im == 0.0) {
        return ZERO;
    }

    if (re == 1.0 && im == 0.0) {
        return ONE;
    }

    if (re == 0.0 && im == 1.0) {
        return I;
    }

    return new Complex(re, im);
}

사용자는 매번 새로운 객체가 만들어지는지, 기존 객체가 재사용되는지 알 필요가 없다.

Complex first =
        Complex.valueOf(0.0, 0.0);

Complex second =
        Complex.valueOf(0.0, 0.0);

두 변수는 동일한 인스턴스를 참조할 수도 있다.


사실상 final 클래스

클래스 선언에 final이 없더라도 외부에서 상속할 수 없다면 사실상 final처럼 동작한다.

public class Complex {

    private Complex(double re, double im) {
    }
}

클래스 자체는 final이 아니지만 외부에서는 생성자에 접근할 수 없어 상속할 수 없다.

이런 클래스를 사실상 final인 클래스라고 표현할 수 있다.

다만 자바 언어에서 지역 변수에 사용하는 effectively final과는 문맥이 다르다.

여기서 중요한 의미는 다음과 같다.

외부 사용자 관점에서는 상속할 수 없는 클래스

클래스 내부에서는 필요에 따라 하위 구현체를 만들 수 있다.


final 클래스와 생성자 제한 방식 비교

구분final 클래스생성자 접근 제한
외부 상속불가능불가능
내부 상속불가능가능
구현체 교체어려움가능
정적 팩터리 활용선택적사실상 필수
인스턴스 캐싱가능가능
설계 유연성낮음높음
구현 단순성높음상대적으로 낮음

단순한 값 객체라면 final 클래스로도 충분하다.

내부 구현 교체나 캐싱, 여러 구현체 제공 가능성이 있다면 생성자를 제한하고 정적 팩터리를 제공하는 방식이 더 유연하다.


상속 가능한 불변 클래스는 주의해야 한다

모든 불변 클래스가 상속을 막고 있는 것은 아니다.

대표적인 예가 BigIntegerBigDecimal이다.

이 클래스들은 불변 객체로 설계되어 있지만 final 클래스가 아니며, 외부에서 접근 가능한 생성자도 가지고 있다.

따라서 하위 클래스를 만들 수 있다.

public class MutableBigInteger
        extends BigInteger {

    private int mutableState;

    public MutableBigInteger(String value) {
        super(value);
    }

    public void changeState(int value) {
        mutableState = value;
    }
}

BigInteger 타입으로 전달받았다고 해서 실제 객체가 반드시 표준 BigInteger라고 보장할 수 없다.

BigInteger number =
        new MutableBigInteger("100");

이 객체는 하위 클래스가 추가한 가변 상태를 가질 수 있다.

또한 하위 클래스가 상위 클래스의 메서드를 재정의해 예상하지 못한 동작을 만들 수도 있다.


신뢰할 수 없는 하위 타입은 방어적으로 복사한다

상속 가능한 불변 클래스를 외부에서 전달받는다면 실제 타입을 확인한 뒤 복사할 수 있다.

public static BigInteger safeCopy(
        BigInteger value
) {
    if (value.getClass() == BigInteger.class) {
        return value;
    }

    return new BigInteger(value.toByteArray());
}

실제 클래스가 정확히 BigInteger라면 이미 안전한 불변 객체이므로 그대로 사용할 수 있다.

value.getClass() == BigInteger.class

하위 클래스라면 표준 BigInteger 인스턴스로 다시 만든다.

new BigInteger(value.toByteArray());

이제 호출자가 제공한 하위 타입의 동작이나 가변 상태에 영향을 받지 않는다.


instanceof만으로는 충분하지 않다

다음 조건은 하위 클래스도 통과한다.

value instanceof BigInteger

따라서 정확한 구현 타입을 확인하려면 getClass() 비교를 사용할 수 있다.

value.getClass() == BigInteger.class

다만 이런 방어적 복사는 객체 타입과 API 특성을 정확히 이해한 뒤 사용해야 한다.


하위 클래스의 재정의된 메서드도 위험할 수 있다

문제는 하위 클래스의 가변 필드만이 아니다.

하위 클래스가 메서드를 재정의할 수 있다면 기존 불변 클래스의 계약과 다른 동작을 만들 수 있다.

public class StrangeBigInteger
        extends BigInteger {

    public StrangeBigInteger(String value) {
        super(value);
    }

    @Override
    public byte[] toByteArray() {
        throw new UnsupportedOperationException();
    }
}

클라이언트가 BigInteger의 일반적인 동작을 기대해도 하위 클래스가 이를 깨뜨릴 수 있다.

따라서 외부에서 전달받은 상속 가능한 값 객체를 장기간 저장하거나 신뢰해야 한다면 표준 타입으로 복사하는 것이 안전할 수 있다.


모든 필드가 반드시 final일 필요는 없다

불변 클래스를 만드는 기본 규칙 중 하나는 모든 필드를 final로 선언하는 것이다.

private final String number;

하지만 내부 구현을 위한 일부 필드는 반드시 final일 필요가 없다.

중요한 조건은 다음과 같다.

  • 외부에서 관찰 가능한 논리적 상태를 변경하지 않아야 한다.
  • 외부에 공개되지 않아야 한다.
  • 계산 결과가 항상 같아야 한다.
  • 캐시 값이 변경되어도 객체의 값 의미는 달라지지 않아야 한다.

대표적인 예가 지연 초기화된 캐시 필드다.


해시 코드 지연 계산

불변 객체의 해시 코드는 매번 같은 결과를 반환한다.

하지만 해시 코드 계산 비용이 크다면 매번 다시 계산하는 것은 비효율적이다.

public final class PhoneNumber {

    private final short areaCode;
    private final short prefix;
    private final short lineNum;

    private int hashCode;

    public PhoneNumber(
            int areaCode,
            int prefix,
            int lineNum
    ) {
        this.areaCode = rangeCheck(
                areaCode,
                999,
                "지역 코드"
        );

        this.prefix = rangeCheck(
                prefix,
                999,
                "프리픽스"
        );

        this.lineNum = rangeCheck(
                lineNum,
                9999,
                "가입자 번호"
        );
    }
}

hashCode 필드는 final이 아니다.

생성 시점에는 계산하지 않고 실제로 필요할 때 계산한다.

@Override
public int hashCode() {
    int result = hashCode;

    if (result == 0) {
        result = Short.hashCode(areaCode);
        result = 31 * result
                + Short.hashCode(prefix);
        result = 31 * result
                + Short.hashCode(lineNum);

        hashCode = result;
    }

    return result;
}

첫 번째 호출에서만 계산하고 이후에는 저장된 값을 재사용한다.


지연 초기화 캐시가 불변성을 깨지 않는 이유

hashCode 필드 값은 변경된다.

초기값 0
   ↓
계산된 해시 코드

그럼에도 PhoneNumber는 논리적으로 불변이다.

이유는 hashCode가 객체의 본질적인 상태가 아니라 파생된 캐시 값이기 때문이다.

객체의 의미를 결정하는 값은 다음과 같다.

areaCode
prefix
lineNum

hashCode는 이 값들을 이용해 계산한 결과일 뿐이다.

값이 캐시에 저장되든 저장되지 않든 객체가 나타내는 전화번호는 달라지지 않는다.


물리적 상태와 논리적 상태

불변성을 이해할 때는 물리적 상태와 논리적 상태를 구분하는 것이 중요하다.

물리적 상태

객체 내부의 실제 메모리 값이다.

hashCode 필드가 0에서 계산값으로 변경됨

논리적 상태

객체가 외부에 표현하는 의미다.

전화번호 02-1234-5678

캐시 필드는 물리적으로 변경되지만 논리적인 값은 변하지 않는다.

따라서 이런 객체를 논리적 불변 객체로 볼 수 있다.


캐시 필드는 외부에 노출하면 안 된다

지연 계산에 사용하는 캐시 필드는 반드시 내부 구현으로 감춰야 한다.

private int hashCode;

다음과 같이 공개하면 안 된다.

public int hashCodeCache;

외부 코드가 캐시 값을 변경할 수 있고 내부 구현에도 직접 의존하게 된다.

phoneNumber.hashCodeCache = 100;

캐시 필드는 객체의 내부 최적화 수단이어야 한다.


캐시 필드 사용 시 주의할 점

지연 초기화 캐시를 사용할 때는 몇 가지 주의할 점이 있다.

계산 결과가 항상 같아야 한다

같은 객체 상태에서는 항상 동일한 결과가 나와야 한다.

hashCode(areaCode, prefix, lineNum)

시간, 외부 시스템 상태, 난수처럼 변할 수 있는 값에 의존하면 안 된다.


캐시 값이 객체의 의미를 바꾸면 안 된다

캐시는 결과 계산을 빠르게 하기 위한 보조 데이터여야 한다.

캐시 값이 달라졌다고 객체의 equals() 결과나 핵심 동작이 달라지면 안 된다.


멀티스레드 환경을 고려해야 한다

불변 객체는 여러 스레드에서 공유될 수 있다.

따라서 지연 초기화 필드도 경쟁 상태를 고려해야 한다.

다만 해시 코드처럼 여러 스레드가 같은 값을 중복 계산하는 것이 허용되는 경우라면 반드시 동기화할 필요는 없을 수 있다.

Thread A가 계산
Thread B도 같은 값 계산
두 스레드가 같은 결과 저장

결과가 항상 동일하고 중복 계산이 안전하다면 논리적 오류는 발생하지 않는다.

하지만 객체 생성 비용이 크거나 초기화 로직에 부작용이 있다면 동기화 또는 다른 초기화 전략을 검토해야 한다.


0을 초기값으로 사용할 때의 주의점

해시 코드 캐시에서 0을 미계산 상태로 사용하는 경우 실제 해시 코드가 0일 수 있다는 점을 고려해야 한다.

if (result == 0) {
    result = calculateHashCode();
    hashCode = result;
}

실제 결과가 0이면 호출할 때마다 다시 계산한다.

정확성 문제는 없지만 캐싱 효과가 사라질 수 있다.

이를 명확하게 구분하려면 계산 여부 필드를 별도로 둘 수 있다.

private int hashCode;
private boolean hashCodeCalculated;
@Override
public int hashCode() {
    if (!hashCodeCalculated) {
        hashCode = calculateHashCode();
        hashCodeCalculated = true;
    }

    return hashCode;
}

다만 필드가 늘어나므로 실제 계산 비용과 복잡도를 고려해 선택해야 한다.


record와 불변 설계

단순한 값 객체는 record로 표현할 수 있다.

public record Complex(
        double real,
        double imaginary
) {

    public Complex plus(Complex other) {
        return new Complex(
                real + other.real,
                imaginary + other.imaginary
        );
    }
}

레코드는 구성 요소를 private final 필드로 관리하고 접근자도 자동으로 제공한다.

그러나 레코드라고 해서 내부 값까지 모두 자동으로 불변이 되는 것은 아니다.

public record Person(
        String name,
        List<String> roles
) {
}

roles가 가변 리스트라면 내부 상태가 변경될 수 있다.

person.roles().add("ADMIN");

따라서 생성자에서 복사해야 한다.

public record Person(
        String name,
        List<String> roles
) {

    public Person {
        roles = List.copyOf(roles);
    }
}

고급 설계 기법을 사용하더라도 가변 컴포넌트 보호 원칙은 그대로 적용된다.


실무 설계 전략

불변 클래스를 설계할 때는 다음 순서로 판단할 수 있다.

단순한 값 객체

final 클래스로 만들고 생성자를 공개한다.

public final class Money {
}

구조가 단순하고 별도의 구현 교체 가능성이 없다면 가장 이해하기 쉬운 방식이다.


구현 은닉과 캐싱이 필요한 값 객체

생성자를 private으로 만들고 정적 팩터리를 제공한다.

public static Money valueOf(
        BigDecimal amount
) {
    return new Money(amount);
}

다음 기능이 필요할 때 적합하다.

  • 인스턴스 캐싱
  • 구현체 교체
  • 입력값에 따른 하위 타입 선택
  • 생성 과정 검증
  • 이름 있는 생성 방식

외부에서 상속 가능한 불변 타입을 받는 경우

실제 타입을 신뢰할 수 없다면 방어적 복사를 고려한다.

BigInteger copy =
        new BigInteger(value.toByteArray());

특히 전달받은 객체를 내부 필드에 장기간 저장하거나 보안상 중요한 값으로 사용하는 경우 주의해야 한다.


계산 비용이 큰 파생 값

외부에 노출되지 않는 non-final 필드에 지연 계산 결과를 캐싱할 수 있다.

private int hashCode;

다만 캐시 변경이 논리적 상태를 바꾸지 않아야 한다.


불변 클래스 설계 체크리스트

상속 제어

  • 외부에서 상속할 필요가 있는가?
  • 단순히 final로 막는 것이 적절한가?
  • 내부 구현 확장이 필요하다면 생성자를 제한할 수 있는가?
  • 정적 팩터리로 구현체를 감출 수 있는가?

인스턴스 생성

  • 자주 사용하는 값을 캐싱할 수 있는가?
  • 항상 새로운 인스턴스를 만들 필요가 있는가?
  • 입력값에 따라 다른 구현체를 반환할 필요가 있는가?

외부 타입 신뢰

  • 전달받은 객체가 상속 가능한 타입인가?
  • 하위 클래스가 가변일 가능성이 있는가?
  • 메서드가 재정의되어 있을 가능성이 있는가?
  • 안전을 위해 표준 타입으로 복사해야 하는가?

필드 설계

  • 논리적 상태를 표현하는 필드는 모두 private final인가?
  • 가변 컴포넌트가 외부에 노출되지 않는가?
  • non-final 필드는 단순한 캐시나 파생 값인가?
  • 캐시 변경이 외부 동작을 바꾸지 않는가?

핵심 정리

  • 불변 클래스의 상속을 막는 가장 간단한 방법은 클래스를 final로 만드는 것이다.
  • 생성자를 private 또는 package-private으로 제한해 외부 상속을 막을 수도 있다.
  • 생성자를 숨기면 정적 팩터리 메서드를 통해 객체를 생성해야 한다.
  • 정적 팩터리는 실제 구현체를 감추고 내부 구현을 교체할 수 있다.
  • 불변 객체는 동일한 인스턴스를 캐싱하고 재사용할 수 있다.
  • 클래스가 final이 아니더라도 외부에서 상속할 수 없다면 사실상 final처럼 동작한다.
  • 상속 가능한 불변 타입은 하위 클래스가 가변이거나 메서드를 재정의할 수 있으므로 주의해야 한다.
  • 신뢰할 수 없는 하위 타입은 표준 불변 타입으로 방어적 복사할 수 있다.
  • 불변 클래스의 모든 필드가 물리적으로 반드시 final일 필요는 없다.
  • 해시 코드처럼 객체의 논리적 상태를 바꾸지 않는 파생 값은 non-final 필드에 캐싱할 수 있다.
  • 캐시 필드는 반드시 외부에 노출하지 않아야 한다.
  • non-final 필드를 허용하더라도 외부에서 관찰 가능한 객체의 값은 변하지 않아야 한다.

한 줄 정리

불변 클래스는 final 선언뿐 아니라 제한된 생성자와 정적 팩터리로 더 유연하게 설계할 수 있으며, 외부에서 관찰되는 논리적 상태만 변하지 않는다면 내부 캐시를 위한 non-final 필드도 사용할 수 있다.

아이템 17. 완벽 공략 요약

아이템 17 완벽 공략: final 필드, 방어적 복사와 CountDownLatch

불변 객체는 단순히 setter가 없고 필드가 final이라는 이유만으로 완성되는 것이 아니다.

객체가 다른 스레드에 전달되는 과정, 내부에 가변 객체를 포함하는 경우, 직렬화와 역직렬화를 수행하는 경우까지 고려해야 실제로 안전한 불변 객체를 만들 수 있다.

이번에는 아이템 17에서 추가로 확인해야 할 다음 주제들을 살펴본다.

  • 새로 생성한 객체를 다른 스레드에 안전하게 전달하는 원리
  • 자바 메모리 모델과 final 필드의 관계
  • 가변 필드를 가진 불변 객체의 방어적 복사
  • 직렬화 과정에서 불변성이 깨질 수 있는 이유
  • java.util.concurrent 패키지의 역할
  • CountDownLatch가 변경 가능성을 최소화하는 방법

불변 객체는 다른 스레드에 안전하게 전달할 수 있다

불변 객체의 중요한 장점 중 하나는 생성이 완료된 객체를 별도의 동기화 없이 다른 스레드에 전달할 수 있다는 점이다.

예를 들어 다음과 같은 불변 클래스가 있다고 가정해보자.

public final class UserInfo {

    private final String name;
    private final int age;

    public UserInfo(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String name() {
        return name;
    }

    public int age() {
        return age;
    }
}

객체를 생성한 뒤 다른 스레드에서 사용한다.

UserInfo userInfo =
        new UserInfo("Yoon", 32);

Thread thread = new Thread(() -> {
    System.out.println(userInfo.name());
    System.out.println(userInfo.age());
});

thread.start();

UserInfo가 올바르게 설계된 불변 객체라면 다른 스레드에서도 생성자에서 설정된 값을 안전하게 읽을 수 있다.

별도의 setter가 없기 때문에 값이 변경될 가능성도 없다.


단순히 값이 바뀌지 않는 것만의 문제는 아니다

불변 객체가 스레드 안전하다고 말할 때는 두 가지 측면을 생각해야 한다.

생성 이후 상태 변경

객체가 생성된 이후 내부 값이 변경되지 않아야 한다.

private final String name;
private final int age;

생성 시점의 값이 다른 스레드에 올바르게 보여야 함

생성자에서 설정한 값이 다른 스레드에서도 정확하게 보여야 한다.

this.name = name;
this.age = age;

멀티스레드 환경에서는 한 스레드가 쓴 값이 다른 스레드에 즉시 보이지 않을 수 있다.

이를 메모리 가시성 문제라고 한다.

자바는 final 필드에 대해 일반 필드보다 강한 초기화 보장을 제공한다.


자바 메모리 모델이 필요한 이유

개발자가 작성한 코드는 반드시 작성된 순서 그대로 CPU에서 실행되는 것은 아니다.

컴파일러, JVM, CPU는 성능 향상을 위해 명령어 실행 순서를 재배치할 수 있다.

예를 들어 개발자는 다음과 같이 코드를 작성한다.

UserInfo userInfo =
        new UserInfo("Yoon", 32);

개념적으로는 다음 순서처럼 보인다.

메모리 할당
생성자 실행
필드 초기화
참조 변수에 객체 주소 저장

하지만 최적화 과정에서는 일부 명령이 재배치될 가능성이 있다.

멀티스레드 환경에서는 생성이 완전히 끝나지 않은 객체의 참조가 다른 스레드에 먼저 보이는 문제가 발생할 수도 있다.


안전하지 않은 객체 공개

다음과 같은 가변 클래스가 있다고 가정해보자.

public class Holder {

    private int value;

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

    public int getValue() {
        return value;
    }
}

한 스레드에서 객체를 만들고 공유 변수에 저장한다.

sharedHolder = new Holder(100);

다른 스레드에서는 공유 변수를 읽는다.

if (sharedHolder != null) {
    System.out.println(sharedHolder.getValue());
}

동기화 없이 객체를 공개하면 이론적으로 다른 스레드가 참조는 발견했지만 필드 초기화 결과는 제대로 보지 못하는 상황을 고려해야 한다.

이러한 문제를 해결하기 위해 다음과 같은 안전한 공개 방법이 사용된다.

  • synchronized
  • volatile
  • 스레드 안전한 컬렉션
  • 클래스 초기화 과정
  • 스레드 시작 전 전달
  • final 필드의 초기화 보장

final 필드의 초기화 안전성

자바 메모리 모델은 생성자가 정상적으로 완료된 객체의 final 필드에 특별한 보장을 제공한다.

public final class UserInfo {

    private final String name;
    private final int age;

    public UserInfo(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

객체 생성 과정에서 this 참조가 외부로 유출되지 않았다면, 다른 스레드는 객체를 전달받았을 때 생성자에서 설정한 final 필드 값을 정상적으로 관찰할 수 있다.

이를 흔히 final 필드의 안전한 초기화 보장이라고 설명한다.


생성자에서 this를 외부에 노출하면 안 된다

final 필드가 있다고 해서 어떤 방식으로 객체를 생성해도 안전한 것은 아니다.

생성자가 끝나기 전에 this를 외부로 공개하면 안 된다.

public final class UnsafeListener {

    private final int value;

    public UnsafeListener(EventSource source) {
        source.register(this);
        this.value = 100;
    }
}

source.register(this)를 통해 생성 중인 객체가 외부에 전달된다.

다른 스레드가 이 객체를 사용하면 value가 초기화되기 전 상태를 볼 가능성을 고려해야 한다.

안전하게 만들려면 객체 생성이 끝난 후 등록해야 한다.

public final class SafeListener {

    private final int value;

    private SafeListener() {
        this.value = 100;
    }

    public static SafeListener create(
            EventSource source
    ) {
        SafeListener listener =
                new SafeListener();

        source.register(listener);

        return listener;
    }
}

객체가 완전히 초기화된 이후 외부에 공개된다.


JLS 17.4와 17.5

자바 언어 명세에서 멀티스레드와 메모리 가시성은 자바 메모리 모델 부분에서 다룬다.

JLS 17.4

주로 다음 개념들을 다룬다.

  • 메모리 모델
  • 스레드 간 메모리 가시성
  • 명령어 재배치
  • 데이터 경쟁
  • happens-before 관계
  • 동기화 순서
  • volatile
  • 잠금과 모니터

JLS 17.5

주로 final 필드의 의미와 초기화 안전성을 다룬다.

  • 생성자에서 설정된 final 필드의 가시성
  • 생성 중인 객체의 참조가 외부로 유출될 때의 문제
  • final 참조가 가리키는 객체 상태와의 관계
  • 역직렬화와 리플렉션 같은 특수 상황

불변 객체가 왜 별도 동기화 없이 공유될 수 있는지 이해하려면 이 두 부분을 함께 살펴봐야 한다.


final 참조와 참조 대상의 상태는 다르다

다음 필드를 생각해보자.

private final Address address;

final이 보장하는 것은 address 참조가 다른 객체를 가리키도록 바뀌지 않는다는 것이다.

this.address = anotherAddress;

위와 같은 재대입은 불가능하다.

하지만 Address 자체가 가변이라면 내부 값은 변경될 수 있다.

address.setCity("Seoul");

즉 다음 두 개념을 구분해야 한다.

참조 불변
객체 상태 불변

final 참조만으로 참조 대상 객체까지 불변이 되는 것은 아니다.


불변 객체가 가변 객체를 포함할 수 있다

불변 클래스 내부에 가변 객체를 저장하는 것 자체가 항상 금지되는 것은 아니다.

다만 외부 코드가 그 가변 객체를 변경할 수 없도록 철저하게 차단해야 한다.

public final class Person {

    private final String name;
    private final Address address;

    public Person(
            String name,
            Address address
    ) {
        this.name = name;
        this.address = address;
    }
}

Address가 가변 객체라면 이 코드는 안전하지 않다.


생성자에서 방어적 복사가 필요하다

외부에서 전달받은 가변 객체를 그대로 저장하면 호출자가 나중에 객체를 변경할 수 있다.

Address address =
        new Address("Seoul", "Gangnam");

Person person =
        new Person("Yoon", address);

address.setCity("Busan");

Person이 가진 주소도 함께 바뀐다.

불변성을 유지하려면 생성자에서 복사해야 한다.

public Person(
        String name,
        Address address
) {
    this.name = name;
    this.address = new Address(
            address.getCity(),
            address.getStreet()
    );
}

외부에서 전달된 객체와 내부에 저장된 객체가 분리된다.


접근자에서도 방어적 복사가 필요하다

생성자에서 복사했더라도 getter가 내부 객체를 그대로 반환하면 다시 불변성이 깨진다.

public Address getAddress() {
    return address;
}

외부 코드는 다음과 같이 내부 상태를 변경할 수 있다.

person.getAddress()
        .setCity("Busan");

따라서 접근자에서도 복사본을 반환해야 한다.

public Address getAddress() {
    return new Address(
            address.getCity(),
            address.getStreet()
    );
}

생성자와 접근자 모두 보호해야 한다

가변 객체를 가진 불변 클래스를 설계할 때는 입구와 출구를 모두 막아야 한다.

외부 객체 입력
    ↓
생성자에서 복사
    ↓
내부에 독립된 객체 저장
    ↓
getter 호출
    ↓
복사본 반환

생성자만 복사하고 getter에서 그대로 반환하거나, getter에서만 복사하고 생성자에서 그대로 저장하면 불변성이 깨질 수 있다.


배열도 대표적인 가변 객체다

배열은 내용이 변경될 수 있기 때문에 방어적 복사가 필요하다.

public final class Password {

    private final char[] value;

    public Password(char[] value) {
        this.value = value;
    }

    public char[] getValue() {
        return value;
    }
}

이 클래스는 불변처럼 보이지만 외부에서 값을 변경할 수 있다.

char[] chars = {'1', '2', '3', '4'};

Password password =
        new Password(chars);

chars[0] = '9';

또한 getter를 통해서도 내부 배열을 변경할 수 있다.

password.getValue()[0] = '9';

생성자와 접근자 모두 복사해야 한다.

public final class Password {

    private final char[] value;

    public Password(char[] value) {
        this.value = value.clone();
    }

    public char[] getValue() {
        return value.clone();
    }
}

컬렉션은 불변 복사본을 사용할 수 있다

컬렉션도 가변 객체다.

public final class Team {

    private final List<String> members;

    public Team(List<String> members) {
        this.members = members;
    }

    public List<String> getMembers() {
        return members;
    }
}

외부에서 원본 리스트나 getter로 반환받은 리스트를 변경할 수 있다.

members.add("new member");
team.getMembers().clear();

Java에서는 List.copyOf()를 활용할 수 있다.

public final class Team {

    private final List<String> members;

    public Team(List<String> members) {
        this.members = List.copyOf(members);
    }

    public List<String> getMembers() {
        return members;
    }
}

List.copyOf()가 반환한 리스트는 구조적으로 변경할 수 없다.

다만 내부 요소가 가변 객체라면 요소까지 자동으로 복사되지는 않는다.


방어적 복사는 깊이를 고려해야 한다

다음과 같은 리스트를 생각해보자.

List<Address> addresses;

단순히 리스트만 복사하면 리스트 구조는 보호할 수 있지만 내부 Address 객체는 공유된다.

this.addresses =
        List.copyOf(addresses);

외부에서 Address의 상태를 변경하면 내부 논리적 상태도 바뀔 수 있다.

addresses.get(0)
        .setCity("Busan");

깊은 불변성이 필요하다면 요소도 복사하거나 요소 자체를 불변으로 만들어야 한다.

this.addresses = addresses.stream()
        .map(Address::copy)
        .toList();

가장 좋은 방법은 가능하다면 Address 자체를 불변 객체로 설계하는 것이다.


직렬화 과정에서도 불변성이 깨질 수 있다

불변 클래스가 Serializable을 구현한다면 생성자를 통한 일반 객체 생성 경로만 고려해서는 안 된다.

역직렬화는 객체를 바이트 데이터에서 다시 복원하는 과정이다.

ObjectInputStream input =
        new ObjectInputStream(stream);

Person person =
        (Person) input.readObject();

일반적인 생성자 호출과는 다른 방식으로 객체가 복원될 수 있기 때문에 가변 필드가 외부 객체와 공유되는 문제를 주의해야 한다.


readObject란?

readObject()는 자바 직렬화 과정에서 역직렬화 동작을 제어할 수 있는 특별한 메서드다.

개념적인 형태는 다음과 같다.

private void readObject(
        ObjectInputStream stream
) throws IOException,
         ClassNotFoundException {

    stream.defaultReadObject();
}

직렬화된 데이터를 읽어 객체 상태를 복원하는 과정에 추가 검증이나 복사 로직을 넣을 수 있다.


역직렬화는 또 하나의 생성자다

역직렬화는 기존 바이트 데이터를 바탕으로 새로운 인스턴스를 만든다.

따라서 보안과 불변성 관점에서는 사실상 또 하나의 public 생성자와 비슷하게 봐야 한다.

일반 생성자
외부 데이터 → 객체 생성

역직렬화
직렬화 데이터 → 객체 생성

일반 생성자에서 유효성 검증과 방어적 복사를 수행한다면 역직렬화에서도 같은 수준의 보호가 필요하다.


readObject에서도 검증이 필요하다

불변 객체가 잘못된 상태로 복원되는 것을 막으려면 역직렬화 후 유효성을 검사해야 한다.

private void readObject(
        ObjectInputStream stream
) throws IOException,
         ClassNotFoundException {

    stream.defaultReadObject();

    if (name == null) {
        throw new InvalidObjectException(
                "이름은 null일 수 없습니다."
        );
    }
}

다만 final 필드와 방어적 복사를 포함하는 복잡한 직렬화 설계는 별도의 주의가 필요하다.


readResolve의 역할

readResolve()는 역직렬화된 객체 대신 다른 객체를 반환하게 할 수 있다.

private Object readResolve() {
    return INSTANCE;
}

싱글턴이나 캐싱된 불변 객체의 동일성을 유지할 때 사용할 수 있다.

예를 들어 역직렬화할 때마다 새로운 객체가 생기는 대신 기존 상수를 반환하도록 만들 수 있다.

private Object readResolve() {
    if (re == 0.0 && im == 0.0) {
        return ZERO;
    }

    return this;
}

writeUnshared와 readUnshared

ObjectOutputStreamObjectInputStream은 객체 그래프의 참조 관계를 유지하기 위해 같은 객체를 여러 번 직렬화할 때 참조를 공유할 수 있다.

경우에 따라 이런 공유가 불변성이나 독립성에 영향을 줄 수 있다.

이를 피하기 위해 다음 메서드를 사용할 수 있다.

outputStream.writeUnshared(object);
Object object =
        inputStream.readUnshared();

이 메서드들은 객체를 공유된 참조가 아닌 독립된 객체처럼 다루도록 요청한다.

다만 자바 직렬화는 구조적으로 복잡하고 보안상 주의할 점이 많기 때문에, 불변 객체의 직렬화 문제는 직렬화 전용 설계 원칙과 함께 살펴봐야 한다.


불변 클래스를 만들 수 없는 경우

모든 클래스를 불변으로 만들 수 있는 것은 아니다.

객체가 본질적으로 상태 변화를 표현해야 하는 경우가 있다.

예를 들면 다음과 같다.

  • 카운터
  • 작업 진행 상태
  • 버퍼
  • 동기화 도구
  • 세션 상태
  • 트랜잭션 상태
  • 생명주기가 있는 엔티티

이런 객체는 가변성을 완전히 제거할 수 없다.

대신 변경 가능한 상태를 최소화해야 한다.


가변 클래스를 설계하는 원칙

불변으로 만들 수 없는 클래스라면 다음 원칙을 적용하는 것이 좋다.

  • 변경 가능한 필드 수를 최소화한다.
  • 필드는 가능한 한 private으로 만든다.
  • 변경되지 않는 필드는 final로 만든다.
  • 생성자가 유효한 완성 상태를 만든다.
  • 중간 상태가 외부에 노출되지 않게 한다.
  • 상태 변경 메서드를 제한한다.
  • 불변식을 항상 유지한다.
  • 스레드 안전성이 필요하면 동기화 정책을 명확히 한다.

java.util.concurrent 패키지

java.util.concurrent는 멀티스레드 프로그래밍을 지원하는 동시성 유틸리티 패키지다.

대표적으로 다음 기능을 제공한다.

분류대표 클래스
스레드 풀ExecutorService
비동기 작업Future, CompletableFuture
동시성 컬렉션ConcurrentHashMap, BlockingQueue
원자적 연산AtomicInteger, AtomicLong
잠금Lock, ReentrantLock
동기화 도구CountDownLatch, Semaphore, CyclicBarrier

직접 wait(), notify(), 저수준 락을 구현하는 대신 검증된 고수준 도구를 사용할 수 있다.


CountDownLatch란?

CountDownLatch는 하나 이상의 스레드가 다른 작업들의 완료를 기다리도록 만드는 동기화 도구다.

생성할 때 카운트 값을 지정한다.

CountDownLatch latch =
        new CountDownLatch(3);

작업이 완료될 때마다 countDown()을 호출한다.

latch.countDown();

기다리는 쪽에서는 await()를 호출한다.

latch.await();

카운트가 0이 될 때까지 대기한다.


CountDownLatch 사용 예시

세 개의 작업이 모두 끝난 뒤 다음 단계로 진행해야 한다고 가정해보자.

CountDownLatch latch =
        new CountDownLatch(3);

ExecutorService executor =
        Executors.newFixedThreadPool(3);

for (int i = 0; i < 3; i++) {
    executor.submit(() -> {
        try {
            doWork();
        } finally {
            latch.countDown();
        }
    });
}

latch.await();

System.out.println(
        "모든 작업이 완료되었습니다."
);

countDown()은 카운트를 하나 줄인다.

초기값: 3
작업 1 완료: 2
작업 2 완료: 1
작업 3 완료: 0

카운트가 0이 되면 await() 중인 스레드가 실행을 계속한다.


CountDownLatch는 불변 클래스가 아니다

CountDownLatch의 내부 상태는 변경된다.

3 → 2 → 1 → 0

따라서 불변 객체는 아니다.

하지만 변경 가능성을 매우 제한적으로 관리한다.

외부 사용자는 임의의 값으로 카운트를 바꿀 수 없다.

latch.setCount(100);

이런 메서드는 존재하지 않는다.

상태 변경은 오직 제한된 연산으로만 가능하다.

countDown()

상태 변경 방향이 한쪽으로 제한된다

CountDownLatch의 카운트는 감소만 가능하다.

3 → 2 → 1 → 0

다시 증가할 수 없다.

0 → 1

이런 변경은 허용되지 않는다.

가변 객체이지만 상태 전이 방향을 제한해 복잡성을 크게 줄인 것이다.


생성자는 완전히 초기화된 객체를 만든다

CountDownLatch는 생성 시점에 초기 카운트를 반드시 전달받는다.

CountDownLatch latch =
        new CountDownLatch(3);

생성 직후부터 바로 사용할 수 있다.

다음과 같이 불완전한 상태로 생성한 뒤 별도로 초기화하지 않는다.

CountDownLatch latch =
        new CountDownLatch();

latch.setCount(3);

이런 구조라면 초기화 전 객체가 외부에 노출될 수 있다.

생성자가 완전히 유효한 상태를 만들면 객체 생명주기 관리가 단순해진다.


CountDownLatch가 변경 가능성을 최소화하는 방법

CountDownLatch는 가변 객체지만 다음 원칙을 따른다.

상태를 외부에 직접 공개하지 않는다

내부 카운트는 private하게 관리된다.

임의 변경을 허용하지 않는다

카운트를 특정 값으로 설정하는 setter가 없다.

상태 전이를 제한한다

카운트는 감소하는 방향으로만 변경된다.

초기화가 생성자에서 완료된다

생성 시점에 유효한 초기 카운트를 제공해야 한다.

핵심 연산만 공개한다

사용자에게 제공되는 주요 동작은 단순하다.

await()
countDown()
getCount()

동시성 제어를 내부에 캡슐화한다

사용자는 내부 잠금이나 메모리 가시성 구현을 알 필요가 없다.


CountDownLatch의 상태 흐름

생성
  ↓
count = N
  ↓
countDown()
  ↓
count = N - 1
  ↓
반복
  ↓
count = 0
  ↓
await() 해제

카운트가 0에 도달한 뒤에는 다시 사용할 수 없다.

한 번만 사용할 수 있는 동기화 도구다.

반복적인 장벽이 필요하다면 CyclicBarrierPhaser 같은 다른 도구를 검토할 수 있다.


가변성을 제거할 수 없다면 통제해야 한다

CountDownLatch가 보여주는 핵심은 모든 클래스를 억지로 불변으로 만들라는 것이 아니다.

상태 변경이 반드시 필요하다면 다음 질문을 해야 한다.

변경 가능한 상태가 정말 필요한가?

변경 가능한 필드를 줄일 수 있는가?

상태 전이 방향을 제한할 수 있는가?

임의 setter를 제거할 수 있는가?

객체를 생성과 동시에 완전한 상태로 만들 수 있는가?

동기화 정책을 객체 내부에 감출 수 있는가?

가변성을 없애지 못하더라도 변경 범위를 줄이면 오류 가능성과 관리 비용을 낮출 수 있다.


실무에서 적용할 수 있는 설계 방향

값 객체

가능하면 완전한 불변 객체로 만든다.

Money
Email
PhoneNumber
Address
DateRange

컬렉션을 가진 값 객체

불변 복사본을 저장한다.

this.items =
        List.copyOf(items);

내부 요소도 가변이라면 깊은 복사를 고려한다.

가변 엔티티

setter를 무분별하게 제공하지 않고 의미 있는 상태 변경 메서드를 만든다.

order.pay();
order.cancel();
order.ship();

동시성 도구

상태 변경을 제한하고 동기화 구현을 내부에 캡슐화한다.

CountDownLatch
Semaphore
AtomicInteger

Serializable 불변 객체

일반 생성자뿐 아니라 역직렬화 경로도 객체 생성 경로로 간주한다.

생성자
정적 팩터리
복사 생성자
역직렬화
리플렉션

모든 경로에서 불변식이 유지되어야 한다.


핵심 정리

  • 불변 객체는 다른 스레드에 전달해도 안전하게 사용할 수 있다.
  • final 필드는 생성자에서 초기화된 값의 메모리 가시성과 관련된 특별한 보장을 가진다.
  • 생성자가 끝나기 전에 this를 외부에 노출하면 안 된다.
  • 자바 메모리 모델은 JLS 17.4에서, final 필드 관련 규칙은 JLS 17.5에서 주로 다룬다.
  • final 참조가 참조 대상 객체의 불변성을 보장하지는 않는다.
  • 불변 객체가 가변 필드를 포함한다면 생성자와 접근자에서 방어적 복사를 해야 한다.
  • 배열과 컬렉션도 가변 객체이므로 그대로 저장하거나 반환하면 안 된다.
  • 불변 클래스가 직렬화를 지원한다면 역직렬화 경로에서도 유효성 검증과 방어 조치가 필요하다.
  • readObject()readResolve()는 역직렬화 과정에서 객체의 상태와 동일성을 제어하는 데 사용된다.
  • 모든 클래스를 불변으로 만들 수는 없다.
  • 가변 클래스는 변경 가능한 상태를 최소화하고 상태 전이를 제한해야 한다.
  • CountDownLatch는 가변 객체지만 변경 방향과 공개 연산을 엄격하게 제한한다.
  • 생성자는 객체를 완전히 초기화된 유효한 상태로 만들어야 한다.

한 줄 정리

불변 객체는 자바 메모리 모델과 final 필드 보장을 통해 안전하게 공유할 수 있지만, 가변 컴포넌트와 역직렬화 경로까지 보호해야 하며, 불변으로 만들 수 없는 클래스는 CountDownLatch처럼 변경 가능한 상태와 상태 전이를 최소화해야 한다.

아이템 17. 완벽 공략 - final과 자바 메모리 모델(JMM)

final과 자바 메모리 모델

final 키워드는 일반적으로 다음과 같이 설명된다.

한 번 초기화한 변수에는 다른 값을 다시 대입할 수 없다.

final int number = 10;

// 컴파일 오류
number = 20;

참조 변수에 final을 사용하면 참조 대상도 바꿀 수 없다.

final Person person = new Person("Yoon");

// 컴파일 오류
person = new Person("Kim");

그러나 final의 역할은 단순히 재할당을 막는 데서 끝나지 않는다.

멀티스레드 환경에서는 final 필드가 객체 초기화의 안전성과 관련된 특별한 메모리 가시성 보장을 제공한다.

즉, 올바르게 사용된 final 필드는 다른 스레드가 객체를 읽을 때 생성자에서 설정한 값을 안정적으로 관찰할 수 있도록 돕는다.

이를 이해하려면 먼저 자바 메모리 모델이 무엇인지 알아야 한다.


참고한 Java Language Specification

이번 내용은 다음 두 문서를 기준으로 한다.

JLS 17.4는 자바 메모리 모델의 전반적인 규칙을 설명하고, JLS 17.5는 final 필드에 적용되는 특별한 규칙을 설명한다.


자바 메모리 모델은 JVM 메모리 구조가 아니다

자바 메모리 모델을 검색하면 다음과 같은 내용이 나오는 경우가 있다.

Heap
Stack
Method Area
PC Register
Native Method Stack

이것은 JVM의 런타임 데이터 영역 또는 JVM 메모리 구조에 관한 설명이다.

자바 메모리 모델, 즉 JMM(Java Memory Model)이 설명하는 핵심은 힙과 스택이 어떻게 나뉘는지가 아니다.

JMM은 다음과 같은 질문에 대한 규칙을 정의한다.

여러 스레드가 공유 변수를 읽고 쓸 때,
어떤 쓰기 결과를 어떤 읽기 연산이 볼 수 있는가?

컴파일러와 CPU는 연산 순서를 어느 범위까지 바꿀 수 있는가?

한 스레드의 변경 결과가 다른 스레드에 언제 보이는가?

어떤 멀티스레드 실행 결과를 자바가 허용하는가?

JLS는 메모리 모델을 주어진 프로그램과 실행 추적이 있을 때, 해당 실행이 적법한 실행인지를 판단하는 규칙으로 설명한다. 구현체는 JMM이 허용하는 결과만 만들어낸다면 명령 재배치나 불필요한 동기화 제거와 같은 최적화를 수행할 수 있다.


JVM 메모리 구조와 JMM의 차이

구분JVM 메모리 구조Java Memory Model
주요 관심사런타임 데이터가 저장되는 영역스레드 사이에서 허용되는 읽기와 쓰기
대표 개념Heap, Stack, Method Area가시성, 순서성, 원자성
핵심 질문데이터가 어디에 저장되는가?다른 스레드가 어떤 값을 볼 수 있는가?
관련 문서JVM SpecificationJava Language Specification 17.4
멀티스레드 규칙핵심 대상이 아님핵심 대상
주요 키워드객체, 지역 변수, 프레임happens-before, data race, synchronization

두 개념은 서로 관련은 있지만 같은 개념은 아니다.

힙과 스택을 설명하는 글을 JMM의 전체 설명으로 이해하면 멀티스레드 가시성과 재배치 문제를 제대로 파악하기 어렵다.


자바 메모리 모델이 필요한 이유

우리가 작성한 자바 소스 코드는 반드시 눈에 보이는 순서 그대로 실행되는 것은 아니다.

다음 코드를 살펴보자.

int x = 1;
int y = 2;

개발자는 자연스럽게 다음 순서를 떠올린다.

x에 1 저장
y에 2 저장

하지만 컴파일러, JIT 컴파일러, CPU는 프로그램의 의미를 훼손하지 않는 범위에서 명령 순서를 바꾸거나 값을 레지스터에 보관하거나 중복 연산을 제거할 수 있다.

소스 코드
    ↓
javac 컴파일
    ↓
바이트코드
    ↓
JIT 컴파일 및 최적화
    ↓
CPU 명령 실행

이러한 최적화는 성능 향상을 위해 필요하다.

문제는 한 스레드에서는 차이가 없던 재배치가 여러 스레드가 공유 데이터를 사용할 때 예상하지 못한 결과를 만들 수 있다는 점이다.

JLS는 잘못 동기화된 프로그램에서는 컴파일러나 프로세서의 최적화로 인해 직관에 어긋나는 결과가 나타날 수 있다고 설명한다. 같은 변수에 대한 쓰기와 다른 스레드의 읽기가 동기화 순서로 연결되지 않으면 데이터 경합이 발생한다.


한 스레드 안에서는 프로그램의 의미가 유지되어야 한다

컴파일러가 아무렇게나 명령을 바꿀 수 있는 것은 아니다.

각 스레드는 혼자 실행했을 때 자바 프로그램의 의미를 지켜야 한다. 이를 스레드 내부 의미론, 즉 intra-thread semantics라고 한다.

예를 들어 다음 코드는 항상 3을 출력해야 한다.

int x = 1;
int y = 2;

System.out.println(x + y);

컴파일러가 내부 실행 순서를 최적화하더라도 해당 스레드에서 관찰되는 결과는 단일 스레드 프로그램의 의미를 만족해야 한다.

그러나 다른 스레드가 중간의 공유 메모리를 읽는다면 이야기가 달라진다.

JMM은 단일 스레드의 의미를 유지하면서도, 각 공유 변수 읽기가 어떤 쓰기 값을 관찰할 수 있는지까지 규정한다. 따라서 JMM을 단순히 “한 스레드에서만 실행의 적법성을 판단한다”고 표현하는 것은 정확하지 않다. 각 스레드는 intra-thread semantics를 따라야 하지만, 메모리 모델의 핵심 목적은 스레드 간 상호작용까지 포함한 전체 실행의 적법성을 판단하는 것이다.


공유 변수와 지역 변수

JMM에서 스레드 간 공유 대상이 되는 것은 주로 다음과 같다.

  • 인스턴스 필드
  • static 필드
  • 배열 요소

이 값들은 공유 메모리의 일부로 취급된다.

반면 다음 값은 스레드 사이에서 직접 공유되지 않는다.

  • 지역 변수
  • 메서드 매개변수
  • 예외 처리 매개변수
public void calculate(int parameter) {
    int localValue = 10;
}

parameterlocalValue 자체는 해당 메서드를 실행하는 스레드의 실행 문맥에 속한다.

다만 지역 변수가 가리키는 객체가 외부에도 공유되어 있다면 그 객체의 필드는 여러 스레드가 접근할 수 있다.

JLS 17.4.1은 인스턴스 필드, static 필드와 배열 요소를 스레드 간 공유 변수로 다루며, 지역 변수와 메서드 매개변수는 스레드 사이에서 공유되지 않는다고 설명한다.


가시성 문제

한 스레드가 공유 변수에 값을 기록했다고 해서 다른 스레드가 그 값을 즉시 보게 된다고 단정할 수 없다.

public class Task {

    private boolean done;

    public void finish() {
        done = true;
    }

    public void waitUntilFinished() {
        while (!done) {
        }
    }
}

스레드 A가 다음 메서드를 실행한다고 가정해보자.

task.finish();

스레드 B는 다음 코드를 실행한다.

task.waitUntilFinished();

직관적으로는 스레드 A가 donetrue로 바꾸면 스레드 B의 반복문이 끝날 것처럼 보인다.

하지만 donevolatile도 아니고 동기화도 없다면 스레드 B가 이전에 읽은 false를 계속 재사용할 수 있다.

private volatile boolean done;

volatile을 사용하거나 적절한 동기화 수단을 적용해야 한 스레드의 쓰기 결과를 다른 스레드가 볼 수 있도록 보장할 수 있다.

Thread.sleep()이나 Thread.yield()를 호출하는 것만으로는 메모리 동기화 효과가 생기지 않는다. JLS는 컴파일러가 비휘발성 필드를 한 번만 읽은 뒤 캐시된 값을 반복적으로 사용할 수 있다고 명시한다.


happens-before 관계

JMM을 이해할 때 가장 중요한 개념 중 하나가 happens-before다.

어떤 동작 A가 동작 B보다 happens-before 관계에 있다면 다음을 보장한다.

A의 결과는 B에 보인다.
A는 B보다 논리적으로 먼저 일어난다.

대표적인 happens-before 규칙은 다음과 같다.

같은 스레드에서 앞선 동작
    happens-before
같은 스레드에서 뒤의 동작
모니터 unlock
    happens-before
동일 모니터의 이후 lock
volatile 변수 쓰기
    happens-before
동일 volatile 변수의 이후 읽기
Thread.start() 호출
    happens-before
새 스레드 내부의 모든 동작
스레드 내부의 모든 동작
    happens-before
다른 스레드의 성공적인 join() 반환

JLS 17.4.5는 happens-before 관계가 앞선 동작의 결과를 뒤의 동작에서 볼 수 있게 하는 순서 관계라고 정의한다. 올바르게 동기화되어 데이터 경합이 없는 프로그램은 순차적 일관성이 있는 것처럼 동작한다.


올바르게 동기화된 프로그램

두 스레드가 동일한 변수에 접근하고 그중 하나 이상이 쓰기 연산인데, 두 접근 사이에 happens-before 관계가 없다면 데이터 경합이 발생한다.

Thread A: value = 10
Thread B: result = value

위의 쓰기와 읽기를 연결하는 동기화가 없다면 Thread B가 어떤 값을 관찰하는지 직관적으로 판단하기 어려워진다.

반대로 프로그램이 올바르게 동기화되어 있다면 개발자는 컴파일러 재배치의 세부 사항까지 생각할 필요가 없다.

public class SafeValue {

    private int value;

    public synchronized void setValue(int value) {
        this.value = value;
    }

    public synchronized int getValue() {
        return value;
    }
}

동일한 모니터의 unlock과 이후 lock 사이에 happens-before 관계가 만들어지므로 값의 가시성이 보장된다.


객체 생성은 어떤 과정으로 이루어지는가

다음 클래스를 살펴보자.

public class WhiteShip {

    private int x;
    private int y;

    public WhiteShip() {
        this.x = 1;
        this.y = 2;
    }
}

객체를 생성한다.

WhiteShip whiteShip = new WhiteShip();

개념적으로는 다음과 같은 과정이 필요하다.

1. 객체를 위한 메모리 확보
2. 필드를 기본값으로 초기화
3. 생성자 본문 실행
4. x에 1 저장
5. y에 2 저장
6. 생성된 객체의 참조를 변수에 저장

그러나 이 설명을 그대로 물리적인 실행 명령의 고정 순서라고 이해하면 안 된다.

JVM과 CPU는 JMM이 허용하는 관찰 결과를 유지하는 범위에서 최적화와 재배치를 수행할 수 있다.

중요한 것은 실제 기계 명령이 어떤 순서로 실행되었는지가 아니라, 다른 스레드가 어떤 값을 관찰하는 것이 JMM상 허용되는가이다.


안전하지 않은 객체 공개

객체를 생성한 스레드와 객체를 사용하는 스레드 사이에 적절한 동기화가 없다고 가정해보자.

public class WhiteShip {

    private int x;
    private int y;

    public WhiteShip() {
        this.x = 1;
        this.y = 2;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }
}

공유 필드에 객체를 저장한다.

public class WhiteShipHolder {

    static WhiteShip ship;

    public static void writer() {
        ship = new WhiteShip();
    }

    public static void reader() {
        WhiteShip local = ship;

        if (local != null) {
            System.out.println(local.x());
            System.out.println(local.y());
        }
    }
}

한 스레드가 writer()를 실행하고 다른 스레드가 reader()를 실행한다.

Thread A
ship = new WhiteShip();

Thread B
WhiteShip local = ship;

ship이 일반 필드이고 어떠한 동기화도 없다면 데이터 경합이 존재한다.

이 경우 다른 스레드가 객체 참조를 얻더라도 일반 필드 xy에 대해 생성자에서 설정한 값이 보인다고 JMM이 보장하지 않는다.

다른 스레드는 이론적으로 다음과 같은 값을 관찰할 가능성이 있다.

x = 0
y = 0

또는 일부 필드에 대해서만 초기화된 값을 볼 수도 있다.

x = 1
y = 0

항상 이런 결과가 발생한다는 뜻은 아니다.

중요한 것은 동기화가 없으면 올바른 초기화 값을 볼 수 있다는 언어 차원의 보장이 없다는 것이다.


기본값 초기화

자바 객체의 인스턴스 필드는 생성자 본문이 실행되기 전에 기본값으로 초기화된다.

int     → 0
long    → 0L
double  → 0.0
boolean → false
참조형   → null
public class Example {

    private int number;
    private String text;
}

명시적인 값을 대입하지 않으면 다음 값을 가진다.

number = 0
text = null

JMM은 개념적으로 모든 변수의 기본값 쓰기가 프로그램의 다른 일반 동작보다 먼저 일어난다고 정의한다.

따라서 객체가 잘못 공개되었을 때 일반 필드에서 관찰할 수 있는 대표적인 잘못된 값이 바로 기본값이다.


final 필드의 특별한 의미

이제 xfinal로 바꿔보자.

public class WhiteShip {

    private final int x;
    private int y;

    public WhiteShip() {
        this.x = 1;
        this.y = 2;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }
}

공유 코드는 앞과 동일하다.

public class WhiteShipHolder {

    static WhiteShip ship;

    public static void writer() {
        ship = new WhiteShip();
    }

    public static void reader() {
        WhiteShip local = ship;

        if (local != null) {
            int first = local.x();
            int second = local.y();
        }
    }
}

생성자가 정상적으로 종료되고 생성 중인 this가 외부로 유출되지 않았다면, 다른 스레드가 이 객체를 관찰할 때 final 필드 x는 생성자에서 설정한 1을 보도록 보장된다.

반면 일반 필드 y는 동기화 없이 공개되었기 때문에 2를 본다고 보장되지 않는다.

x = 1  // 보장
y = 2  // 보장되지 않음

이론적으로 y는 기본값 0으로 관찰될 수 있다.

x = 1
y = 0

JLS의 공식 예제 역시 final int x = 3과 일반 필드 int y = 4를 비교한다. 객체 참조가 동기화 없이 전달되더라도 다른 스레드는 x에서 3을 보도록 보장되지만, y에서는 기본값 0을 볼 수 있다고 설명한다.


final 필드의 freeze action

JLS 17.5에서 중요한 개념이 freeze action이다.

객체 생성자에서 final 필드에 값을 기록하고 생성자가 종료되면 해당 final 필드에 대한 freeze action이 발생한다.

public WhiteShip() {
    this.x = 1;
}

개념적으로 다음과 같이 볼 수 있다.

final 필드 x에 1 기록
        ↓
생성자 종료
        ↓
x에 대한 freeze action
        ↓
다른 스레드에서 객체 관찰
        ↓
x의 올바른 초기값 1 관찰

JLS는 객체 생성자의 종료 시점에 생성자에서 기록된 final 필드에 대한 freeze action이 발생한다고 규정한다.

여기서 freeze를 스레드가 실제로 멈춰서 기다리는 락 동작이라고 이해하면 안 된다.

freeze action ≠ synchronized lock
freeze action ≠ 스레드 일시 정지
freeze action ≠ 객체 전체를 물리적으로 동결

freeze action은 final 필드의 초기화 값을 다른 스레드가 관찰할 때 적용되는 JMM상의 특별한 순서 및 가시성 규칙이다.


객체 전체가 freeze되는 것은 아니다

final 필드 하나가 있다고 해서 객체의 모든 일반 필드에 같은 보장이 적용되는 것은 아니다.

public final class Example {

    private final int finalValue;
    private int normalValue;

    public Example() {
        this.finalValue = 10;
        this.normalValue = 20;
    }
}

동기화 없이 공개하면 JLS 차원에서 보장되는 것은 finalValue의 올바른 초기값이다.

finalValue  → 10 보장
normalValue → 20 보장 안 됨

특정 JVM 구현이 실제로 더 강한 결과를 보여줄 수는 있다.

예를 들어 현실의 특정 JVM, 특정 CPU, 특정 최적화 조건에서는 일반 필드도 항상 초기화된 값으로 보일 수 있다.

하지만 애플리케이션 코드는 특정 JVM의 우연한 구현 결과에 의존해서는 안 된다.

현재 실행 환경에서 잘 동작한다
≠
Java Language Specification이 보장한다

이식성과 정확성을 위해서는 JVM 구현 세부 사항이 아니라 JLS가 제공하는 보장을 기준으로 설계해야 한다.


final 필드를 올바르게 사용하기 위한 조건

final 필드의 초기화 보장을 받으려면 중요한 조건이 있다.

생성자에서 값을 설정해야 한다

public final class Point {

    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

불변 객체의 핵심 상태는 생성 과정에서 완성해야 한다.


생성자가 끝나기 전에 this를 외부에 노출하면 안 된다

다음 코드는 안전하지 않다.

public final class UnsafeListener {

    private final int value;

    public UnsafeListener(EventSource source) {
        source.register(this);
        this.value = 100;
    }
}

source.register(this)에서 생성 중인 객체의 참조가 외부로 전달된다.

다른 스레드가 등록된 객체를 즉시 사용하면 value 초기화가 완료되기 전에 객체를 관찰할 수 있다.

이런 현상을 this escape라고 한다.

생성자 시작
    ↓
this 외부 공개
    ↓
다른 스레드에서 사용 가능
    ↓
final 필드 초기화
    ↓
생성자 종료

final 필드의 특별한 보장을 안전하게 받으려면 생성자가 끝나기 전에 객체 참조가 다른 스레드에 보이는 위치로 전달되어서는 안 된다. JLS도 생성 중인 객체의 참조를 생성자 종료 전에 다른 스레드가 볼 수 있는 곳에 기록하지 말라고 명시한다.


안전한 생성 패턴

객체 생성과 외부 등록을 분리할 수 있다.

public final class SafeListener {

    private final int value;

    private SafeListener() {
        this.value = 100;
    }

    public static SafeListener create(
            EventSource source
    ) {
        SafeListener listener =
                new SafeListener();

        source.register(listener);

        return listener;
    }
}

실행 순서는 다음과 같다.

SafeListener 생성
    ↓
final 필드 초기화
    ↓
생성자 종료
    ↓
완성된 객체 등록

객체가 완전히 초기화된 뒤 외부에 공개된다.


생성자에서 피해야 하는 this 노출 사례

정적 필드에 자신을 저장

public final class UnsafeObject {

    private static UnsafeObject instance;

    private final int value;

    public UnsafeObject() {
        instance = this;
        value = 100;
    }
}

외부 컬렉션에 자신을 추가

public UnsafeObject(
        List<UnsafeObject> registry
) {
    registry.add(this);
    value = 100;
}

외부 콜백에 자신을 등록

public UnsafeObject(
        EventPublisher publisher
) {
    publisher.subscribe(this);
    value = 100;
}

생성자에서 재정의 가능한 메서드 호출

public class Parent {

    public Parent() {
        initialize();
    }

    protected void initialize() {
    }
}

하위 클래스의 재정의 메서드가 호출되면 하위 클래스 필드가 초기화되기 전에 this가 사용되는 것과 비슷한 문제가 생길 수 있다.

따라서 생성자에서는 재정의 가능한 메서드를 호출하지 않는 것이 좋다.


final 참조가 가리키는 객체도 안전하게 보일 수 있다

final 필드가 기본 타입이 아니라 객체를 참조할 수도 있다.

public final class User {

    private final Address address;

    public User() {
        Address created =
                new Address("Seoul");

        this.address = created;
    }

    public Address address() {
        return address;
    }
}

JLS는 올바르게 생성된 객체를 다른 스레드가 관찰할 경우 final 필드 자체뿐 아니라, 그 final 필드가 생성 과정에서 가리키고 있던 객체나 배열의 상태도 특별한 가시성 보장의 영향을 받을 수 있다고 설명한다.

예를 들어 다음 구조를 생각할 수 있다.

public final class Holder {

    private final int[] values;

    public Holder() {
        values = new int[]{1, 2, 3};
    }

    public int first() {
        return values[0];
    }
}

생성자 안에서 배열을 만들고 값을 채운 뒤 final 필드에 저장했다면, 올바르게 생성된 Holder를 본 스레드는 생성 과정에서 설정한 배열 요소도 적절하게 관찰할 수 있다.

그러나 이것이 배열 자체를 불변으로 만들어준다는 뜻은 아니다.


final 참조와 객체 불변성은 다르다

다음 선언에서 final이 막는 것은 참조 재할당이다.

private final List<String> names;

다른 리스트를 대입할 수는 없다.

// 컴파일 오류
this.names = anotherList;

하지만 리스트가 가변이라면 요소는 변경할 수 있다.

names.add("Yoon");
names.clear();

따라서 다음 두 가지는 반드시 구분해야 한다.

final 참조
→ 참조가 다른 객체를 가리키지 못함

불변 객체
→ 객체의 논리적 상태가 바뀌지 않음

final은 참조 대상 객체의 메서드를 통한 상태 변경을 막지 않는다.

불변 클래스를 만들려면 가변 객체를 외부에 노출하지 않고, 필요하면 방어적 복사를 해야 한다.

public final class Team {

    private final List<String> members;

    public Team(List<String> members) {
        this.members = List.copyOf(members);
    }

    public List<String> members() {
        return members;
    }
}

final만으로 모든 동시성 문제가 해결되지는 않는다

final 필드의 특별한 초기화 보장은 매우 유용하지만, 모든 멀티스레드 문제를 해결하는 만능 수단은 아니다.

생성 이후 값이 변경되어야 한다면 final을 사용할 수 없다

public class Counter {

    private int count;

    public void increment() {
        count++;
    }
}

이런 가변 상태에는 synchronized, Lock, 원자 클래스 등 별도의 동시성 제어가 필요하다.

public class Counter {

    private final AtomicInteger count =
            new AtomicInteger();

    public void increment() {
        count.incrementAndGet();
    }
}

객체 참조 자체의 최신성까지 모두 해결하는 것은 아니다

final 필드 규칙은 객체를 관찰한 스레드가 해당 객체의 final 필드 초기값을 올바르게 보도록 특별히 보장한다.

그러나 공유 참조 변수의 변경을 지속적으로 감지해야 하는 문제는 별개다.

private static Config config;

한 스레드가 config를 교체하고 다른 스레드가 새 참조를 반드시 관찰해야 한다면 volatile이나 동기화가 필요할 수 있다.

private static volatile Config config;

여러 연산의 원자성을 보장하지 않는다

if (balance >= amount) {
    balance -= amount;
}

필드가 올바르게 초기화되었다고 해서 위의 복합 연산이 자동으로 원자적이 되는 것은 아니다.

읽기, 비교, 쓰기를 하나의 논리적 작업으로 보호하려면 락 등의 동기화가 필요하다.


final, volatile, synchronized 비교

구분finalvolatilesynchronized
주요 목적재할당 방지와 초기화 안전성최신 값의 가시성과 순서 보장상호 배제와 가시성 보장
값 변경생성 이후 재할당 불가변경 가능변경 가능
복합 연산 원자성보장하지 않음보장하지 않음임계 영역 단위로 보장 가능
다른 스레드의 최신 값 관찰초기화된 final 필드에 특별 보장읽을 때 최신 volatile 쓰기 관찰동일 모니터 동기화로 관찰
락 사용없음없음있음
대표 용도불변 값 객체상태 플래그, 참조 교체복합 상태 변경
재배치 제한생성자와 final 초기화 관련 특별 규칙volatile 읽기·쓰기 주변 제약lock·unlock 경계의 제약

각 키워드는 해결하는 문제가 다르다.

final
→ 객체의 완성된 초기 상태

volatile
→ 변경되는 단일 값의 가시성

synchronized
→ 여러 연산과 상태를 하나의 임계 영역으로 보호

final 필드가 있는 불변 객체

불변 값 객체는 다음과 같이 설계할 수 있다.

public final class Point {

    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return x;
    }

    public int y() {
        return y;
    }
}

이 클래스는 다음 조건을 만족한다.

클래스 상속 불가
필드 재할당 불가
생성자에서 완전한 초기화
생성 중 this 노출 없음
상태 변경 메서드 없음
가변 내부 객체 없음

이처럼 올바르게 만들어진 불변 객체는 생성 후 여러 스레드가 안전하게 공유하기에 적합하다.


불변 객체의 안전한 공유 예제

public final class ServerConfig {

    private final String host;
    private final int port;
    private final boolean secure;

    public ServerConfig(
            String host,
            int port,
            boolean secure
    ) {
        this.host = host;
        this.port = port;
        this.secure = secure;
    }

    public String host() {
        return host;
    }

    public int port() {
        return port;
    }

    public boolean secure() {
        return secure;
    }
}

객체를 만든 뒤 여러 작업에 전달할 수 있다.

ServerConfig config =
        new ServerConfig(
                "api.example.com",
                443,
                true
        );

ExecutorService executor =
        Executors.newFixedThreadPool(3);

for (int i = 0; i < 3; i++) {
    executor.submit(() -> {
        connect(
                config.host(),
                config.port(),
                config.secure()
        );
    });
}

각 스레드는 동일한 ServerConfig 인스턴스를 사용한다.

어느 스레드도 값을 변경하지 않기 때문에 상태 경쟁이 없고, 올바른 생성 규칙을 지킨 final 필드들은 생성자에서 설정한 값을 안정적으로 제공한다.

다만 객체를 스레드 풀이나 큐에 제출하는 과정 자체도 java.util.concurrent가 정의하는 동기화 규칙을 제공하므로, 실제 코드에서는 객체 전달 경로의 happens-before 관계도 함께 존재한다.


Thread.start()를 통한 전달

다음 코드는 객체를 만든 뒤 새 스레드를 시작한다.

ServerConfig config =
        new ServerConfig(
                "localhost",
                8080,
                false
        );

Thread thread = new Thread(() -> {
    System.out.println(config.host());
    System.out.println(config.port());
});

thread.start();

Thread.start() 호출 이전에 현재 스레드에서 수행된 동작은 시작된 스레드 내부의 동작보다 happens-before 관계에 있다.

따라서 이 예제는 final 필드 규칙뿐 아니라 Thread.start()가 제공하는 안전한 공개 규칙도 활용한다.

이를 통해 중요한 사실을 알 수 있다.

불변 객체라고 해서
객체 전달 방법을 전혀 신경 쓸 필요가 없다는 뜻은 아니다.

다만 final 필드는
잘못 동기화된 참조 전달 상황에서도
초기화된 final 값에 대해 더 강한 보장을 제공한다.

실무에서는 가능한 한 Thread.start(), 동시성 컬렉션, volatile, 락, 클래스 초기화처럼 명확한 안전 공개 수단을 사용하는 것이 좋다.


안전한 공개 방법

객체를 다른 스레드에 전달할 때는 다음과 같은 방법을 사용할 수 있다.

정적 초기화

public final class ConfigHolder {

    public static final ServerConfig CONFIG =
            new ServerConfig(
                    "localhost",
                    8080,
                    false
            );
}

클래스 초기화는 JVM이 동기화해서 수행한다.


volatile 참조

private volatile ServerConfig config;
public void update(
        ServerConfig newConfig
) {
    config = newConfig;
}

synchronized 블록

private ServerConfig config;

public synchronized void update(
        ServerConfig newConfig
) {
    config = newConfig;
}

public synchronized ServerConfig get() {
    return config;
}

동시성 컬렉션

BlockingQueue<ServerConfig> queue =
        new LinkedBlockingQueue<>();

queue.put(config);

ServerConfig received = queue.take();

스레드 시작 전 객체 전달

ServerConfig config = createConfig();

new Thread(() -> use(config)).start();

ExecutorService에 작업 제출

executorService.submit(() -> use(config));

실무에서는 final 필드의 특별 규칙에만 의존하기보다는 이러한 안전한 공개 방식을 명확하게 사용하는 것이 더 이해하기 쉽고 유지보수에도 유리하다.


잘못 이해하기 쉬운 부분

final이 있으면 객체 전체가 자동으로 안전하다

그렇지 않다.

public final class Container {

    private final List<String> values;

    public Container(List<String> values) {
        this.values = values;
    }

    public List<String> values() {
        return values;
    }
}

values 참조는 바뀌지 않지만 리스트 내용은 바뀔 수 있다.

container.values().clear();

이 클래스는 불변이 아니다.


final 필드가 하나라도 있으면 모든 일반 필드가 보장된다

JLS 차원에서는 그렇지 않다.

private final int x;
private int y;

특별한 초기화 보장은 final 필드인 x에 적용된다.

일부 JVM이 더 강하게 동작한다고 하더라도 그 구현 세부 사항에 의존하면 안 된다.


final을 사용하면 volatile과 synchronized가 필요 없다

final은 생성 이후 값이 바뀌지 않는 상태에 적합하다.

값이 계속 변경되어야 한다면 다른 동시성 수단이 필요하다.

private volatile boolean running;
private final AtomicInteger count =
        new AtomicInteger();
public synchronized void update() {
}

명령 재배치는 항상 소스 코드와 반대 순서로 실행된다는 뜻이다

재배치는 특정한 기계 명령 순서 하나를 의미하지 않는다.

JMM에서 중요한 것은 실제 CPU가 어떤 순서로 실행했는지가 아니라 프로그램이 외부적으로 어떤 결과를 관찰할 수 있는가이다.

다음 표현은 개념을 설명하기 위한 단순화로는 사용할 수 있다.

객체 참조가 먼저 보이고
필드 초기화가 나중에 보일 수 있다.

그러나 JVM이 반드시 소스 코드를 정확히 그 순서로 바꿔 실행한다고 단정해서는 안 된다.

JIT 최적화, CPU 재배치, 캐시와 메모리 계층 등의 결과가 다른 스레드에서 마치 순서가 달라진 것처럼 관찰될 수 있다는 의미에 가깝다.


final 필드 설계 원칙

객체의 핵심 값에는 final을 사용한다

public final class Money {

    private final BigDecimal amount;
    private final Currency currency;
}

생성자에서 완전한 상태를 만든다

public Money(
        BigDecimal amount,
        Currency currency
) {
    this.amount =
            Objects.requireNonNull(amount);

    this.currency =
            Objects.requireNonNull(currency);
}

생성자에서 this를 외부에 전달하지 않는다

// 피해야 하는 코드
eventPublisher.subscribe(this);

객체 생성 후 별도 팩터리나 초기화 메서드에서 등록한다.


가변 객체는 방어적으로 복사한다

public final class Schedule {

    private final List<LocalDate> dates;

    public Schedule(List<LocalDate> dates) {
        this.dates = List.copyOf(dates);
    }
}

파생 값 캐시는 논리적 불변성을 해치지 않게 만든다

private int cachedHashCode;

불변 객체 내부에 지연 계산용 non-final 캐시 필드를 둘 수 있지만, 그 값의 변경이 객체의 논리적 상태를 바꾸어서는 안 된다.


실무에서 final을 적극적으로 사용해야 하는 이유

컴파일 시점에 실수를 방지한다

private final String orderNumber;

생성 이후 주문 번호를 실수로 변경할 수 없다.


객체의 설계 의도를 드러낸다

private final Money price;

이 필드는 객체가 존재하는 동안 다른 Money 인스턴스로 교체되지 않는다는 사실을 코드로 표현한다.


생성자에서 초기화 누락을 발견할 수 있다

public final class Product {

    private final Long id;
    private final String name;

    public Product(Long id) {
        this.id = id;
    }
}

name을 초기화하지 않았기 때문에 컴파일 오류가 발생한다.


불변 객체 설계를 돕는다

모든 핵심 상태를 private final로 만들면 객체 상태 변경 경로가 크게 줄어든다.


멀티스레드 초기화 안전성을 강화한다

생성자에서 올바르게 초기화되고 this가 유출되지 않은 final 필드는 다른 스레드가 생성된 값을 안정적으로 관찰하도록 JMM의 특별한 보장을 받는다.


핵심 정리

  • 자바 메모리 모델은 JVM의 힙과 스택 구조를 설명하는 개념이 아니다.
  • JMM은 멀티스레드 프로그램에서 어떤 읽기와 쓰기 결과가 허용되는지를 정의한다.
  • JVM, JIT 컴파일러와 CPU는 JMM이 허용하는 범위에서 명령을 재배치하거나 최적화할 수 있다.
  • 각 스레드는 단일 스레드 프로그램의 의미인 intra-thread semantics를 지켜야 한다.
  • 공유 변수에 대한 쓰기와 읽기가 happens-before 관계로 연결되지 않으면 데이터 경합이 발생할 수 있다.
  • 올바르게 동기화된 프로그램은 순차적으로 실행되는 것처럼 추론할 수 있다.
  • 동기화 없이 객체를 공개하면 다른 스레드가 일반 필드의 기본값을 관찰할 가능성이 있다.
  • final 필드는 일반 필드와 다른 특별한 초기화 가시성 보장을 가진다.
  • 생성자 종료 시 생성자에서 기록된 final 필드에 대한 freeze action이 발생한다.
  • freeze action은 실제 락이나 스레드 정지를 의미하지 않는다.
  • 생성자가 끝나기 전에 this를 외부에 노출하면 final 필드 보장을 훼손할 수 있다.
  • final 필드 하나가 객체의 모든 일반 필드까지 자동으로 보호하는 것은 아니다.
  • 특정 JVM 구현이 더 강한 보장을 제공하더라도 구현 세부 사항에 의존해서는 안 된다.
  • final 참조가 참조 대상 객체까지 불변으로 만드는 것은 아니다.
  • final은 객체 초기화 안전성을 돕지만 가변 상태의 동시성 제어까지 해결하지는 않는다.
  • 값이 변경되는 상태에는 volatile, synchronized, Lock, 원자 클래스 등의 수단이 필요하다.
  • 실무에서는 final 필드와 함께 명확한 안전 공개 방법을 사용하는 것이 좋다.

한 줄 요약

final은 단순히 재할당을 막는 키워드가 아니라, 생성자에서 완전히 초기화되고 생성 중인 this가 외부로 유출되지 않았을 때 다른 스레드가 해당 필드의 올바른 초기값을 관찰하도록 자바 메모리 모델이 특별히 보장하는 불변 객체 설계의 핵심 도구다.

아이템 17. 완벽 공략 - CountDownLatch

완벽 공략 33. Java Util Concurrent 패키지와 CountDownLatch

이번에는 Item 17에서 언급된 java.util.concurrent 패키지와 그 안에 있는 CountDownLatch를 살펴본다.

CountDownLatch는 멀티스레드 환경에서 여러 스레드의 실행 시점을 맞추거나, 모든 작업이 끝날 때까지 기다려야 하는 상황에서 사용하는 대표적인 동기화(Synchronization) 유틸리티이다.


Java Util Concurrent 패키지란?

java.util.concurrent 패키지는 멀티스레드 프로그래밍을 보다 안전하고 쉽게 작성할 수 있도록 다양한 기능을 제공하는 패키지이다.

기존에는 Thread, synchronized, wait(), notify()만으로 동시성 문제를 해결해야 했기 때문에 구현이 어렵고 버그도 자주 발생했다.

이를 해결하기 위해 Java 5부터 다양한 동시성 유틸리티가 추가되었다.

대표적인 클래스들은 다음과 같다.

클래스용도
ExecutorService스레드 풀 관리
Future비동기 결과 조회
CompletableFuture비동기 작업 조합
CountDownLatch여러 작업 완료 대기
CyclicBarrier여러 스레드의 실행 시점 동기화
Semaphore동시에 접근 가능한 개수 제한
ReentrantLocksynchronized보다 유연한 Lock

병행성(Concurrency)과 병렬성(Parallelism)

java.util.concurrent를 이해하려면 먼저 병행성과 병렬성의 차이를 알아야 한다.

병행성(Concurrency)

병행성은 하나의 CPU가 여러 작업을 번갈아 수행하는 방식이다.

실제로는 한 번에 하나의 작업만 수행하지만 매우 빠르게 작업을 전환하기 때문에 동시에 실행되는 것처럼 보인다.

CPU

Task A
 ↓
Task B
 ↓
Task C
 ↓
Task A
 ↓
Task B

이를 시분할(Time Sharing) 방식이라고 한다.


병렬성(Parallelism)

병렬성은 여러 CPU(Core)가 실제로 동시에 작업을 수행하는 것이다.

CPU 1 → Task A

CPU 2 → Task B

CPU 3 → Task C

최근 대부분의 CPU는 멀티코어이므로 병렬 실행이 가능하다.


병행성과 병렬성은 함께 사용된다

Java에서 여러 Thread를 생성하면 JVM과 운영체제가 적절하게 CPU에 분배한다.

따라서 개발자는

  • CPU 개수가 몇 개인지
  • 어느 코어에서 실행되는지

를 직접 신경 쓸 필요가 없다.

개발자는 Thread 단위로 작업을 분리하기만 하면 된다.


java.util.concurrent가 필요한 이유

멀티스레드 환경에서는 여러 스레드가 동일한 객체를 동시에 접근할 수 있다.

이때 다양한 문제가 발생한다.

Race Condition

두 스레드가 같은 데이터를 동시에 수정하는 경우

Thread A
count++

Thread B
count++

실행 순서에 따라 결과가 달라질 수 있다.


Visibility 문제

한 스레드가 변경한 값을 다른 스레드가 즉시 보지 못할 수도 있다.


Thread Synchronization

특정 작업이 끝날 때까지 기다려야 하는 경우가 있다.

예를 들어

  • 파일 10개 다운로드
  • 모든 다운로드 완료
  • 압축 시작

이런 작업에서는 모든 스레드가 끝났는지 확인해야 한다.

이때 사용하는 대표적인 클래스가 CountDownLatch이다.


CountDownLatch란?

CountDownLatch

여러 스레드의 작업이 모두 끝날 때까지 기다리거나, 특정 조건이 만족될 때 여러 스레드를 동시에 시작시키기 위한 동기화 도구이다.

쉽게 말하면

"카운트가 0이 될 때까지 기다리는 문"

이라고 생각하면 된다.


CountDownLatch의 주요 메서드

countDown()

카운트를 1 감소시킨다.

latch.countDown();

await()

카운트가 0이 될 때까지 현재 스레드를 대기시킨다.

latch.await();

동작 과정

예를 들어 Worker가 3개 있다고 하자.

초기 카운트

Count = 3

Worker1 종료

Count = 2

Worker2 종료

Count = 1

Worker3 종료

Count = 0

이 순간

await()

에서 기다리던 스레드가 깨어난다.


동작 구조

flowchart TD

Main --> CreateLatch

CreateLatch --> Worker1
CreateLatch --> Worker2
CreateLatch --> Worker3

Worker1 --> countDown
Worker2 --> countDown
Worker3 --> countDown

countDown --> Await

Await --> Continue

Start Signal과 Done Signal

Oracle 공식 예제에서는 CountDownLatch를 두 개 사용한다.

Start Signal

Done Signal

역할은 서로 다르다.


Start Signal

모든 Worker를 먼저 생성한다.

하지만 바로 실행하지 않는다.

startSignal.await();

를 호출하여 대기한다.

이후

startSignal.countDown();

을 호출하면

모든 Worker가 동시에 작업을 시작한다.


Done Signal

Worker들이 작업을 끝낼 때마다

doneSignal.countDown();

을 호출한다.

메인 스레드는

doneSignal.await();

으로 기다린다.

모든 Worker가 종료되어 카운트가 0이 되면

메인 스레드가 다시 실행된다.


실행 순서

Main Thread

↓

Worker 생성

↓

Worker 모두 대기

↓

Start Signal 발생

↓

Worker 동시 실행

↓

Worker 종료

↓

Done Signal 감소

↓

Done Signal = 0

↓

Main Thread 재개

CountDownLatch의 특징

1. 시작 신호를 만들 수 있다.

여러 스레드를 생성해 두고

원하는 순간 동시에 시작시킬 수 있다.


2. 종료 신호를 만들 수 있다.

모든 작업이 끝났을 때

다음 작업을 수행할 수 있다.

예를 들어

다운로드 완료

↓

압축

↓

업로드

처럼 순서를 보장할 수 있다.


3. 여러 작업 완료를 기다릴 수 있다.

대표적인 예이다.

API A 호출

API B 호출

API C 호출

↓

모든 API 완료

↓

결과 합치기

CountDownLatch는 재사용할 수 없다

가장 중요한 특징 중 하나이다.

CountDownLatch latch = new CountDownLatch(3);

카운트가

3

↓

2

↓

1

↓

0

이 되면

이 인스턴스는 역할이 끝난다.

다시

0 → 3

으로 복구되지 않는다.

즉,

일회성(One-shot) 동기화 도구이다.


재사용이 필요하다면?

이 경우에는

CyclicBarrier

를 사용해야 한다.

Barrier는 이름 그대로

여러 스레드가 모두 도착하면

Barrier를 통과하고

다시 초기 상태로 돌아간다.

즉,

재사용 가능한 동기화 도구이다.


CountDownLatch와 CyclicBarrier 비교

항목CountDownLatchCyclicBarrier
재사용불가능가능
카운트감소만 가능반복 사용 가능
용도작업 완료 대기여러 스레드 동시 시작
초기화생성 시 1회반복 가능

실무에서 사용하는 경우

서버 시작 순서 제어

DB 연결

Redis 연결

Kafka 연결

↓

모든 연결 완료

↓

Server Start

여러 API 병렬 호출

회원 조회

주문 조회

쿠폰 조회

↓

모든 조회 완료

↓

응답 생성

Batch 작업

파일 100개 처리

↓

모든 파일 처리 완료

↓

결과 저장

병렬 테스트

JUnit에서 여러 스레드를 동시에 시작하여

동시성 문제를 재현할 때 자주 사용된다.


Item 17에서 CountDownLatch를 언급한 이유

Effective Java에서는 다음과 같이 설명한다.

CountDownLatch는 가변 클래스이지만 상태 변화가 매우 제한적이다.

실제로 상태 변화는

10

↓

9

↓

8

↓

...

↓

1

↓

0

처럼 한 방향으로만 진행된다.

또한 0이 되면 객체의 역할이 끝난다.

즉,

  • 상태 수가 매우 적고
  • 사용 목적이 명확하며
  • 재사용하지 않는다.

그래서 가변 객체임에도 비교적 안전하게 사용할 수 있는 좋은 설계 사례로 소개된다.


핵심 정리

  • java.util.concurrent는 멀티스레드 프로그래밍을 위한 유틸리티 패키지이다.
  • 병행성은 하나의 CPU를 번갈아 사용하는 것이고, 병렬성은 여러 CPU가 동시에 작업하는 것이다.
  • Java에서는 Thread만 사용하면 JVM과 운영체제가 병행성과 병렬성을 자동으로 관리한다.
  • CountDownLatch는 여러 작업의 완료를 기다리거나 여러 스레드를 동시에 시작시키기 위한 동기화 도구이다.
  • await()는 카운트가 0이 될 때까지 대기하고, countDown()은 카운트를 감소시킨다.
  • CountDownLatch일회성 객체이며 재사용할 수 없다.
  • 반복적으로 동기화가 필요하다면 CyclicBarrier를 사용한다.
  • 상태 변화가 제한적이고 사용 목적이 명확하기 때문에, Effective Java에서는 잘 설계된 가변 클래스의 예시CountDownLatch를 소개한다.

한 줄 요약

CountDownLatch는 여러 스레드의 시작 시점을 맞추거나 모든 작업이 끝날 때까지 기다리기 위한 일회성 동기화 도구이며, java.util.concurrent가 제공하는 대표적인 멀티스레드 유틸리티이다.


© 2020. All rights reserved.

SIKSIK