이펙티브 자바 완벽 공략 1부

아이템 13. clone 재정의는 주의해서 진행하라

아이템 13. clone 재정의는 주의해서 진행하라


아이템 13. 핵심 정리1 - clone 규약

clone()과 Cloneable 제대로 이해하기 (아이템 13)

clone()은 자바에서 제공하는 객체 복제 메커니즘이지만, 실제로는 가장 오해가 많고 실수하기 쉬운 기능 중 하나다. 표면적으로는 “객체를 복사한다”는 단순한 개념처럼 보이지만, 내부 동작과 규약을 제대로 이해하지 못하면 버그와 설계 문제를 만들기 쉽다.


Cloneable의 정체: 인터페이스인데 아무 것도 없다

Cloneable은 매우 특이한 인터페이스다.

  • 메서드 정의 없음
  • 단순히 “복제 가능”이라는 표시 역할
  • 일종의 마커 인터페이스
class MyClass implements Cloneable {
}

👉 이 인터페이스를 구현하지 않으면?

CloneNotSupportedException 발생

즉, clone()을 사용할 수 있는지 여부를 런타임에서 체크하는 스위치 역할이다.


clone()의 기본 정의

Object에 정의된 메서드:

protected Object clone() throws CloneNotSupportedException

👉 문제점

  • protected → 외부에서 호출 불가
  • Object 반환 → 매번 캐스팅 필요
  • 체크 예외 → 사용성 떨어짐

실제 구현 시 바꿔야 할 것들


1. 접근 제어자: protected → public

@Override
public PhoneNumber clone()

👉 이유

  • 외부에서 호출 가능해야 의미 있음

2. 반환 타입: Object → 자기 타입

public PhoneNumber clone()

👉 장점

  • 캐스팅 제거
  • 타입 안정성 확보

3. 예외 처리: 제거 or 런타임 변환

try {
    return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
    throw new AssertionError();
}

👉 이유

  • Cloneable 구현 시 예외 발생 가능성 거의 없음

clone()의 핵심 규약


1. 반드시 다른 인스턴스여야 한다

a != a.clone()

👉 참조값은 달라야 함


2. 같은 클래스여야 한다

a.getClass() == a.clone().getClass()

3. equals는 상황에 따라 다르다

  • 대부분 true (특히 불변 객체)
  • 경우에 따라 false 가능 (ID 등 변경 필요 시)

❗ 가장 중요한 규칙: 생성자 사용 금지


잘못된 코드

public PhoneNumber clone() {
    return new PhoneNumber(...);
}

👉 문제 발생


왜 위험한가

상속 구조에서 깨진다.

class Item {
    public Item clone() {
        return new Item();
    }
}

class SubItem extends Item {}
SubItem s = new SubItem();
SubItem clone = (SubItem) s.clone(); // ❌ ClassCastException

👉 이유

  • 생성자는 항상 자기 클래스 타입만 생성
  • 런타임 타입 유지 불가

✔ 정답: 반드시 super.clone()

@Override
public PhoneNumber clone() {
    try {
        return (PhoneNumber) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

👉 핵심

  • 런타임 타입 유지
  • 상속 구조 안전

내부 동작 이해

super.clone()

  • 생성자를 호출하지 않는다
  • 메모리를 그대로 복사하는 방식 (shallow copy)

👉 그래서 빠르고, 특이한 동작을 한다


불변 객체에서의 clone()


특징

  • 모든 필드가 변경 불가
  • 상태 동일

👉 equals == true


PhoneNumber a = ...
PhoneNumber b = a.clone();

a.equals(b) == true
a != b

👉 가장 안전한 clone 사용 케이스


⚠️ 가변 객체에서의 문제

여기서부터 복잡해진다.


문제: 얕은 복사 (Shallow Copy)

class Example {
    List<String> list;
}
Example clone = original.clone();

👉 결과

original.list == clone.list  // 같은 객체

위험

  • 한쪽 수정 → 다른 쪽 영향
  • 의도치 않은 공유 상태

👉 해결 방법

  • Deep Copy 직접 구현
  • 내부 필드도 복제

clone()을 추천하지 않는 이유

이제 핵심을 짚어보자.


문제 요약

  • 설계가 직관적이지 않음
  • Cloneable이 이상한 구조
  • shallow copy 문제
  • 상속과 충돌
  • 예외 처리 awkward

👉 그래서 현대 자바에서는

clone() 사용을 권장하지 않는다


대안


1. 복사 생성자

public PhoneNumber(PhoneNumber other)

2. 정적 팩토리

public static PhoneNumber copyOf(PhoneNumber other)

👉 장점

  • 명확함
  • 안전함
  • 유연함

핵심 정리

  • Cloneable은 마커 인터페이스다
  • clone()은 반드시 super.clone() 사용
  • 생성자 사용하면 상속 깨진다
  • 반환 타입은 자기 타입으로
  • 불변 객체에서는 비교적 안전
  • 가변 객체에서는 매우 위험
  • 실무에서는 clone보다 복사 생성자 선호

한 줄 결론

clone()은 “가능은 하지만 추천하지 않는 기능” — 필요하면 정확히 알고 제한적으로 사용하라


아이템 13. 핵심 정리2 - 가변 객체 clone 정의하는 방법

가변 객체에서 clone() 구현 시 반드시 알아야 하는 것들

앞에서 살펴본 불변 객체의 clone()은 비교적 단순했다. 하지만 가변 객체(mutable object)는 이야기가 완전히 달라진다.

특히 내부에 배열, 컬렉션, LinkedList, Map 같은 참조 타입 필드를 가지고 있다면 clone() 구현 난이도가 급격하게 올라간다.

이때 핵심적으로 이해해야 하는 개념이 바로 다음 두 가지다.

  • shallow copy (얕은 복사)
  • deep copy (깊은 복사)

가변 객체 clone()의 기본 구조는 동일하다

가변 객체 역시 기본적인 clone 구현 규칙은 동일하다.

  • Cloneable 구현
  • clone() 재정의
  • public 접근 제어자 사용
  • 반환 타입을 자기 자신의 타입으로 변경
  • super.clone() 호출

예시:

@Override
public Stack clone() {
    try {
        return (Stack) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

하지만 여기에는 치명적인 문제가 숨어 있다.


문제의 시작: 배열이 공유된다

예를 들어 다음과 같은 Stack 클래스가 있다고 가정해보자.

public class Stack implements Cloneable {

    private Object[] elements;
    private int size;

}

이 상태에서 단순히 super.clone()만 호출하면 어떤 일이 발생할까?


❗ clone 이후에도 동일한 배열을 참조한다

원본 Stack  ----\
                 > 동일한 elements 배열 참조
복사된 Stack ----/

즉:

stack.elements == copy.elements

가 되어버린다.


실제로 어떤 문제가 생길까?

Stack stack = new Stack();
stack.push(a);
stack.push(b);

Stack copy = stack.clone();

여기서:

stack.pop();
stack.pop();

을 실행하면?


👉 copy도 영향을 받는다.

왜냐하면 내부 배열이 동일하기 때문이다.

즉:

  • 원본과 복사본이 서로 독립적이지 않다
  • 한쪽 수정이 다른 쪽에 전파된다

이건 clone의 기대 동작과 다르다.


해결 방법 1: 배열 자체를 복사한다

@Override
public Stack clone() {
    try {
        Stack result = (Stack) super.clone();
        result.elements = elements.clone();
        return result;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

이제 무엇이 달라졌을까?

원본 Stack  ---> elements A
복사 Stack  ---> elements B

배열 자체는 달라졌다.

즉:

stack.elements != copy.elements

하지만 여전히 문제가 남아 있다

배열은 복사되었지만:

배열 안의 객체 참조는 동일

즉:

stack.elements[0] == copy.elements[0]

이 상태다.


이것이 shallow copy다

즉:

  • 바깥 컨테이너만 복사
  • 내부 객체는 공유

왜 위험할까?

만약 배열 안 객체가 가변 객체라면:

PhoneNumber number = stack.pop();
number.change(...);

👉 copy 안 객체도 변경된다.

왜냐하면 같은 인스턴스를 보고 있기 때문이다.


그래서 필요한 것이 deep copy

Deep Copy는:

내부 객체까지 모두 새로운 객체로 복사하는 방식

이다.


HashTable 예제로 이해하기

예를 들어 HashTable 내부 구조를 생각해보자.

bucket[0] -> Node -> Node -> Node

여기서 Node는 LinkedList처럼 연결되어 있다.


shallow copy 문제

배열만 clone하면:

원본 bucket[0] ----\
                     > 같은 Node 참조
복사 bucket[0] ----/

즉:

  • 원본 LinkedList 수정
  • clone LinkedList도 같이 변경

결과적으로 생기는 문제

원본 HashTable에 값 추가
→ clone에도 값이 생김

엄청 위험하다.


해결책: Node까지 새로 복사해야 한다

즉:

new Node(...)

를 통해 LinkedList 전체를 새로 만들어야 한다.


deep copy 구현 방식

재귀 방식

Node deepCopy() {
    return new Node(
        key,
        value,
        next == null ? null : next.deepCopy()
    );
}

❗ 하지만 여기에도 문제

Node가 매우 많다면?

deepCopy()
 -> deepCopy()
   -> deepCopy()
     -> ...

메서드 호출이 계속 쌓인다.


결국 StackOverflowError 가능성

재귀 호출은 호출마다 스택 프레임이 쌓인다.

Node가 많아지면:

Stack Frame Overflow

발생 가능.


그래서 Effective Java가 권장하는 방식

재귀 대신 반복문(iterative) 사용


반복문 방식의 장점

  • 스택 사용 최소화
  • 대량 데이터 안전
  • StackOverflowError 방지

shallow copy vs deep copy 핵심 비교

구분shallow copydeep copy
배열 복사OO
내부 객체 복사XO
객체 공유 여부공유됨완전히 독립
안전성낮음높음
구현 난이도쉬움어려움

clone()이 어려운 진짜 이유

결국 clone이 어려운 이유는:

“어디까지 복사해야 하는가?”

를 직접 판단해야 하기 때문이다.


불변 객체는 쉬웠다

왜냐하면:

공유해도 안전

하기 때문이다.


가변 객체는 위험하다

왜냐하면:

공유 상태(shared mutable state)

가 생기기 때문이다.


실무에서 clone이 잘 안 쓰이는 이유

실무에서는 대부분 다음 방식 선호:

  • 복사 생성자
  • 정적 팩토리
  • Builder 기반 복사
  • MapStruct
  • 직렬화 기반 복사

핵심 정리

  • 가변 객체 clone은 매우 어렵다
  • 배열 clone만으로는 부족하다
  • shallow copy는 내부 객체를 공유한다
  • mutable 객체 공유는 매우 위험하다
  • 필요한 경우 deep copy 필요
  • 재귀 deep copy는 StackOverflowError 위험 존재
  • 가능하면 반복문 방식 사용
  • 실무에서는 clone보다 명시적 복사 선호

한 줄 결론

불변 객체의 clone은 단순하지만, 가변 객체의 clone은 “복사 범위”와 “공유 상태” 때문에 매우 복잡해진다.


아이템 13. 핵심 정리3 - clone 대안

가변 객체에서 clone() 구현 시 추가로 주의해야 할 점

앞에서 살펴본 것처럼 가변 객체에서 clone()을 구현할 때는 단순히 super.clone()만 호출해서는 충분하지 않다. 배열이나 컬렉션 같은 내부 상태를 공유하지 않도록 deep copy를 고려해야 하고, 경우에 따라 직접 복사 로직까지 구현해야 한다.

그런데 여기서 끝이 아니다. 실제로 Cloneable을 제대로 구현하려고 하면 추가적으로 고려해야 할 사항들이 굉장히 많다.


clone() 내부에서 재정의 가능한 메서드를 호출하면 안 된다

clone() 내부에서 다른 메서드를 호출할 때 가장 주의해야 할 점은:

하위 클래스에서 오버라이딩 가능한 메서드를 호출하면 안 된다는 것

이다.

예를 들어:

@Override
public MyObject clone() {
    MyObject copy = (MyObject) super.clone();
    copy.initialize(); // 위험
    return copy;
}

이런 코드가 있다고 가정해보자.


왜 위험할까?

하위 클래스가 initialize()를 재정의할 수 있기 때문이다.

@Override
public void initialize() {
    ...
}

그러면 clone 과정 중 예상하지 못한 동작이 발생할 수 있다.

즉:

  • 객체 복제 도중
  • 아직 완전히 초기화되지 않은 상태에서
  • 하위 클래스 로직이 실행될 수 있음

이건 생성자에서 재정의 가능한 메서드를 호출하면 안 되는 이유와 동일하다.


생성 과정과 복제 과정은 매우 민감하다

객체 생성 과정이나 복제 과정은:

  • 객체 상태가 아직 완전하지 않을 수 있고
  • 불변 조건(invariant)이 깨져 있을 수도 있다

그 상태에서 재정의된 메서드가 실행되면 매우 위험하다.

그래서 일반적으로:

  • private
  • final
  • static

메서드만 사용하는 것이 안전하다.


상속 계층에서 Cloneable은 특히 위험하다

특히 추상 클래스 기반 계층 구조에서는 더욱 그렇다.

예를 들어:

abstract class Shape implements Cloneable

이런 구조가 있다면?


하위 클래스 개발자에게 엄청난 부담을 준다

하위 클래스 개발자는:

  • clone 규약 이해
  • shallow copy 문제 이해
  • deep copy 필요 여부 판단
  • mutable field 처리
  • thread safety 고려

까지 모두 해야 한다.

즉:

Cloneable을 선언하는 순간 하위 클래스 개발자에게 복잡한 책임을 넘기게 된다.


그래서 Effective Java가 권장하는 방향


1. 아예 Cloneable을 구현하지 않는다

특히:

  • 상속용 클래스
  • 추상 클래스
  • 확장 가능한 클래스

에서는 권장하지 않는다.


2. 구현했다면 하위 클래스 부담을 최소화한다

상위 클래스에서 안전하게 구현해두고:

@Override
public final MyType clone() { ... }

처럼 final로 막아버리는 방법도 있다.


고수준 API를 사용해서 재구성하는 방법

또 다른 방법은:

clone 이후 객체를 다시 구성(reconstruction)하는 방식

이다.

예를 들어:

copy.put(key, value);

같은 public API를 사용해서 데이터를 다시 채우는 방식이다.


장점

  • 구현 안정성 증가
  • 내부 구조 의존성 감소
  • 깊은 복사 구현 단순화

단점

  • 성능 비용 증가 가능
  • 직접 복사보다 느릴 수 있음

하지만 대부분의 실무에서는:

안정성이 성능보다 훨씬 중요하다.


clone()과 final 필드는 충돌한다

가변 객체에서 특히 큰 문제가 하나 더 있다.

바로:

final 필드 사용 어려움

이다.


예시

private final Node[] buckets;

라고 만들고 싶어도 clone 구현이 어려워진다.

왜냐하면 clone 이후:

copy.buckets = new Node[...];

처럼 새로운 배열을 다시 할당해야 할 수 있기 때문이다.

하지만 final이면 재할당 불가능하다.


결과적으로 생기는 문제

clone을 위해:

  • final 제거
  • 불변성 약화

가 발생한다.

이건 설계적으로 매우 좋지 않다.


복사 생성자가 더 좋은 이유

이런 문제 때문에 실제 실무에서는 clone보다:

  • 복사 생성자(copy constructor)
  • 복사 팩터리 메서드(copy factory method)

를 훨씬 더 많이 사용한다.


복사 생성자 예시

public PhoneNumber(PhoneNumber other) {
    this.areaCode = other.areaCode;
    this.prefix = other.prefix;
    this.lineNum = other.lineNum;
}

장점 1: 생성자 로직 재사용 가능

생성자를 사용하므로:

  • 검증 로직
  • 초기화 로직
  • 불변 조건

을 모두 그대로 활용할 수 있다.

clone처럼 우회하지 않는다.


장점 2: final 필드 사용 가능

private final int areaCode;

처럼 안전하게 설계 가능하다.


장점 3: 타입 변환 가능

이건 clone이 절대 못 하는 강력한 장점이다.

예를 들어:

new TreeSet(collection)

의미

HashSet -> TreeSet 변환 가능
List -> TreeSet 변환 가능

즉:

상위 타입(Collection)을 받아 원하는 구현체로 변환 가능

하다.


clone은 같은 타입 복제만 가능하다

반면 clone은 기본적으로:

자기 자신 타입 복제

에 가깝다.

유연성이 떨어진다.


실무에서 clone이 거의 안 쓰이는 이유

정리하면:

  • 규약 복잡
  • shallow/deep copy 문제
  • 상속과 충돌
  • final 사용 어려움
  • 재정의 문제
  • thread safety 문제
  • 유지보수 어려움

등이 있다.

그래서 대부분:

  • 생성자
  • 정적 팩토리
  • Builder
  • Mapper

를 선호한다.


핵심 정리

  • clone 내부에서 재정의 가능한 메서드 호출 금지
  • 상속 가능한 클래스에서 Cloneable은 위험
  • final 필드와 clone은 궁합이 좋지 않다
  • deep copy 구현은 매우 복잡하다
  • 고수준 API 기반 재구성이 더 안전할 수 있다
  • 실무에서는 복사 생성자와 팩토리 메서드를 더 많이 사용한다

한 줄 결론

clone()은 “객체 복제”라는 단순한 기능처럼 보이지만, 실제로는 상속·가변성·불변성·동시성 문제까지 모두 얽혀 있는 매우 복잡한 기능이다.


아이템 13. 완벽 공략

완벽 공략 13. Cloneable, Deep Copy 그리고 예외 설계

clone()Cloneable은 Effective Java에서도 가장 난해한 주제 중 하나로 꼽힌다. 단순히 “객체를 복사한다” 정도로 생각하면 쉬워 보이지만, 실제로는:

  • shallow copy
  • deep copy
  • stack overflow
  • thread safety
  • checked exception
  • unchecked exception

같은 다양한 개념이 함께 얽혀 있다.

이번 내용은 그런 개념들을 전체적으로 연결해서 이해하는 데 목적이 있다.


CloneNotSupportedException은 왜 애매할까?

책에서는 이런 말을 한다.

CloneNotSupportedException은 사실 비검사 예외였어야 한다는 신호다.

이 말은 굉장히 중요한 의미를 담고 있다.


Checked Exception vs Unchecked Exception

자바의 예외는 크게 두 가지로 나뉜다.


1. Checked Exception

컴파일 시점에 반드시 처리해야 하는 예외

try {
    ...
} catch (IOException e) {
    ...
}

대표 예시:

  • IOException
  • SQLException
  • ParseException

특징

호출하는 쪽에서:

  • 복구 가능성이 있고
  • 반드시 대응해야 하는 상황

일 때 사용한다.

즉:

“이 예외는 정상적인 비즈니스 흐름에서 충분히 발생 가능하니 반드시 처리해라”

라는 의미다.


2. Unchecked Exception

런타임 예외

NullPointerException
IllegalArgumentException
IllegalStateException

같은 것들이다.


특징

대부분:

  • 프로그래밍 실수
  • API 오용
  • 잘못된 상태

를 의미한다.

즉:

호출자가 복구할 수 없는 경우

에 사용한다.


그렇다면 CloneNotSupportedException은?

생각해보자.

class MyClass implements Cloneable

를 이미 선언했다면?

사실상:

CloneNotSupportedException

은 발생하지 않는다.


그런데 왜 checked exception일까?

Object.clone()이 설계될 당시:

  • Cloneable 구현 여부를 런타임에 검사했고
  • 구현 안 했으면 예외를 던지도록 설계했기 때문

이다.


문제점

하지만 실제 사용 시에는:

@Override
public MyClass clone() {
    try {
        return (MyClass) super.clone();
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

처럼 작성하게 된다.


왜 이렇게 작성할까?

왜냐하면:

이미 Cloneable을 구현했으므로 발생 자체가 불가능한 예외

이기 때문이다.

즉:

  • 복구 불가능
  • 사실상 설계적 모순
  • 호출자가 처리할 방법 없음

이다.

그래서 Effective Java는:

사실 unchecked exception에 가까운 성격이었다

고 설명하는 것이다.


Deep Copy와 Shallow Copy 다시 정리


Shallow Copy

얕은 복사.

copy.elements = original.elements.clone();

배열 자체는 새로 생긴다.

하지만:

배열 안 객체 참조는 동일

하다.


즉 이런 상태

원본 배열 -----> PhoneNumber 객체
복사 배열 -----> 같은 PhoneNumber 객체

문제점

만약 내부 객체가 mutable이라면?

원본 수정 시 복사본도 영향받는다.


Deep Copy

깊은 복사.

배열뿐 아니라:

  • 배열 안 객체
  • 연결된 객체
  • 내부 상태

까지 새로 복사한다.


예시

new Node(key, value, nextCopy)

처럼 모든 Node를 새로 생성하는 방식이다.


Deep Copy가 어려운 이유

문제는:

“어디까지 복사해야 하는가?”

이다.

예를 들어:

A -> B -> C -> D

질문

  • D까지 모두 복사?
  • 중간 객체 공유?
  • immutable이면 공유 가능?
  • cyclic reference는?

이런 고민이 계속 생긴다.

그래서 deep copy는 상당히 어렵다.


StackOverflowError와 재귀 복사

Effective Java 예제에서는:

deepCopy(next)

처럼 재귀 호출을 사용한다.


문제

LinkedList가 매우 길다면?

Node1
 -> Node2
   -> Node3
      -> ...

메서드 호출이 계속 쌓인다.


결과

스택 프레임이 계속 증가한다.

결국:

StackOverflowError

가 발생할 수 있다.


왜 발생할까?

메서드 호출마다:

  • 지역 변수
  • 매개변수
  • 반환 위치

등이 스택 프레임에 저장된다.

재귀가 깊어질수록 스택이 가득 차게 된다.


해결 방법

재귀 대신 반복문(iterative) 사용.

즉:

while(current != null)

방식으로 순회하는 것이 더 안전하다.


Thread Safety와 clone()

책에서는 또 이런 말을 한다.

thread-safe 클래스라면 clone()도 동기화해야 한다.


왜 그럴까?

clone 과정 중:

  • 여러 스레드가 동시에 객체 상태를 변경하면
  • 복제 중인 데이터가 꼬일 수 있기 때문

이다.


예시

synchronized MyClass clone() {
    ...
}

처럼 보호할 수 있다.


여기서 중요한 관점

clone도 결국:

객체 상태를 읽고 복사하는 작업

이다.

따라서 mutable 상태를 가진 객체라면:

  • 읽는 동안 상태 변경 가능성
  • 메모리 가시성 문제
  • race condition

을 고려해야 한다.


TreeSet과 타입 변환

책 마지막에는 이런 예시가 나온다.

new TreeSet(hashSet)

의미

HashSet -> TreeSet 변환 가능

하다는 뜻이다.


왜 중요할까?

clone은:

같은 타입 복제

에 가깝다.

하지만 생성자는:

상위 타입(Collection)
-> 원하는 구현체(TreeSet)

로 변환할 수 있다.


즉 생성자가 더 유연하다

이게 Effective Java가:

clone보다 복사 생성자를 선호하는 이유

중 하나다.


핵심 정리

  • CloneNotSupportedException은 사실상 unchecked exception에 가깝다
  • checked exception은 복구 가능한 상황에 사용한다
  • unchecked exception은 프로그래밍 오류 성격이 강하다
  • shallow copy는 객체 참조를 공유한다
  • deep copy는 내부 객체까지 새로 만든다
  • deep copy는 매우 어렵고 비용이 크다
  • 재귀 deep copy는 StackOverflowError 위험이 있다
  • thread-safe 클래스라면 clone도 동기화 고려 필요
  • 생성자는 clone보다 훨씬 유연하다

한 줄 정리

clone은 단순 복사 기능이 아니라 “객체 상태·메모리·동시성·예외 설계”까지 모두 연결되는 매우 복합적인 기능이다.


아이템 13. 완벽 공략 29 - UncheckedException

완벽 공략 13. Checked Exception vs Unchecked Exception

자바의 예외 설계에서 가장 중요한 주제 중 하나는 바로:

  • Checked Exception
  • Unchecked Exception

을 언제 어떻게 사용해야 하는가이다.

많은 개발자들이 처음에는 단순하게:

Checked Exception = 귀찮은 예외
Unchecked Exception = 편한 예외

정도로 이해하지만, 실제로는 API 설계 철학과 복구 가능성에 대한 중요한 개념이 들어 있다.


자바 예외 계층 구조

자바의 예외 구조를 단순화하면 다음과 같다.

Throwable
 ├── Error
 └── Exception
       └── RuntimeException

Checked Exception

RuntimeException을 제외한 Exception 계열.

대표적으로:

  • IOException
  • SQLException
  • ParseException

등이 있다.


특징

컴파일러가 반드시 처리하도록 강제한다.

즉:

void method() throws IOException

가 있다면 호출하는 쪽은 반드시:

try {
    method();
} catch (IOException e) {
}

혹은:

throws IOException

둘 중 하나를 해야 한다.


Unchecked Exception

다음 두 계열은 모두 unchecked exception이다.

  • RuntimeException 계열
  • Error 계열

대표 예시:

NullPointerException
IllegalArgumentException
IllegalStateException

특징

컴파일러가 처리 강제를 하지 않는다.

즉:

throw new NullPointerException();

을 해도 호출자가 굳이 잡지 않아도 된다.


왜 우리는 Checked Exception을 선호할까?

처음에는 오히려 이렇게 생각하기 쉽다.

Unchecked Exception이 훨씬 편한데?

실제로 그렇다.


Checked Exception은 귀찮다

  • try-catch 필요
  • throws 선언 필요
  • 예외 전달 시 계속 선언 필요

즉 코드가 번잡해진다.


그런데도 자바가 굳이 만들었다

중요한 포인트는 여기다.

자바가 굳이:

  • 컴파일 에러까지 발생시키면서
  • try-catch를 강제하고
  • throws를 강제한 이유

가 있다는 것이다.


Checked Exception의 핵심 목적

바로:

API 사용자에게 명시적으로 알려주기 위해서

다.


예시

public void readFile() throws IOException

를 보는 순간 사용자는 바로 알 수 있다.

아, 파일 읽다가 실패할 수 있구나

즉 이것 자체가 API다

Checked Exception은 단순 에러 처리가 아니다.

다음 정보를 제공한다.

  • 어떤 상황에서 실패 가능한가
  • 호출자가 무엇을 대비해야 하는가
  • 복구 가능성이 있는가

가장 중요한 기준

Effective Java에서도 강조하는 핵심은 이것이다.

호출자가 복구 가능한가?


복구 가능하면 Checked Exception

예를 들어:

  • 네트워크 재시도
  • 파일 다시 읽기
  • 사용자 입력 재입력
  • 인증 다시 시도

같은 경우는 복구 가능성이 있다.

따라서:

IOException

같은 checked exception이 적절하다.


복구 불가능하면 Unchecked Exception

반대로:

NullPointerException
IllegalStateException
IndexOutOfBoundsException

같은 경우는 대부분:

  • 프로그래밍 실수
  • API 오용
  • 잘못된 상태

이다.


이런 경우 호출자가 할 수 있는 게 거의 없다

예를 들어:

null.length()

가 발생했다면?

복구보다:

코드를 수정해야 하는 상황

에 가깝다.

그래서 runtime exception으로 설계된 것이다.


CloneNotSupportedException 이야기

책에서:

CloneNotSupportedException은 사실 unchecked exception에 가까웠다

고 말하는 이유도 같다.


왜냐하면

이미:

implements Cloneable

을 했는데:

CloneNotSupportedException

이 발생하면 호출자가 할 수 있는 게 사실상 없다.


즉 복구가 불가능하다

그래서 실제 구현에서는 보통 이렇게 바꾼다.

try {
    return (PhoneNumber) super.clone();
} catch (CloneNotSupportedException e) {
    throw new AssertionError();
}

의미

이건 절대 발생하면 안 되는 상황

이라는 뜻이다.


RuntimeException을 남용하면 안 되는 이유

실무에서는:

귀찮으니까 RuntimeException 쓰자

라는 유혹이 굉장히 강하다.


하지만 문제는

호출자가:

  • 어떤 예외가 발생 가능한지 모르고
  • 문서도 안 읽으면 놓치고
  • 런타임에서 터질 수 있다

는 점이다.


결국 API 품질이 떨어진다

Checked Exception은:

이 API는 이런 실패 가능성이 있습니다

를 컴파일 타임에 강제로 알려주는 장치다.

즉:

API 설계 자체의 일부

인 것이다.


그렇다고 Checked Exception만 쓰는 것도 문제

반대로 모든 예외를 checked로 만들면?

throws AException,
       BException,
       CException,
       DException

처럼 코드가 지나치게 복잡해진다.


특히 RuntimeException 계열까지 전부 선언하면?

예를 들어:

  • NullPointerException
  • IllegalStateException
  • ArithmeticException

등을 전부 throws에 적기 시작하면 API 가독성이 오히려 망가진다.


그래서 균형이 중요하다

핵심 기준은 딱 하나다.


판단 기준

1. 호출자가 복구 가능한가?

가능하면 checked exception.


2. 호출자가 할 수 있는 게 없는가?

그렇다면 unchecked exception.


실무에서 좋은 기준

보통 이런 식으로 생각하면 된다.


Checked Exception

  • 외부 환경 문제
  • 네트워크
  • 파일 시스템
  • 사용자 입력
  • 재시도 가능

Unchecked Exception

  • 프로그래밍 실수
  • 잘못된 상태
  • null 접근
  • API 계약 위반

핵심 정리

  • checked exception은 복구 가능성을 전달하는 API다
  • unchecked exception은 프로그래밍 오류 성격이 강하다
  • 단순히 “편해서” runtime exception을 선택하면 안 된다
  • checked exception은 호출자에게 명시적인 정보를 준다
  • 모든 예외를 checked로 만드는 것도 좋지 않다
  • 핵심 판단 기준은 “복구 가능 여부”다

한 줄 정리

예외 설계의 핵심은 “에러를 던지는 것”이 아니라, “호출자가 무엇을 할 수 있는가”를 설계하는 것이다.


아이템 완벽 공략 30 - TreeSet

완벽 공략 13. TreeSet, Comparable 그리고 정렬된 컬렉션

이번에는 자바의 대표적인 정렬 컬렉션인 TreeSet에 대해서 살펴보자.

HashSet이 해시 기반 컬렉션이었다면, TreeSet은 정렬 기반 컬렉션이다.

즉:

데이터를 저장할 때부터 정렬 상태를 유지하는 Set

이라고 이해하면 된다.


TreeSet은 입력 순서를 유지하지 않는다

예를 들어:

TreeSet<Integer> numbers = new TreeSet<>();

numbers.add(10);
numbers.add(4);
numbers.add(6);

System.out.println(numbers);

결과

[4, 6, 10]

왜 이런 일이 발생할까?

TreeSet은:

입력 순서

가 아니라:

정렬 순서

를 기준으로 저장하기 때문이다.


즉 저장할 때부터 정렬 위치를 찾는다

ArrayList처럼:

맨 뒤에 추가

하는 구조가 아니다.


TreeSet 내부 동작

데이터를 추가할 때:

현재 정렬 상태를 유지할 위치를 계산

해서 들어간다.


기본 정렬 기준은 Natural Order

자바에는 기본적으로:

  • Integer
  • Long
  • Double
  • String

같은 타입들에 대한 자연 순서(Natural Order)가 이미 정의되어 있다.


예시

1 < 2 < 3
apple < banana < orange

같은 정렬 규칙이다.


그런데 사용자 정의 클래스는?

예를 들어:

class PhoneNumber {
}

같은 클래스는 자바가:

어떤 기준으로 정렬해야 하는지

알 수 없다.


결과

TreeSet<PhoneNumber> set = new TreeSet<>();

후 데이터를 넣으면:

ClassCastException

이 발생한다.


왜 발생할까?

TreeSet 내부는 정렬을 위해:

Comparable

로 캐스팅을 시도하기 때문이다.

즉 자바는:

"당연히 정렬 가능하겠지?"

라고 생각하는 것이다.


해결 방법 1. Comparable 구현

가장 대표적인 방법이다.

class PhoneNumber implements Comparable<PhoneNumber> {
    
    @Override
    public int compareTo(PhoneNumber o) {
        return ...
    }
}

compareTo란?

객체 간:

대소 비교 기준

을 제공하는 메서드다.


결과

TreeSet이 compareTo를 사용해서:

  • 어디에 넣을지
  • 어떤 순서인지

판단할 수 있게 된다.


해결 방법 2. Comparator 제공

Comparable을 수정할 수 없거나:

  • 외부 클래스
  • 다른 정렬 기준 필요
  • 상황별 정렬

이 필요하다면 Comparator를 사용한다.


예시

TreeSet<PhoneNumber> set =
    new TreeSet<>(Comparator.comparingInt(
        PhoneNumber::hashCode
    ));

의미

정렬 기준을:

객체 외부에서 제공

하는 방식이다.


Comparable vs Comparator


Comparable

객체 스스로 정렬 기준 제공

Comparator

외부에서 정렬 기준 주입

실무에서는 Comparator가 훨씬 자주 쓰인다

왜냐하면:

  • 정렬 기준 여러 개 필요
  • 상황별 정렬 필요
  • 객체 수정 불가능

한 경우가 많기 때문이다.


TreeSet은 Thread-safe하지 않다

기본 TreeSet은 멀티스레드 환경에서 안전하지 않다.

즉:

동시에 여러 스레드 접근 시 문제 가능

하다.


해결 방법

Set<Integer> syncSet =
    Collections.synchronizedSet(
        new TreeSet<>()
    );

의미

모든 연산에 synchronized 적용.


단점

당연히 성능 비용이 생긴다.

왜냐하면:

한 번에 하나의 스레드만 접근 가능

하기 때문이다.


TreeSet 내부 자료구조

TreeSet은 내부적으로:

Red-Black Tree

를 사용한다.


Red-Black Tree란?

균형이 유지되는 이진 탐색 트리(Balanced Binary Search Tree)다.


왜 중요할까?

트리가 균형을 유지하기 때문에:

  • 탐색
  • 삽입
  • 삭제

가 모두 빠르다.


시간 복잡도


삽입

O(log N)

삭제

O(log N)

탐색

O(log N)

전체 순회

O(N)

왜 O(log N)일까?

트리는 탐색 시:

절반씩 제거

하면서 찾는다.

즉:

Binary Search

와 비슷한 구조다.


HashSet과 비교


HashSet

  • 평균 O(1)
  • 정렬 없음

TreeSet

  • O(log N)
  • 정렬 유지

언제 TreeSet을 사용할까?

다음 상황에서 유용하다.


1. 항상 정렬 상태 유지 필요

랭킹 시스템
우선순위 데이터
정렬된 로그

2. 범위 검색 필요

subSet()
headSet()
tailSet()

같은 기능 지원.


3. 중복 제거 + 정렬 동시 필요

Set 특성 유지하면서 정렬 가능.


내부적으로 TreeMap 사용

흥미로운 점은:

TreeSet은 내부적으로 TreeMap 사용

한다는 것이다.


이유

Set은 사실:

Key만 있는 Map

으로 구현 가능하기 때문이다.


핵심 정리

  • TreeSet은 정렬된 Set이다
  • 입력 순서가 아니라 정렬 순서를 유지한다
  • 기본적으로 Natural Order 사용
  • Comparable 또는 Comparator 필요
  • 내부적으로 Red-Black Tree 사용
  • 삽입/삭제/탐색은 O(log N)
  • 기본적으로 Thread-safe하지 않다
  • Collections.synchronizedSet으로 동기화 가능

한 줄 정리

TreeSet은 “중복 제거”와 “정렬 유지”를 동시에 제공하는 균형 트리 기반 컬렉션이다.



© 2020. All rights reserved.

SIKSIK