예를 들어 아래와 같은 Point 클래스가 있다고 해볼게요.
public class Point {
public double x;
public double y;
}
이 클래스는 필드를 직접 공개(public) 하고 있어서
외부에서 바로 x, y 값을 마음대로 변경할 수 있습니다.
👉 즉, 캡슐화(encapsulation) 가 전혀 안 되어 있어요.
그래서 이런 문제가 생깁니다.
내부 구현(필드 구조)을 바꾸면 클라이언트 코드도 전부 수정해야 함
불변식(값의 유효성)을 보장할 수 없음
값을 바꿀 때 추가 로직(로그, 검증 등)을 넣을 수 없음
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() 같은 메서드를 통해서만 값을 조작할 수 있습니다.
이렇게 하면 좋은 점은?
✔ 내부 구조를 마음대로 바꿔도 외부 코드는 그대로 유지
✔ 값을 변경할 때 검증 로직, 로그 출력 등 추가 가능
✔ 객체의 상태를 더 안전하게 관리 가능
즉, 캡슐화된 클래스는 유지보수성과 확장성이 훨씬 좋아집니다.
꼭 그렇진 않습니다.
클래스가 외부에 공개되지 않은 경우, 즉
package-private 클래스나 private 중첩 클래스라면
필드를 직접 공개해도 괜찮습니다.
이런 경우에는 클래스가 자기 패키지 안 또는 자기 클래스 안에서만 쓰이기 때문에
필드 직접 접근이 오히려 더 단순하고 효율적일 수 있어요.
💬 단, 이 경우도 “패키지가 너무 크거나 복잡해진다면 설계 문제”일 수 있습니다.
불변 객체(immutable object)라면
필드가 final이고 변경 불가능하니 public으로 공개해도 괜찮을 수 있습니다.
하지만 주의할 점이 있어요 👇
불변식은 보장되지만,
API 구조(예: 좌표를 double → BigDecimal로 바꾸는 등)를 변경하려면
여전히 클라이언트 코드 수정이 필요합니다.
그래서 대부분의 경우 여전히 getter 방식이 더 안전하고 유연합니다.
java.awt.Point와 Dimension 클래스는
필드(x, y, width, height)를 public으로 공개했습니다.
이 때문에 내부 구현 변경이나 유효성 검증이 어려워
캡슐화 실패 사례로 자주 언급됩니다.
즉, “이렇게 하면 안 된다”의 대표적인 예시예요.
결론:
외부에 공개되는 클래스라면 항상 캡슐화를 지키자.
내부 구현은 언제든 바뀔 수 있지만, API는 가능한 바꾸지 말아야 하니까.
“가능하면 불변 객체(immutable object) 로 만들어라”
한 번 생성되면 상태(필드 값)가 절대 바뀌지 않는 클래스
예: String, Integer, BigDecimal, BigInteger
상태 변경 메서드(setter) 를 제공하지 않는다.
클래스를 final로 선언해 상속을 막는다.
모든 필드를 final로 선언한다.
모든 필드를 private로 선언한다.
가변 객체를 참조한다면 방어적 복사(defensive copy) 를 사용해 외부에서 접근 못 하게 한다.
설계·구현이 단순하고, 오류가 적음
스레드 안전(Thread-safe) → 동기화 불필요
자유로운 공유 가능 (캐싱, 재사용에 유리)
Map의 키나 Set의 원소로 사용하기 적합
실패 원자성(에러가 나도 객체 상태가 변하지 않음)을 자연스럽게 가짐
값이 달라질 때마다 새로운 객체 생성 → 성능 저하 가능
→ 해결책:
자주 쓰는 인스턴스는 캐싱(static factory)
많은 변경이 필요한 경우 가변 동반 클래스 사용 (예: StringBuilder)
public 생성자 대신 정적 팩토리 메서드 사용
→ 캐싱 등 유연한 변경 가능
getter만 두고, 불필요한 setter는 절대 만들지 말자!
모든 필드는 private final,
생성자는 객체의 모든 불변식을 만족해야 함
결론:
“가능한 한 객체를 불변으로 만들자.
불변으로 만들 수 없다면, 변경 가능성을 최소화하라.”
캡슐화를 깨뜨린다.
→ 하위 클래스는 상위 클래스의 내부 구현에 의존하게 된다.
→ 상위 클래스가 바뀌면 하위 클래스가 깨질 수 있다.
강결합 문제.
→ 상위 클래스의 변경이 그대로 하위 클래스에 전파된다.
예상치 못한 동작.
→ 상위 클래스의 메서드가 내부적으로 다른 메서드를 호출하면,
하위 클래스에서 재정의한 메서드가 의도치 않게 중복 호출될 수 있다.
⚡ 예시: 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()를 호출한다는 사실)에 의존한 결과!
컴포지션이란, 기존 클래스를 확장하지 않고 내부에 포함(위임)시켜 사용하는 방법입니다.
전달(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<>());
→ 유연하고 재사용 가능한 구조!
“상속을 허용하려면, 내부 구현까지 공개해야 한다.”
상속을 허용하려면 단순히 extends만 가능하게 만드는 게 아니라,
“하위 클래스가 안전하게 확장할 수 있도록” 내부 동작과 제약을 문서로 남겨야 한다는 뜻이에요.
상속용 클래스를 만들 땐, 자바독에 @implSpec을 사용해 내부 동작 방식을 문서화할 수 있어요.
/**
* @implSpec
* 이 메서드는 iterator()를 이용해 remove 작업을 수행합니다.
*/
public boolean remove(Object o) { ... }
이런 식으로 작성하면 하위 클래스 개발자가
“iterator를 재정의하면 remove 동작이 달라질 수도 있겠구나”
하고 이해할 수 있음.
📌 @implSpec은 자바 8~11에서는 수동 설정해야 하고,
자바 17에서도 여전히 선택 옵션이에요.
내부 구현 중간에 끼어들 수 있는 protected 메서드를 열어주는 것도 방법이에요.
예를 들어 AbstractList의 removeRange()는
일반 사용자는 몰라도 되지만,
하위 클래스가 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()가 호출됩니다.👉 생성자, clone, readObject 같은 객체 생성 시점 메서드에서는
재정의 가능한 메서드를 절대 호출하지 말아야 합니다.
상속을 고려하지 않은 일반 클래스라면
차라리 아예 상속을 막는 게 낫습니다.
final로 선언private / package-private로 두고이렇게 하면 클래스 변경 시 하위 클래스가 깨질 위험이 줄어듭니다.
class Super {
public Super() { helper(); } // 안전
private void helper() { System.out.println("help!"); }
}
protected 멤버는 private로 줄이기“상속용 클래스는 한번 공개하면 되돌릴 수 없는 약속이다.”