[이펙티브 자바] Class와 상속
by rowing0328※ 책 내용을 바탕으로 제 관점에서 풀어 쓴 글입니다. 일부 내용이 다를 수 있습니다.
아이템 15 - 클래스와 멤버의 접근 권한을 최소화하라
public class의 instance field
- Public으로 열 경우 Thread-safe하지 않음
클래스의 인스턴스 필드를 public으로 열어두면 외부에서 필드에 직접 접근이 가능해진다.
이로 인해 여러 스레드에서 동시에 접근할 경우 데이터 무결성을 보장할 수 없으며,
Lock 규칙을 적용하기 어려워진다. - 필요한 경우에만 public static final 사용
상수는 public static final로 선언하여 외부에서 읽기만 가능하도록 설계할 수 있다.
예를 들어, ITZIP_BASE_PAGE와 같은 상수는 공개 가능하다. - public static final 배열은 불변하지 않음
배열은 public static final로 선언하더라도, 배열 요소는 외부에서 수정할 수 있다.
따라서 배열은 외부에 노출하기 전에 복사본을 반환하거나, 불변 리스트로 변환하여 제공해야 한다.
코드 예제
// 환율 계산
public class Capsule {
private String name;
private int cost;
public float getDolorCost() {
return 1050 / cost;
}
public int getWonCost() {
return cost;
}
}
설명
name과 cost 필드는 private으로 선언되어 외부에서 직접 접근할 수 없다.
필드 값이 필요한 경우, getter 메서드를 통해 안전하게 값을 반환하도록 설계되어 있다.
배열의 두 가지 해결책
배열을 외부에 노출하지 않으려면 아래 두 가지 방법이 있다.
private static final Page[] PAGE_INFO = {...};
public static final List<Page> VALUES = Collections.unmodifiableList(Arrays.asList(PAGE_INFO));
- 불변 리스트를 제공하는 방식
배열을 private으로 선언한 뒤, 이를 기반으로 Collections.unmodifiableList를 사용해 읽기 전용 리스트를 생성하고 외부에 제공한다.
이 방식은 배열의 직접적인 수정은 막을 수 있지만, 원본 배열(PAGE_INFO)이 변경되면 리스트도 영향을 받는다.
public static final Page[] values() {
return PAGE_INFO.clone();
}
- 복사본을 반환하는 방식
외부에서 배열을 요청할 때, 항상 복사본을 반환하여 원본 배열이 수정되지 않도록 방어한다.
이 방법은 원본 배열을 완벽히 보호할 수 있지만, 배열을 매번 복사해야 하므로 성능에 약간의 비용이 발생할 수 있다.
아이템 16 - public class에서는 get 메서드를 통해 필드에 접근하라
캡슐화의 유용한 사례
초기에는 public String name; 처럼 데이터를 노출하더라도, 이후 내부 정책이 변경되면 큰 문제가 발생할 수 있다.
예를 들어, 내부에서 price 대신 cost라는 용어를 사용하고 싶어질 경우, 외부는 여전히 price로 접근해야 할 수도 있다.
캡슐화를 통해 외부는 내부 데이터의 관리 방식을 알 필요가 없으며, 이는 유연한 설계를 가능하게 한다.
캡슐화 구현
예제 코드
public class ItemInfo {
private String name;
private int price;
public ItemInfo(String name, int price) {
this.name = name;
this.price = price;
}
public String getName() {
return name;
}
public void setPrice(int price) {
this.price = price;
}
public int getPrice() {
return price;
}
}
설명
필드는 private으로 선언하고, 값에 접근하거나 수정할 수 있도록 getter와 setter 메서드를 제공한다.
특정 필드는 읽기 전용으로 설정할 수 있으며, 이를 위해 getter만 제공하고 setter를 생략하여 데이터의 불변성을 보장한다.
예를 들어, name은 변경할 수 없고, price는 수정 가능하도록 설계할 수 있다.
아이템 17 - 변경 가능성을 최소화하라
Immutable Class (불변 클래스)
불변 클래스란, 객체가 생성된 이후 상태(내부 필드 값)가 절대 변경되지 않는 클래스를 말한다.
이러한 클래스는 객체의 불변성을 보장하며, 특히 멀티스레드 환경이나 캐싱과 같은 상황에서 안전하고 효율적으로 활용할 수 있다.
불변 클래스를 만드는 5가지 원칙
- 상태 변경 메서드 제공 금지
set 메서드와 같은 변경 메서드를 제공하지 않는다.
객체가 생성된 이후 내부 상태는 절대로 변경되지 않아야 한다. - 클래스 확장을 방지
상속을 막기 위해 final 키워드를 사용한다.
상속이 가능하면 하위 클래스에서 내부 상태를 변경하는 메서드를 추가할 수 있다. - 모든 필드를 final로 선언
모든 필드는 반드시 final로 선언하여, 객체 생성 시 초기화 후 변경을 방지한다. - 모든 필드를 private으로 선언
외부에서 직접 접근을 막아야한다.
이를 통해 객체의 불변성을 유지할 수 있다. - 가변 컴포넌트에 대한 접근 차단
가변 객체를 필드로 포함할 경우, 이를 방어적 복사로 관리한다.
이를 통해 외부에서 내부 상태를 변경할 수 없도록 보호한다.
예제 코드
@Getter
class AddressInfo {
private String address;
}
@AllArgsConstructor
final class User {
private final String phone;
private final List<AddressInfo> addressInfoList;
public List<String> getAddressList() {
return addressInfoList.stream()
.map(AddressInfo::getAddress)
.collect(Collectors.toList());
}
}
설명
User 클래스는 final로 선언되어 상속이 불가능하다.
모든 필드(phone, addressInfoList)는 final로 선언되어, 객체 생성 후 변경될 수 없다.
addressInfoList는 가변 객체(List)이므로, 내부적으로 처리하며 외부에는 직접 노출하지 않고 필요한 정보만 추출하여 반환한다.
BigInteger 예시로 본 Immutable Class
BigInteger bigInteger = new BigInteger("10000");
System.out.println(bigInteger.add(new BigInteger("100"))); // 10100
System.out.println(bigInteger); // 10000
add 메서드는 새로운 객체를 반환하며, 기존 객체는 변경되지 않고 그대로 유지된다.
Immutable Class의 조건
- Thread Safe
여러 스레드에서 동시에 접근하더라도 상태가 변경되지 않으므로 안전하다. - Failure Atomicity
예외가 발생하더라도 객체의 상태는 항상 유효하게 유지된다. - 값이 다르면 새로운 객체 생성
기존 객체를 수정하지 않고, 항상 새로운 객체를 생성하여 반환한다.
중간 단계 상태(객체 생성 도중 상태) 관리
public static ImmutableClass of(String name) {
return new ImmutableClass(name);
}
new 키워드를 사용한 직접 생성 대신 정적 팩토리 메서드를 사용한다.
이를 통해 객체 생성 과정에서 발생할 수 있는 중간 상태를 안전하게 관리할 수 있다.
Immutable VO (Value Object)
예제 코드
@Entity
@Setter
@Getter
public class Person {
@Id
private Long id;
private String name;
private float height;
}
@Getter
public class PersonVo {
private final String name;
private final float height;
public PersonVo(String name, float height) {
this.name = name;
this.height = height;
}
public static PersonVo from(Person p) {
return new PersonVo(p.getName(), p.getHeight());
}
}
설명
- Person Entity
데이터베이스와 매핑되며, 값이 변경 가능하다.
@Setter를 통해 값을 수정할 수 있도록 설계되어 있다. - PersonVo
불변 객체로 설계되어 값이 변경되지 않는다.
생성자를 통해서만 값을 설정하며, 모든 필드는 final로 선언되어 변경할 수 없다.
아이템 18 - 상속보다는 컴포지션을 사용하라
상속의 문제점
- 캡슐화 파괴
상속은 부모 클래스의 내부 구현 세부 사항을 자식 클래스가 사용할 수 있게 한다.
이로 인해 부모 클래스의 내부 구조가 변경되면 자식 클래스에 영향을 미쳐 유지보수가 어려워질 수 있다. - 부적절한 동작 가능성
상속된 메서드가 부모 클래스와 자식 클래스 간에 의도와 다르게 작동할 수 있다.
부모 클래스가 업데이트되거나 메서드가 추가되면 자식 클래스의 동작이 예기치 않게 변할 가능성이 있다.
예제 코드
public class InstrumentedHashSet<E> extends HashSet<E> {
private int cnt = 0;
@Override
public boolean add(E e) {
cnt++;
return super.add(e);
}
@Override
public boolean addAll(Collection<? extends E> c) {
cnt += c.size();
return super.addAll(c);
}
}
설명
addAll() 메서드는 여러 요소를 추가하기 위해 내부적으로 add() 메서드를 반복 호출한다.
이로 인해 cnt가 실제 추가된 요소 수보다 더 많이 증가하는 문제가 발생한다.
상속으로 인해 부모 클래스의 메서드 호출 방식이 자식 클래스의 의도와 충돌하는 상황을 보여주는 사례이다.
상속을 대체할 컴포지션(Composition) 사용
컴포지션이란?
컴포지션은 기존 클래스의 인스턴스를 새로운 클래스의 필드로 포함하여 기능을 재사용하는 방식이다.
이 방법은 직접 상속하지 않고 캡슐화를 유지하면서 기존 클래스의 기능을 활용할 수 있다.
Forwarding 방식을 사용하여, 새로운 클래스에서 기존 클래스의 메서드를 호출함으로써 기능을 위임할 수 있다. 아래는 컴포지션을 활용한 예제 코드이다.
예제 코드
public class InstrumentedSet<E> {
private final Set<E> set;
private int cnt = 0;
public InstrumentedSet(Set<E> set) {
this.set = set;
}
public boolean add(E e) {
cnt++;
return set.add(e);
}
public boolean addAll(Collection<? extends E> c) {
cnt += c.size();
return set.addAll(c);
}
}
설명
HashSet 외에도 TreeSet, LinkedHashSet 등 다른 구현체를 유연하게 포함할 수 있다.
부모 클래스에 직접 의존하지 않으므로 변경에 강건한 설계를 제공한다.
Composite Pattern (합성 패턴)
구조
- Base Component
공통 인터페이스나 추상 클래스를 정의하여 기본 동작을 선언한다. - Leaf
개별 구성 요소를 나타내는 클래스이며, 실제 작업을 수행한다. - Composite
복합 객체를 표현하는 클래스이며, 하나 이상의 Base Component를 포함한다.
Leaf와 Composite를 동일하게 처리할 수 있도록 기능을 위임하거나 조합한다.
코드 예제
interface Shape {
void draw();
}
class Triangle implements Shape {
public void draw() {
System.out.println("Draw Triangle");
}
}
class Rectangle implements Shape {
public void draw() {
System.out.println("Draw Rectangle");
}
}
class Drawer implements Shape {
private List<Shape> shapes = new ArrayList<>();
public void addShape(Shape shape) {
shapes.add(shape);
}
public void draw() {
shapes.forEach(Shape::draw);
}
}
설명
각 구성 요소(Triangle, Rectangel)는 독립적으로 사용이 가능하다.
복합 객체(Drawer)는 단일 객체처럼 동작하며, 이를 통해 코드의 재사용성과 확장성이 크게 향상된다.
상속과 컴포지션 비교
상속 | 컴포지션 |
부모 - 자식 간 강한 결합 | 약한 결합 유지 |
캡슐화 파괴 가능성 | 캡슐화를 유지 |
부모 클래스 변경에 취약 | 부모 클래스 변경에 강건 |
is - a 관계에서 적합 | has - a 관계에서 적합 |
아이템 19 - 상속을 고려해 설계하고 문서화하라
상속 설계 시 고려할 점
- 상속의 논리적 적합성
상속을 허용하려면 부모 클래스의 동작을 상속받아 확장하는 것이 논리적으로 타당해야 한다. - 내부 구현에 대한 의존 최소화
하위 클래스가 부모 클래스의 내부 구현 세부 사항에 의존하지 않도록 설계해야 한다. - 메서드 동작과 제약의 명확한 문서화
부모 클래스의 재정의 가능한 메서드의 동작과 제약 조건을 명확히 문서화하여, 하위 클래스에서 예측 가능한 동작을 구현할 수 있도록 해야 한다.
잘못된 상속 설계의 예시
코드 예제
public class Super {
public Super() {
overrideMe(); // 생성자에서 재정의 가능한 메서드를 호출
}
public void overrideMe() {
}
}
public class Sub extends Super {
private final Instant instant;
public Sub() {
instant = Instant.now();
}
@Override
public void overrideMe() {
System.out.println(instant);
}
}
설명
Super 클래스의 생성자에서 overrideMe() 메서드를 호출한다.
Sub 클래스는 overrideMe()를 재정의했으며, 이 메서드에서 instant를 참조한다.
그러나 instant가 초기화되기 전에 메서드가 호출되므로, 결과적으로 null이 출력된다.
상속을 금지하는 방법
public final class ProhibitInheritance {
}
- 클래스를 final로 선언
클래스를 final로 선언하면 상속이 불가능하다.
이 방법은 간단하면서도 확실하게 상속을 금지할 수 있다.
@Getter
public class ProhibitInheritance {
private int sum;
private ProhibitInheritance() {
}
private ProhibitInheritance(int sum) {
this.sum = sum;
}
public static ProhibitInheritance getInstance() {
return new ProhibitInheritance();
}
}
- 모든 생성자를 private 또는 package-private으로 선언
생성자를 private 또는 package-private으로 선언하여 상속을 방지한다.
외부에서 인스턴스를 생성하려면 정적 팩토리 메서드를 사용하도록 설계한다.
/**
* @implSpec
* 해당 메서드는 전달받은 색상으로 모든 도형을 그립니다.
*/
public void draw(String color) {
for (Shape shape : shapes) {
shape.draw(color);
}
}
- @implSpec 어노테이션 활용
@implSpec 어노테이션은 상속 가능한 메서드의 동작을 설명할 때 사용된다.
재정의해야 할 메서드의 조건과 제약을 명시함으로써, 상속 설계의 의도를 문서화할 수 있다.
상속 설계 시 검증
- Clone 및 readObject와 같은 재정의 가능한 메서드 호출 피하기
이들 메서드는 재정의 가능하므로, 상속 설계 시 직접 호출하지 않도록 주의해야 한다.
불필요한 호출은 예측 불가능한 동작을 초래할 수 있다. - 재정의 가능한 메서드 호출 여부 확인
부모 클래스에서 직접 또는 간접적으로 재정의 가능한 메서드를 호출하지 않도록 설계해야 한다.
특히, 생성자나 초기화 블록에서 이러한 메서드를 호출하면 예기치 않은 동작이 발생할 수 있다.
인터페이스와 컴포지션을 활용한 상속 대안
- 인터페이스를 통한 구현 추천
인터페이스는 명확한 계약을 정의하며, 구현에 대한 의존성을 줄인다. - 컴포지션 (Composition)
기능 확장이 필요하면 상속 대신 기존 클래스의 인스턴스를 포함하는 방식을 사용한다.
마무리
코드를 작성하면서 "이 정도는 괜찮겠지"라는 생각으로
접근 권한을 넓히거나, 불변성을 간과하거나,
상속을 남용했던 경험이 누구나 한 번쯤은 있을 것이다.
하지만 이런 작은 실수들이 결국에는 유지보수의 악몽이 되고,
시스템의 복잡성을 크게 증가시킨다는 것을
다시 한번 깨닫게 됐다.
모든 설계에 중심에는 변경에 강건하고 협업에 친화적인 코드라는
목표가 있어야 한다고 생각한다.
이를 위해 더 많은 고민과 경험이 필요하다는 사실도
늘 염두에 두어야 할 것이다.
'📚Book Archive > Effective Java' 카테고리의 다른 글
[이펙티브 자바] Generic 으로 만들어 사용하기 (0) | 2025.01.03 |
---|---|
[이펙티브 자바] Interface와 Class 설계 원칙 (0) | 2024.12.30 |
[이펙티브 자바] 모든 객체의 공통 메서드 (0) | 2024.12.13 |
[이펙티브 자바] 객체 파괴 (0) | 2024.12.07 |
[이펙티브 자바] 객체 생성 (0) | 2024.12.06 |
블로그의 정보
코드의 여백
rowing0328