이펙티브 자바 완벽 공략 3부
아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
- 아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
- 아이템 16. 핵심 정리
- 아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
- public 필드를 직접 공개하면 캡슐화가 깨진다
- 내부 표현을 변경하기 어려워진다
- 접근자 메서드를 사용하면 내부 구현을 숨길 수 있다
- 필드 이름을 변경해도 클라이언트 코드를 유지할 수 있다
- setter에서 값을 검증할 수 있다
- getter에서도 부가 작업을 수행할 수 있다
- setter를 반드시 제공해야 하는 것은 아니다
- public 클래스에서는 접근자 메서드를 사용해야 한다
- package-private 클래스는 예외가 될 수 있다
- public final 필드는 완전한 해결책이 아니다
- final은 객체 내부까지 불변으로 만들지 않는다
- 가변 객체를 전달할 때는 복사본을 고려해야 한다
- 가변 public 필드는 스레드 안전성도 해친다
- 접근자 메서드 사용 시 주의할 점
- public 필드와 접근자 메서드 비교
- 실무에서의 권장 방향
- 핵심 정리
- 한 줄 정리
- 아이템 16. 완벽 공략
- 아이템 16 완벽 공략:
Dimension클래스의 성능 문제 Dimension은 내부 상태를 그대로 노출한다- 성능 문제는
Dimension자체보다 사용하는 코드에서 발생한다 - 방어적 복사가 필요한 이유
- 불필요한 객체 생성이 누적된다
- 객체 생성 자체보다 더 큰 문제는 사용의 불편함이다
- 가변 객체를 그대로 전달하면 코드가 불안정해진다
- 안전하게 사용하려면 복사해야 한다
Dimension은 불변 객체였어야 한다- 불변 객체라면 방어적 복사가 필요하지 않다
- 가변 객체와 불변 객체 비교
- 접근자 메서드만 추가해도 완전한 해결은 아니다
- 값 객체는 불변으로 설계하는 것이 유리하다
- 실무에서의 적용
- 핵심 정리
- 한 줄 정리
- 아이템 16 완벽 공략:
- 아이템 16. 핵심 정리
아이템 16. 핵심 정리
아이템 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라
이번 아이템의 핵심은 단순하다.
public 클래스의 필드는 직접 공개하지 말고, private으로 감춘 뒤 접근자 메서드를 제공하라.
필드를 public으로 공개하면 외부 코드가 객체의 내부 상태에 직접 접근하게 된다. 이는 캡슐화를 무너뜨리고, 이후 클래스의 내부 구현을 변경하기 어렵게 만든다.
public 필드를 직접 공개하면 캡슐화가 깨진다
다음과 같은 Point 클래스가 있다고 가정해보자.
public class Point {
public double x;
public double y;
}
클라이언트 코드는 다음과 같이 필드에 직접 접근하게 된다.
Point point = new Point();
point.x = 10;
point.y = 20;
System.out.println(point.x);
System.out.println(point.y);
이 구조에서는 외부 코드가 객체의 내부 상태를 직접 읽고 변경한다.
즉, Point 클래스는 자신의 상태를 통제하지 못한다.
public 필드의 문제점
필드를 직접 공개하면 다음과 같은 문제가 발생한다.
- 내부 표현을 변경하기 어렵다.
- 필드에 저장되는 값을 검증할 수 없다.
- 값을 읽거나 변경할 때 부가 작업을 수행할 수 없다.
- 객체의 불변식을 유지하기 어렵다.
- 외부 코드가 언제 어디서 값을 변경했는지 추적하기 어렵다.
결국 클래스 내부 구현과 클라이언트 코드가 강하게 결합된다.
내부 표현을 변경하기 어려워진다
현재 Point가 x, y 필드로 좌표를 표현한다고 가정해보자.
public class Point {
public double x;
public double y;
}
외부 코드가 다음처럼 직접 사용하고 있다면
point.x
point.y
나중에 필드 이름을 변경하기 어렵다.
예를 들어 내부 표현을 다음처럼 바꾸고 싶다고 해보자.
private double horizontal;
private double vertical;
기존에 point.x, point.y를 사용하던 모든 코드를 수정해야 한다.
public 클래스는 어디에서 사용되고 있는지 완전히 파악하기 어려울 수 있다. 라이브러리나 외부 공개 API라면 변경 영향 범위는 더 커진다.
접근자 메서드를 사용하면 내부 구현을 숨길 수 있다
필드를 private으로 감추고 getter와 setter를 제공하면 내부 구현을 외부에 노출하지 않을 수 있다.
public class Point {
private double x;
private double y;
public double getX() {
return x;
}
public double getY() {
return y;
}
public void setX(double x) {
this.x = x;
}
public void setY(double y) {
this.y = y;
}
}
클라이언트는 다음과 같이 사용한다.
Point point = new Point();
point.setX(10);
point.setY(20);
System.out.println(point.getX());
System.out.println(point.getY());
접근자 메서드의 장점
접근자 메서드를 사용하면 다음과 같은 장점이 생긴다.
- 내부 필드 이름을 자유롭게 변경할 수 있다.
- 값 설정 전에 검증할 수 있다.
- 값 조회 시 추가 계산을 수행할 수 있다.
- 로깅이나 이벤트 발행 같은 부가 작업을 추가할 수 있다.
- 가변 객체를 불변 객체로 전환하기 쉽다.
- 외부 코드와 내부 구현의 결합도를 낮출 수 있다.
필드 이름을 변경해도 클라이언트 코드를 유지할 수 있다
기존 필드가 다음과 같다고 가정해보자.
private double x;
private double y;
이후 내부 표현을 다음과 같이 변경할 수 있다.
private double horizontal;
private double vertical;
접근자 메서드 이름을 유지하면 클라이언트 코드는 변경하지 않아도 된다.
public double getX() {
return horizontal;
}
public double getY() {
return vertical;
}
외부에서는 여전히 다음 코드를 사용할 수 있다.
point.getX();
point.getY();
내부 구현이 변경되어도 공개 API가 유지되는 것이다.
setter에서 값을 검증할 수 있다
필드에 직접 접근하면 값이 올바른지 확인할 방법이 없다.
public int age;
외부에서는 다음과 같이 잘못된 값을 넣을 수 있다.
person.age = -10;
하지만 setter를 사용하면 값을 검증할 수 있다.
public class Person {
private int age;
public void setAge(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"나이는 0 이상이어야 합니다."
);
}
this.age = age;
}
public int getAge() {
return age;
}
}
객체가 자신의 상태를 스스로 통제할 수 있게 된다.
getter에서도 부가 작업을 수행할 수 있다
접근자 메서드는 단순히 필드 값을 반환하는 역할만 하는 것이 아니다.
필요하다면 다음과 같은 작업을 추가할 수 있다.
- 값 계산
- 지연 초기화
- 로깅
- 보안 검사
- 방어적 복사
- 표현 형식 변환
예를 들어 내부에는 섭씨 온도를 저장하지만 외부에는 화씨 온도를 제공할 수 있다.
public class Temperature {
private double celsius;
public double getFahrenheit() {
return celsius * 1.8 + 32;
}
}
필드를 직접 공개했다면 이런 추상화를 제공하기 어렵다.
setter를 반드시 제공해야 하는 것은 아니다
필드를 private으로 만들었다고 해서 반드시 setter를 제공해야 하는 것은 아니다.
오히려 불변 객체를 만들고 싶다면 setter를 제공하지 않는 것이 좋다.
public final class Point {
private final double x;
private final double y;
public Point(double x, double y) {
this.x = x;
this.y = y;
}
public double getX() {
return x;
}
public double getY() {
return y;
}
}
이 객체는 생성 이후 상태가 변경되지 않는다.
getter와 setter가 캡슐화의 전부는 아니다
필드를 private으로 바꾸고 모든 필드에 getter와 setter를 제공했다고 해서 완벽한 캡슐화가 되는 것은 아니다.
다음과 같은 코드는 여전히 객체의 상태를 외부에서 자유롭게 변경하게 만든다.
public void setBalance(BigDecimal balance) {
this.balance = balance;
}
도메인 규칙이 있다면 단순 setter보다 의미 있는 메서드를 제공하는 것이 좋다.
public void deposit(BigDecimal amount) {
validatePositive(amount);
balance = balance.add(amount);
}
public void withdraw(BigDecimal amount) {
validateWithdraw(amount);
balance = balance.subtract(amount);
}
객체가 자신의 비즈니스 규칙을 직접 보호할 수 있게 된다.
public 클래스에서는 접근자 메서드를 사용해야 한다
이 아이템의 권장은 특히 public 클래스에 중요하다.
public 클래스는 외부 패키지나 외부 프로젝트에서 사용할 수 있다. 한 번 필드를 공개하면 해당 필드가 사실상 공개 API가 된다.
외부 사용자는 다음과 같이 직접 의존하게 된다.
point.x
point.y
그 순간부터 필드의 이름, 타입, 의미를 쉽게 변경하기 어려워진다.
따라서 public 클래스에서는 필드를 private으로 숨기고 메서드를 통해 접근하도록 만들어야 한다.
package-private 클래스는 예외가 될 수 있다
책에서는 package-private 클래스나 private 중첩 클래스라면 데이터 필드를 직접 노출해도 큰 문제가 없을 수 있다고 설명한다.
class Point {
double x;
double y;
}
이 클래스는 같은 패키지 안에서만 사용할 수 있다.
따라서 변경 영향 범위도 해당 패키지 내부로 제한된다.
package-private 클래스는 통제 범위가 작다
package-private 클래스는 다음 특성을 가진다.
- 외부 패키지에서 사용할 수 없다.
- 사용 범위를 비교적 쉽게 파악할 수 있다.
- 변경 영향을 패키지 내부로 제한할 수 있다.
- 구현 세부사항으로 관리하기 쉽다.
이런 이유로 책에서는 package-private 클래스의 필드 노출을 어느 정도 허용한다.
그래도 접근자 메서드가 더 안전할 수 있다
package-private 클래스라고 해도 필드 직접 접근에는 여전히 단점이 있다.
- 값 검증을 추가하기 어렵다.
- 내부 표현을 변경하기 어렵다.
- 부가 작업을 넣기 어렵다.
- 필드 변경 시 사용 코드를 한꺼번에 수정해야 한다.
따라서 package-private 클래스라도 변경 가능성이 있거나 의미 있는 상태를 가진다면 접근자 메서드를 사용하는 편이 더 안전할 수 있다.
단순한 데이터 전달 객체나 아주 제한된 내부 구현이라면 필드를 직접 사용할 수도 있지만, 습관적으로 모든 필드를 노출하는 것은 피하는 것이 좋다.
public final 필드는 완전한 해결책이 아니다
다음과 같이 필드를 public final로 공개할 수도 있다.
public class Time {
public final int hour;
public final int minute;
public Time(int hour, int minute) {
this.hour = hour;
this.minute = minute;
}
}
final이므로 생성 이후 다른 값을 대입할 수 없다.
time.hour = 10; // 컴파일 오류
가변 public 필드보다는 안전하다.
public final 필드가 줄여주는 문제
public final 필드는 다음 문제를 줄여준다.
- 외부에서 필드를 다시 대입할 수 없다.
- 객체 상태가 생성 이후 변경되지 않는다.
- 여러 코드에서 값이 임의로 변경되는 문제를 방지한다.
하지만 캡슐화 문제를 완전히 해결하지는 못한다.
public final 필드에도 남는 문제
필드 자체가 공개 API라는 사실은 변하지 않는다.
time.hour
를 사용하는 외부 코드가 존재하면 다음 변경이 어려워진다.
- 필드 이름 변경
- 필드 타입 변경
- 내부 표현 변경
- 값 반환 시 추가 계산
- 접근 시 검증이나 로깅 추가
즉 불변성은 높아지지만 정보 은닉의 장점은 여전히 얻지 못한다.
final은 객체 내부까지 불변으로 만들지 않는다
필드가 가변 객체를 참조한다면 final만으로는 충분하지 않다.
public class Group {
public final List<String> members =
new ArrayList<>();
}
members 필드가 다른 리스트를 참조하도록 바꿀 수는 없다.
하지만 리스트의 내용은 변경할 수 있다.
group.members.add("new member");
group.members.clear();
따라서 public final로 공개하려면 참조 대상도 불변이어야 한다.
가변 객체를 전달할 때는 복사본을 고려해야 한다
가변 객체를 다른 메서드나 클래스에 그대로 전달하면 호출된 코드가 객체를 변경할 수 있다.
public void doSomething(Point point) {
point.x = 100;
point.y = 200;
}
호출자는 메서드 실행 이후 값이 바뀌었는지 쉽게 알기 어렵다.
Point point = new Point();
point.x = 10;
point.y = 20;
doSomething(point);
System.out.println(point.x); // 100
방어적 복사
외부 변경으로부터 보호해야 한다면 복사본을 만들어 사용할 수 있다.
public void doSomething(Point point) {
Point localPoint = new Point();
localPoint.x = point.x;
localPoint.y = point.y;
localPoint.x = 100;
}
원본 객체에는 영향을 주지 않는다.
다만 매번 복사해야 한다는 사실 자체가 가변 객체 관리 비용을 보여준다.
가능하다면 처음부터 객체를 불변으로 설계하는 편이 더 안전하다.
가변 public 필드는 스레드 안전성도 해친다
public 가변 필드는 여러 스레드가 동시에 접근할 수 있다.
public class Counter {
public int count;
}
여러 스레드가 다음 코드를 실행하면
counter.count++;
레이스 컨디션이 발생할 수 있다.
객체가 상태 변경을 통제하지 못하기 때문에 동기화나 원자적 연산을 적용하기도 어렵다.
메서드를 통해 상태를 변경하도록 만들면 내부에서 동기화 정책을 적용할 수 있다.
public synchronized void increment() {
count++;
}
public 필드를 직접 공개하면 이런 변경도 클라이언트 코드 수정 없이 적용하기 어렵다.
접근자 메서드 사용 시 주의할 점
접근자 메서드는 public 필드보다 낫지만, 무조건 getter와 setter를 생성하는 방식은 피해야 한다.
모든 필드에 setter를 만들지 않는다
setter가 필요하지 않다면 제공하지 않는다.
가변 객체를 그대로 반환하지 않는다
다음 코드는 내부 컬렉션을 그대로 노출한다.
public List<String> getMembers() {
return members;
}
외부에서 내부 상태를 변경할 수 있다.
group.getMembers().clear();
불변 뷰나 복사본을 반환하는 것이 더 안전하다.
public List<String> getMembers() {
return List.copyOf(members);
}
의미 있는 행동을 제공한다
단순 setter보다 객체의 의도를 표현하는 메서드를 제공한다.
order.changeShippingAddress(address);
account.withdraw(amount);
member.updateProfile(profile);
이렇게 하면 비즈니스 규칙과 상태 변경을 객체 내부에 모을 수 있다.
public 필드와 접근자 메서드 비교
| 구분 | public 필드 | 접근자 메서드 |
|---|---|---|
| 내부 구현 은닉 | 어려움 | 가능 |
| 값 검증 | 불가능 | 가능 |
| 부가 작업 | 불가능 | 가능 |
| 내부 표현 변경 | 어려움 | 상대적으로 쉬움 |
| 불변 객체 설계 | 어려움 | 가능 |
| 변경 추적 | 어려움 | 상대적으로 쉬움 |
| API 확장성 | 낮음 | 높음 |
| 스레드 안전 정책 추가 | 어려움 | 가능 |
실무에서의 권장 방향
public 클래스를 설계할 때는 다음 원칙을 적용하는 것이 좋다.
- 필드는 기본적으로
private으로 선언한다. - 외부에 필요한 동작만 public 메서드로 공개한다.
- 단순 setter보다 의미 있는 상태 변경 메서드를 제공한다.
- 변경이 필요하지 않다면 setter를 제공하지 않는다.
- 가능하면 불변 객체로 만든다.
- 컬렉션이나 배열은 방어적 복사 또는 불변 뷰로 반환한다.
- package-private 클래스라도 변경 가능성이 크다면 필드를 직접 노출하지 않는다.
- public final 필드도 공개 API라는 사실을 기억한다.
핵심 정리
- public 클래스의 필드는 직접 공개하지 않는다.
- 필드는 private으로 감추고 접근자 메서드를 제공한다.
- 메서드를 통하면 검증, 로깅, 계산 등의 부가 작업을 추가할 수 있다.
- 내부 필드 이름이나 표현을 변경해도 공개 메서드를 유지할 수 있다.
- setter는 반드시 제공해야 하는 것이 아니다.
- 단순 getter와 setter보다 의미 있는 행동을 제공하는 것이 좋다.
- package-private 클래스는 필드를 노출해도 영향 범위가 제한적이지만, 접근자 메서드가 더 안전할 수 있다.
- public final 필드는 가변 필드보다 안전하지만 캡슐화를 제공하지는 못한다.
- final은 참조만 고정할 뿐 참조 대상까지 불변으로 만들지는 않는다.
- 가변 객체는 복사본이나 불변 뷰를 통해 노출해야 한다.
한 줄 정리
public 클래스는 내부 상태를 필드로 직접 노출하지 말고, 메서드를 통해 상태를 통제해야 변경에 유연하고 안전한 설계를 만들 수 있다.
아이템 16. 완벽 공략
아이템 16 완벽 공략: Dimension 클래스의 성능 문제
아이템 16에서는 public 클래스가 내부 필드를 직접 공개하면 캡슐화가 깨진다고 설명했다.
이번 완벽 공략에서는 그 문제가 단순히 설계 유연성에만 머무르지 않고, 실제 성능 문제로도 이어질 수 있는 이유를 살펴본다.
대표적인 사례가 java.awt.Dimension 클래스다.
Dimension은 너비와 높이를 다음과 같은 public 가변 필드로 노출한다.
public class Dimension {
public double width;
public double height;
}
겉으로 보면 단순히 두 개의 숫자 필드를 직접 읽고 쓰는 구조일 뿐이다.
하지만 이 설계는 객체를 사용하는 쪽에 지속적인 방어적 복사 비용을 발생시킨다.
Dimension은 내부 상태를 그대로 노출한다
버튼의 크기를 설정하고 다시 조회하는 코드를 생각해보자.
Button button = new Button();
button.setBounds(10, 20, 300, 100);
Dimension size = button.getSize();
System.out.println(size.width);
System.out.println(size.height);
getSize()가 반환하는 타입은 Dimension이다.
클라이언트는 다음과 같이 필드에 직접 접근한다.
size.width;
size.height;
문제는 Dimension이 가변 객체라는 점이다.
외부 코드가 언제든지 값을 변경할 수 있다.
size.width = 1000;
size.height = 500;
객체가 자신의 상태를 통제하지 못하고, 사용하는 쪽에서도 전달받은 객체를 신뢰하기 어려워진다.
성능 문제는 Dimension 자체보다 사용하는 코드에서 발생한다
Dimension 클래스 내부에 특별히 느린 알고리즘이 있는 것은 아니다.
문제는 Dimension을 안전하게 사용하려는 클라이언트 코드가 매번 객체를 복사해야 한다는 데 있다.
예를 들어 메서드로 전달받은 Dimension을 그대로 사용한다고 가정해보자.
public void process(Dimension dimension) {
dimension.width = 500;
}
이 메서드는 원본 객체를 변경한다.
호출자는 메서드 실행 이후 자신의 객체가 변경되었다는 사실을 예상하지 못할 수도 있다.
Dimension original = new Dimension(300, 100);
process(original);
System.out.println(original.width); // 500
이런 부작용을 방지하려면 전달받은 객체를 복사해서 사용해야 한다.
public void process(Dimension dimension) {
Dimension copy = new Dimension(
dimension.width,
dimension.height
);
copy.width = 500;
}
이제 원본은 변경되지 않는다.
하지만 객체를 하나 더 생성해야 한다.
방어적 복사가 필요한 이유
가변 객체를 그대로 공유하면 다음 문제가 발생할 수 있다.
- 호출한 메서드가 객체를 변경할 수 있다.
- 객체를 전달받은 다른 코드가 값을 변경할 수 있다.
- 언제 상태가 변경되었는지 추적하기 어렵다.
- 여러 코드가 같은 인스턴스를 공유하면 예상하지 못한 부작용이 발생한다.
- 멀티스레드 환경에서는 상태 변경 시점을 예측하기 더 어렵다.
따라서 안전하게 사용하려면 복사본을 만들어야 한다.
Dimension safeCopy = new Dimension(
original.width,
original.height
);
이 방어적 복사는 설계를 안전하게 만들지만 비용이 발생한다.
불필요한 객체 생성이 누적된다
객체 하나를 복사하는 비용은 크지 않을 수 있다.
new Dimension(width, height);
하지만 이 작업이 반복되면 이야기가 달라진다.
예를 들어 다음 작업이 수백만 번 실행된다고 가정해보자.
for (int i = 0; i < 1_000_000; i++) {
Dimension copy = new Dimension(
original.width,
original.height
);
consume(copy);
}
이 과정에서 수많은 Dimension 객체가 생성된다.
그 결과 다음과 같은 비용이 발생한다.
- 힙 메모리 사용량 증가
- 객체 생성 비용 증가
- 가비지 컬렉션 대상 증가
- GC 수행 빈도 증가
- CPU와 메모리 사용량 증가
객체 하나의 생성 비용은 작더라도 반복 횟수가 많아지면 전체 성능에 영향을 줄 수 있다.
객체 생성 자체보다 더 큰 문제는 사용의 불편함이다
실제 애플리케이션에서 Dimension을 몇 번 복사한다고 즉시 눈에 띄는 성능 저하가 발생하는 것은 아니다.
성능 문제가 명확하게 드러나려면 상당히 많은 객체를 반복해서 생성해야 할 수 있다.
그러나 더 현실적인 문제는 개발자가 항상 객체의 변경 가능성을 의식해야 한다는 점이다.
Dimension을 전달받을 때마다 다음 고민이 필요하다.
이 객체를 그대로 사용해도 되는가?
다른 코드가 값을 바꿀 가능성은 없는가?
내가 값을 변경하면 호출자에게 영향을 주지 않는가?
복사해서 사용해야 하는가?
이런 고민을 계속 해야 한다는 것 자체가 사용성이 좋지 않다는 의미다.
안전하게 사용하려면 항상 복사해야 한다는 규칙이 생기고, 코드도 불필요하게 장황해진다.
가변 객체를 그대로 전달하면 코드가 불안정해진다
다음과 같이 객체를 다른 메서드에 전달한다고 가정해보자.
Dimension dimension = button.getSize();
doSomething(dimension);
System.out.println(dimension.width);
System.out.println(dimension.height);
doSomething() 내부에서 객체를 변경했는지 외부에서는 쉽게 알 수 없다.
private void doSomething(Dimension dimension) {
dimension.width = 1000;
}
호출자는 doSomething() 이후에도 기존 너비와 높이가 유지될 것이라고 생각할 수 있다.
하지만 실제 값은 변경되어 있다.
이처럼 가변 객체를 공유하면 메서드 호출 전후 상태를 신뢰하기 어려워진다.
안전하게 사용하려면 복사해야 한다
원본 상태를 보호하려면 메서드 호출 전에 복사본을 만들어야 한다.
Dimension dimension = button.getSize();
Dimension copy = new Dimension(
dimension.width,
dimension.height
);
doSomething(copy);
이제 doSomething()이 복사본을 변경하더라도 원본에는 영향을 주지 않는다.
하지만 코드가 복잡해지고 매번 새로운 인스턴스를 생성해야 한다.
즉, Dimension의 잘못된 캡슐화 설계가 모든 클라이언트에게 비용을 전가한 것이다.
Dimension은 불변 객체였어야 한다
책에서 지적하는 핵심도 여기에 있다.
Dimension이 처음부터 불변 객체로 설계되었다면 클라이언트는 방어적 복사를 반복할 필요가 없었을 것이다.
예를 들어 다음과 같이 설계할 수 있다.
public final class Dimension {
private final double width;
private final double height;
public Dimension(double width, double height) {
this.width = width;
this.height = height;
}
public double getWidth() {
return width;
}
public double getHeight() {
return height;
}
}
생성 이후에는 상태가 변경되지 않는다.
Dimension dimension = new Dimension(300, 100);
외부 코드가 값을 바꿀 수 없기 때문에 여러 곳에서 안전하게 공유할 수 있다.
불변 객체라면 방어적 복사가 필요하지 않다
불변 객체는 상태가 변경되지 않는다.
따라서 다음처럼 동일한 인스턴스를 전달해도 안전하다.
public void process(Dimension dimension) {
System.out.println(dimension.getWidth());
}
호출된 메서드에서 값을 변경할 방법이 없기 때문이다.
이로 인해 다음 장점이 생긴다.
- 방어적 복사 불필요
- 객체 공유 가능
- 상태 추적 용이
- 스레드 안전성 향상
- 코드 단순화
- 불필요한 객체 생성 감소
가변 객체와 불변 객체 비교
| 구분 | 가변 Dimension | 불변 Dimension |
|---|---|---|
| 상태 변경 | 가능 | 불가능 |
| 외부에서 필드 수정 | 가능 | 불가능 |
| 방어적 복사 | 자주 필요 | 대부분 불필요 |
| 객체 공유 | 위험할 수 있음 | 안전함 |
| 스레드 안전성 | 별도 동기화 필요 | 상대적으로 안전 |
| 객체 생성 비용 | 복사로 인해 증가 가능 | 공유 가능 |
| 코드 복잡도 | 높아짐 | 낮아짐 |
| 상태 추적 | 어려움 | 쉬움 |
접근자 메서드만 추가해도 완전한 해결은 아니다
필드를 private으로 바꾸고 getter와 setter를 제공하면 직접 필드 노출 문제는 줄어든다.
public class Dimension {
private double width;
private double height;
public double getWidth() {
return width;
}
public void setWidth(double width) {
this.width = width;
}
}
하지만 setter가 존재하는 한 객체는 여전히 가변이다.
다른 코드가 언제든 상태를 변경할 수 있고, 안전한 공유를 위해서는 방어적 복사를 고려해야 한다.
따라서 단순히 getter와 setter를 제공하는 것보다, 변경이 필요하지 않은 값 객체라면 불변 객체로 만드는 편이 더 좋다.
값 객체는 불변으로 설계하는 것이 유리하다
Dimension은 너비와 높이라는 값을 표현한다.
이런 객체는 보통 식별자보다 상태 값 자체가 중요하다.
width = 300
height = 100
같은 값을 가진 두 객체는 논리적으로 같은 크기를 나타낸다.
이런 값 객체는 불변으로 만들면 다음 장점이 있다.
- 생성 후 상태가 변하지 않는다.
- 여러 코드에서 안전하게 공유할 수 있다.
equals()와hashCode()구현이 단순해진다.- 캐싱하기 쉽다.
- 멀티스레드 환경에서 안전하다.
- 방어적 복사가 줄어든다.
실무에서의 적용
다음과 같은 객체들은 가능하면 불변으로 설계하는 것이 좋다.
- 좌표
- 크기
- 금액
- 기간
- 날짜 범위
- 주소
- 전화번호
- 페이지 요청 조건
- 검색 조건
- 식별자 값 객체
예를 들어 크기 객체를 다음과 같이 만들 수 있다.
public record Size(
int width,
int height
) {
public Size {
if (width < 0 || height < 0) {
throw new IllegalArgumentException(
"너비와 높이는 0 이상이어야 합니다."
);
}
}
}
Java 16 이상에서는 record를 활용해 간결한 불변 값 객체를 만들 수도 있다.
핵심 정리
Dimension은 내부 가변 필드를public으로 노출한다.- 클라이언트는 객체 상태를 신뢰하기 어려워진다.
- 안전하게 사용하려면 방어적 복사가 필요하다.
- 반복되는 방어적 복사는 객체 생성과 GC 비용을 증가시킨다.
- 실제로는 성능 문제뿐 아니라 사용의 불편함이 더 큰 문제일 수 있다.
- 가변 객체를 다른 코드와 공유하면 부작용을 추적하기 어렵다.
Dimension은 처음부터 불변 객체로 설계되었어야 한다.- 불변 객체는 방어적 복사 없이 안전하게 공유할 수 있다.
- 값 객체는 가능한 한 불변으로 설계하는 것이 좋다.
한 줄 정리
Dimension의 성능 문제는 필드 접근 자체가 느려서가 아니라, 가변 상태를 안전하게 다루기 위해 클라이언트가 반복적으로 방어적 복사를 해야 하는 잘못된 설계에서 발생한다.