이펙티브 자바 완벽 공략 2부
아이템 17.변경 가능성을 최소화 하라.
아이템 17.변경 가능성을 최소화 하라.
- 아이템 17.변경 가능성을 최소화 하라.
- 아이템 17. 핵심 정리 - 불변 클래스
- 아이템 17. 변경 가능성을 최소화하라
- 불변 클래스란?
- 불변 객체를 사용하는 이유
- 불변 클래스의 장점
- 불변 클래스를 만드는 다섯 가지 규칙
- 객체의 상태를 변경하는 메서드를 제공하지 않는다
- 클래스를 확장할 수 없도록 만든다
- 모든 필드를 final로 선언한다
- final 필드와 메모리 가시성
- 모든 필드를 private으로 선언한다
- 내부의 가변 컴포넌트에 접근할 수 없도록 한다
- 생성자에서 전달받은 가변 객체가 문제다
- getter로 가변 객체를 그대로 반환해도 문제가 된다
- 방어적 복사를 사용한다
- 더 좋은 방법은 내부 객체도 불변으로 만드는 것이다
- final은 참조 대상까지 불변으로 만들지 않는다
- 변경 작업은 새로운 객체를 반환한다
- 숫자형 불변 객체도 새로운 객체를 반환한다
- 불변 값 객체의 변경 메서드
- 함수형 접근 방식
- 불변 클래스 설계 예시
- 불변 클래스 설계 체크리스트
- 핵심 정리
- 한 줄 정리
- 아이템 17. 핵심 정리 - 불변 클래스
아이템 17. 핵심 정리 - 불변 클래스
아이템 17. 변경 가능성을 최소화하라
이번 아이템에서는 불변 클래스가 무엇인지, 불변 클래스를 어떻게 만드는지, 그리고 왜 불변 객체가 안전하고 사용하기 쉬운지를 살펴본다.
불변 클래스란 인스턴스를 생성한 이후부터 소멸될 때까지 내부 상태가 바뀌지 않는 클래스를 의미한다.
즉, 객체를 한 번 만들면 그 객체가 가지고 있는 값은 끝까지 유지된다.
대표적인 불변 클래스로는 다음과 같은 것들이 있다.
StringBigIntegerBigDecimal- 기본 타입의 박싱 클래스
LocalDateLocalDateTime
불변 객체는 상태가 바뀌지 않기 때문에 가변 객체보다 이해하기 쉽고, 오류가 발생할 가능성이 낮으며, 여러 스레드가 동시에 사용해도 안전하다.
불변 클래스란?
불변 클래스(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
이처럼 생성 이후 상태가 변하지 않는 객체를 불변 객체라고 한다.
불변 객체를 사용하는 이유
불변 객체의 가장 큰 장점은 단순함과 안전성이다.
가변 객체는 언제 어디서 상태가 변경될지 추적해야 한다.
반면 불변 객체는 처음 생성된 상태가 끝까지 유지되므로, 객체를 사용하는 동안 상태 변경을 걱정할 필요가 없다.
가변 객체를 사용할 때의 고민
가변 객체를 사용하면 다음과 같은 질문을 계속해야 한다.
이 객체는 현재 어떤 상태인가?
다른 코드가 값을 변경하지 않았는가?
메서드 호출 이후 상태가 달라지는가?
다른 스레드에서 값을 수정하지 않았는가?
불변 객체를 사용할 때
불변 객체는 상태가 변하지 않으므로 다음처럼 단순하게 생각할 수 있다.
처음 생성된 값이 현재 값이다.
이 차이가 프로그램의 복잡도를 크게 줄인다.
불변 클래스의 장점
불변 클래스는 다음과 같은 장점을 제공한다.
- 이해하기 쉽다.
- 상태 변경으로 인한 오류가 적다.
- 여러 코드에서 안전하게 공유할 수 있다.
- 방어적 복사가 줄어든다.
- 멀티스레드 환경에서 안전하다.
- 캐싱하기 쉽다.
- 값 객체로 사용하기 적합하다.
특히 여러 스레드가 동일한 인스턴스를 공유하더라도 상태가 바뀌지 않기 때문에 동기화 없이 안전하게 사용할 수 있다.
불변 클래스를 만드는 다섯 가지 규칙
불변 클래스를 만들기 위해서는 다음 다섯 가지 규칙을 지켜야 한다.
- 객체 상태를 변경하는 메서드를 제공하지 않는다.
- 클래스를 확장할 수 없도록 만든다.
- 모든 필드를
final로 선언한다. - 모든 필드를
private으로 선언한다. - 내부의 가변 컴포넌트에 접근할 수 없도록 한다.
각 규칙을 자세히 살펴보자.
객체의 상태를 변경하는 메서드를 제공하지 않는다
불변 객체를 만들려면 객체의 내부 상태를 변경하는 메서드를 제공해서는 안 된다.
대표적인 상태 변경 메서드는 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;
}
}
MyPhoneNumber는 PhoneNumber 타입으로 사용할 수 있지만 상태가 변경될 수 있다.
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
Person의 address 참조 자체는 바뀌지 않았다.
하지만 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()
);
}
}
외부에서 전달한 원본 Address와 Person 내부의 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"
숫자형 불변 객체도 새로운 객체를 반환한다
BigInteger와 BigDecimal도 동일한 방식으로 동작한다.
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참조가 참조 대상 객체의 불변성을 보장하지는 않는다.- 상태 변경이 필요하면 기존 객체를 수정하지 않고 새로운 객체를 반환한다.
- 가능하다면 내부 컴포넌트도 불변 객체로 설계한다.
한 줄 정리
불변 클래스는 상태 변경 경로를 모두 차단하고, 변경이 필요할 때 기존 객체를 수정하는 대신 새로운 객체를 반환하도록 설계해야 한다.