이펙티브 자바 4장-1) 클래스와 인터페이스

동동주·2025년 10월 30일

이펙티브 자바

목록 보기
3/13

💡 Item 16. public 클래스에서는 public 필드가 아닌 접근자 메서드를 사용하라

단순히 필드만 있는 클래스, 왜 문제일까?

예를 들어 아래와 같은 Point 클래스가 있다고 해볼게요.

public class Point {
    public double x;
    public double y;
}

이 클래스는 필드를 직접 공개(public) 하고 있어서
외부에서 바로 x, y 값을 마음대로 변경할 수 있습니다.

👉 즉, 캡슐화(encapsulation) 가 전혀 안 되어 있어요.

그래서 이런 문제가 생깁니다.

내부 구현(필드 구조)을 바꾸면 클라이언트 코드도 전부 수정해야 함

불변식(값의 유효성)을 보장할 수 없음

값을 바꿀 때 추가 로직(로그, 검증 등)을 넣을 수 없음

해결 방법 — 접근자 메서드(getter/setter) 사용

public class Point {

    private double x;
    private double y;

    public Point() {}

    public Point(double x, double y) {
        this.x = x;
        this.y = 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;
    }
}

이제 x, y는 private이기 때문에 외부에서 바로 접근할 수 없고,
getX(), setX() 같은 메서드를 통해서만 값을 조작할 수 있습니다.

이렇게 하면 좋은 점은?

✔ 내부 구조를 마음대로 바꿔도 외부 코드는 그대로 유지
✔ 값을 변경할 때 검증 로직, 로그 출력 등 추가 가능
✔ 객체의 상태를 더 안전하게 관리 가능

즉, 캡슐화된 클래스는 유지보수성과 확장성이 훨씬 좋아집니다.

그럼 항상 getter/setter를 써야 할까?

꼭 그렇진 않습니다.

클래스가 외부에 공개되지 않은 경우, 즉
package-private 클래스나 private 중첩 클래스라면
필드를 직접 공개해도 괜찮습니다.

이런 경우에는 클래스가 자기 패키지 안 또는 자기 클래스 안에서만 쓰이기 때문에
필드 직접 접근이 오히려 더 단순하고 효율적일 수 있어요.

💬 단, 이 경우도 “패키지가 너무 크거나 복잡해진다면 설계 문제”일 수 있습니다.

불변 객체의 경우는?

불변 객체(immutable object)라면
필드가 final이고 변경 불가능하니 public으로 공개해도 괜찮을 수 있습니다.

하지만 주의할 점이 있어요 👇

불변식은 보장되지만,

API 구조(예: 좌표를 double → BigDecimal로 바꾸는 등)를 변경하려면
여전히 클라이언트 코드 수정이 필요합니다.

그래서 대부분의 경우 여전히 getter 방식이 더 안전하고 유연합니다.

⚠️ 실제 사례 — java.awt.Point

java.awt.Point와 Dimension 클래스는
필드(x, y, width, height)를 public으로 공개했습니다.

이 때문에 내부 구현 변경이나 유효성 검증이 어려워
캡슐화 실패 사례로 자주 언급됩니다.

즉, “이렇게 하면 안 된다”의 대표적인 예시예요.

결론:
외부에 공개되는 클래스라면 항상 캡슐화를 지키자.
내부 구현은 언제든 바뀔 수 있지만, API는 가능한 바꾸지 말아야 하니까.


💡 Item 17. 변경 가능성을 최소화하라

“가능하면 불변 객체(immutable object) 로 만들어라”

불변 클래스란?

한 번 생성되면 상태(필드 값)가 절대 바뀌지 않는 클래스

예: String, Integer, BigDecimal, BigInteger

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

상태 변경 메서드(setter) 를 제공하지 않는다.

클래스를 final로 선언해 상속을 막는다.

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

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

가변 객체를 참조한다면 방어적 복사(defensive copy) 를 사용해 외부에서 접근 못 하게 한다.

불변 객체의 장점

설계·구현이 단순하고, 오류가 적음

스레드 안전(Thread-safe) → 동기화 불필요

자유로운 공유 가능 (캐싱, 재사용에 유리)

Map의 키나 Set의 원소로 사용하기 적합

실패 원자성(에러가 나도 객체 상태가 변하지 않음)을 자연스럽게 가짐

단점

값이 달라질 때마다 새로운 객체 생성 → 성능 저하 가능
→ 해결책:

자주 쓰는 인스턴스는 캐싱(static factory)

많은 변경이 필요한 경우 가변 동반 클래스 사용 (예: StringBuilder)

불변 클래스 설계 팁

public 생성자 대신 정적 팩토리 메서드 사용
→ 캐싱 등 유연한 변경 가능

getter만 두고, 불필요한 setter는 절대 만들지 말자!

모든 필드는 private final,
생성자는 객체의 모든 불변식을 만족해야 함

결론:
“가능한 한 객체를 불변으로 만들자.
불변으로 만들 수 없다면, 변경 가능성을 최소화하라.”


💡 Item 18. 상속보다는 컴포지션을 사용하라

  • 상속(extends)은 객체지향의 대표적인 특징 중 하나지만, 무분별하게 사용하면 오히려 캡슐화를 깨뜨리고 결합도를 높이는 원인이 됩니다.

상속의 문제점

캡슐화를 깨뜨린다.
→ 하위 클래스는 상위 클래스의 내부 구현에 의존하게 된다.
→ 상위 클래스가 바뀌면 하위 클래스가 깨질 수 있다.

강결합 문제.
→ 상위 클래스의 변경이 그대로 하위 클래스에 전파된다.

예상치 못한 동작.
→ 상위 클래스의 메서드가 내부적으로 다른 메서드를 호출하면,
하위 클래스에서 재정의한 메서드가 의도치 않게 중복 호출될 수 있다.

⚡ 예시: HashSet을 상속할 때의 문제

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }

    public static void main(String[] args) {
        InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
        s.addAll(List.of("틱", "탁탁", "펑"));
        System.out.println(s.getAddCount()); // ❌ 6 출력 (의도는 3)
    }
}

addAll()이 내부적으로 add()를 호출하기 때문에 addCount가 두 번 증가합니다.
→ 하위 클래스가 상위 클래스의 내부 구현(addAll()이 add()를 호출한다는 사실)에 의존한 결과!

해결책: 상속 대신 컴포지션(Composition)

컴포지션이란, 기존 클래스를 확장하지 않고 내부에 포함(위임)시켜 사용하는 방법입니다.

전달(Forwarding) 클래스

public class ForwardingSet<E> implements Set<E> {
    private final Set<E> s;

    public ForwardingSet(Set<E> s) { this.s = s; }

    public boolean add(E e) { return s.add(e); }
    public boolean addAll(Collection<? extends E> c) { return s.addAll(c); }
    // 나머지 메서드도 그대로 전달
}

래퍼(Wrapper) 클래스

public class InstrumentedSet<E> extends ForwardingSet<E> {
    private int addCount = 0;

    public InstrumentedSet(Set<E> s) {
        super(s);
    }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() { return addCount; }
}

이제 어떤 Set 구현체라도 감싸서(add 기능을 추가한) InstrumentedSet으로 사용할 수 있습니다 👇

Set<Instant> times = new InstrumentedSet<>(new TreeSet<>());
Set<String> words = new InstrumentedSet<>(new HashSet<>());

→ 유연하고 재사용 가능한 구조!


💡 Item 19. 상속을 고려해 설계하고, 그렇지 않다면 상속을 금지하라

“상속을 허용하려면, 내부 구현까지 공개해야 한다.”

상속을 고려했다는 건?

상속을 허용하려면 단순히 extends만 가능하게 만드는 게 아니라,
“하위 클래스가 안전하게 확장할 수 있도록” 내부 동작과 제약을 문서로 남겨야 한다는 뜻이에요.

  • 어떤 메서드를 재정의해도 되는지
  • 그 메서드가 어떤 타이밍에, 어떤 흐름에서 호출되는지
  • 재정의할 때 사이드 이펙트(예상치 못한 영향) 가 없는지
    이걸 명확히 알려줘야 해요.

@implSpec 태그 — “이 메서드는 이렇게 동작합니다”

상속용 클래스를 만들 땐, 자바독에 @implSpec을 사용해 내부 동작 방식을 문서화할 수 있어요.

/**
 * @implSpec
 * 이 메서드는 iterator()를 이용해 remove 작업을 수행합니다.
 */
public boolean remove(Object o) { ... }

이런 식으로 작성하면 하위 클래스 개발자가
“iterator를 재정의하면 remove 동작이 달라질 수도 있겠구나”
하고 이해할 수 있음.

📌 @implSpec은 자바 8~11에서는 수동 설정해야 하고,
자바 17에서도 여전히 선택 옵션이에요.

상속을 돕는 “Hook 메서드” 설계

내부 구현 중간에 끼어들 수 있는 protected 메서드를 열어주는 것도 방법이에요.

예를 들어 AbstractListremoveRange()
일반 사용자는 몰라도 되지만,
하위 클래스가 clear()의 성능을 개선할 수 있도록 설계된 hook 메서드예요.

➡️ 이런 메서드는 하위 클래스 개발자를 위한 문서화가 필요합니다.

생성자에서는 재정의 메서드 호출 금지 ❌

다음 예시를 보세요.

class Super {
    public Super() {
        overrideMe(); // ❌
    }
    public void overrideMe() {}
}

class Sub extends Super {
    private final Instant instant;
    public Sub() { instant = Instant.now(); }

    @Override
    public void overrideMe() {
        System.out.println(instant); // 아직 초기화 안됨 → null
    }
}
  • 상위 클래스 생성자가 먼저 실행되기 때문에
    instant가 아직 초기화되지 않은 상태에서 overrideMe()가 호출됩니다.
  • NullPointerException이나 예기치 못한 동작이 발생할 수 있어요.

👉 생성자, clone, readObject 같은 객체 생성 시점 메서드에서는
재정의 가능한 메서드를 절대 호출하지 말아야 합니다.

상속을 허용하지 않으려면?

상속을 고려하지 않은 일반 클래스라면
차라리 아예 상속을 막는 게 낫습니다.

  • final로 선언
  • 또는 생성자를 private / package-private로 두고
    정적 팩터리 메서드로 객체를 생성하게 하기

이렇게 하면 클래스 변경 시 하위 클래스가 깨질 위험이 줄어듭니다.

상속을 꼭 해야 한다면?

  1. 인터페이스 상속을 우선 고려하자 (구현 상속보다 안전)
  2. 재정의 가능한 메서드 내부에서는 다른 재정의 메서드 호출 금지
  3. 공통 로직은 private 도우미 메서드(helper) 로 옮겨서 안전하게 호출
class Super {
    public Super() { helper(); }  // 안전
    private void helper() { System.out.println("help!"); }
}

상속용 클래스는 “테스트”로 검증하라

  • 하위 클래스를 직접 여러 개 만들어보며 테스트
  • 전혀 쓰이지 않는 protected 멤버는 private로 줄이기
  • 문서화된 부분은 API 계약으로서 영원히 책임져야 함

“상속용 클래스는 한번 공개하면 되돌릴 수 없는 약속이다.”


참고블로그:
https://github.com/back-end-study/effective-java/tree/main/4%EC%9E%A5_%ED%81%B4%EB%9E%98%EC%8A%A4%EC%99%80_%EC%9D%B8%ED%84%B0%ED%8E%98%EC%9D%B4%EC%8A%A4

0개의 댓글