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

아이템 27. 비검사 경고를 제거 하라

아이템 27. 비검사 경고를 제거 하라


아이템 27. 핵심 정리

아이템 27. 비검사 경고를 제거하라

제네릭 코드를 작성하다 보면 컴파일러가 다음과 같은 경고를 보여주는 경우가 있다.

unchecked

또는

unchecked warning

Java에서는 여러 종류의 컴파일 경고가 발생할 수 있는데, 이번 아이템에서 집중하는 것은 그중에서도 비검사 경고(Unchecked Warning) 다.

여기서 주의할 점은 unchecked warningunchecked exception은 전혀 다른 개념이라는 것이다.

unchecked warning
→ 컴파일러의 타입 안전성 관련 경고

unchecked exception
→ 예외 처리 체계에서 사용하는 개념

이름에 unchecked라는 단어가 공통으로 들어갈 뿐 서로 별개의 내용이다.


컴파일 에러와 컴파일 경고는 다르다

먼저 컴파일 에러와 컴파일 경고의 차이를 이해할 필요가 있다.

컴파일 에러

컴파일러가 코드를 정상적인 프로그램으로 만들 수 없다고 판단하는 경우다.

예를 들어 다음과 같은 코드는 컴파일할 수 없다.

int number = "hello";

타입 자체가 맞지 않는다.

Compile

↓

Error

↓

컴파일 실패

↓

실행 불가능

컴파일 경고

경고는 다르다.

컴파일러가

"이 코드는 컴파일할 수는 있지만
위험할 가능성이 있습니다."

라고 알려주는 것이다.

즉 일반적으로

Compile

↓

Warning

↓

컴파일 가능

↓

실행 가능

한 상태다.

프로그램을 만들 수 없는 정도의 오류는 아니지만 안전성이나 코드 품질 측면에서 확인해야 할 부분이 있다는 뜻이다.


비검사 경고란 무엇인가?

비검사 경고는 제네릭과 관련해서 컴파일러가 타입 안전성을 완전히 확인할 수 없는 상황에서 발생한다.

예를 들어 Raw Type을 사용한다고 생각해보자.

Set set = new HashSet();

Set은 원래 제네릭 타입이다.

개념적으로 다음과 같이 선언되어 있다.

Set<E>

그런데 타입 인수를 지정하지 않았다.

Set set

즉 Raw Type을 사용했다.

이 상태에서는 컬렉션이 어떤 타입의 값을 다루는지 알 수 없다.


Raw Type은 타입 안전성을 깨뜨릴 수 있다

다음 코드를 보자.

Set set = new HashSet();

set.add("100");
set.add(200);

String도 들어가고 Integer도 들어간다.

컬렉션 내부는 다음과 같은 상태가 될 수 있다.

Set

├─ String
└─ Integer

그런데 개발자가 이 컬렉션에는 Integer만 들어 있다고 생각한다고 해보자.

for (Object value : set) {

    Integer number =
            (Integer) value;

    System.out.println(
            number * 2
    );
}

Integer인 값에서는 문제가 없지만 String"100"Integer로 형변환하려는 순간 문제가 발생한다.

ClassCastException

잘못된 데이터는 훨씬 이전에 들어갔지만 실제 문제는 나중에 발견된다.


컴파일러는 이런 위험을 경고한다

컴파일러는 Raw Type과 제네릭 코드가 섞였을 때

unchecked

관련 경고를 보여줄 수 있다.

IDE에서도 이런 코드를 작성하면 보통 노란색 경고 표시를 볼 수 있다.

중요한 점은 경고 메시지를 단순히 귀찮은 메시지로 생각하지 않는 것이다.

경고는 컴파일러가

현재 코드만으로는 타입 안전성을 보장하기 어렵다.

라고 알려주는 신호다.


비검사 경고는 가능하면 모두 제거한다

이번 아이템의 첫 번째 원칙은 단순하다.

제거할 수 있는 비검사 경고는 모두 제거한다.

Raw Type 때문에 경고가 발생하고 있다면 타입을 정확하게 선언하면 된다.

예를 들어 다음 코드가 있다.

Set numbers =
        new HashSet();

Integer를 저장하려는 Set이라면 다음처럼 변경한다.

Set<Integer> numbers =
        new HashSet<Integer>();

이제 컴파일러는 numbersInteger만 다룬다는 사실을 알 수 있다.

numbers.add(100);

은 가능하지만

numbers.add("100");

은 컴파일되지 않는다.


제네릭 타입을 명시하면 경고와 위험이 함께 사라진다

중요한 것은 단순히 노란색 경고 표시를 없앤 것이 아니다.

다음 두 가지가 동시에 개선된다.

비검사 경고 제거

+

타입 안전성 확보

컴파일러가 코드에 대한 더 많은 정보를 가지게 되었기 때문이다.

Raw Type

Set

↓

무엇을 담는지 알 수 없음

반면

Parameterized Type

Set<Integer>

↓

Integer만 다룬다는 사실을 알 수 있음

이다.


Java 7부터는 다이아몬드 연산자를 사용할 수 있다

제네릭이 처음 도입되었을 때는 다음처럼 타입을 반복해서 작성해야 했다.

Set<Integer> numbers =
        new HashSet<Integer>();

왼쪽에서 이미 Integer라고 선언했는데 오른쪽에서도 다시 Integer를 적어야 한다.

Java 7부터는 이를 간단하게 작성할 수 있다.

Set<Integer> numbers =
        new HashSet<>();

오른쪽의

<>

다이아몬드 연산자(Diamond Operator) 라고 부른다.

컴파일러가 왼쪽의 타입 정보를 이용해서 오른쪽의 타입을 추론한다.

Set<Integer>

↓

new HashSet<>()

↓

Integer라는 타입을 컴파일러가 추론

따라서 같은 타입을 반복해서 적을 필요가 없다.


다이아몬드 연산자를 사용하는 이유

다음 두 코드는 의미상 동일하게 사용할 수 있다.

Map<String, Integer> map =
        new HashMap<String, Integer>();
Map<String, Integer> map =
        new HashMap<>();

두 번째 코드가 훨씬 간결하다.

특히 타입이 복잡해질수록 차이가 커진다.

Map<String, List<Integer>> map =
        new HashMap<String, List<Integer>>();

다이아몬드 연산자를 사용하면

Map<String, List<Integer>> map =
        new HashMap<>();

로 줄일 수 있다.

제네릭 타입은 정확하게 유지하면서 불필요한 반복은 제거할 수 있다.


비검사 경고를 항상 제거할 수 있는 것은 아니다

이상적으로는 모든 비검사 경고를 타입 선언을 수정해서 제거하면 좋다.

하지만 실제 제네릭 코드를 구현하다 보면 컴파일러가 타입 안전성을 완벽하게 증명할 수 없는 경우가 존재할 수 있다.

개발자는 코드 구조상 안전하다는 사실을 알고 있지만 컴파일러에게 그것을 충분히 표현하기 어려운 경우다.

대표적으로 제네릭 배열이나 내부 구현을 다루는 코드 등에서 이런 상황이 생길 수 있다.

이럴 때 사용할 수 있는 것이

@SuppressWarnings("unchecked")

다.


@SuppressWarnings란?

@SuppressWarnings는 특정 컴파일 경고를 표시하지 않도록 만드는 애노테이션이다.

이름 자체를 풀어보면 의미가 명확하다.

Suppress

→ 억제하다


Warnings

→ 경고들

"이 경고는 알고 있으니
이 위치에서는 표시하지 말아 달라."

는 의미다.

비검사 경고를 억제하려면 다음과 같이 작성한다.

@SuppressWarnings("unchecked")

여기에서 "unchecked"가 어떤 종류의 경고를 억제할 것인지 지정한다.


@SuppressWarnings는 unchecked 전용 애노테이션이 아니다

@SuppressWarnings 자체가 비검사 경고만을 위한 기능은 아니다.

@SuppressWarnings("unchecked")

에서 "unchecked"를 지정했기 때문에 현재는 비검사 경고를 억제하는 것이다.

즉 구조는 다음과 같다.

@SuppressWarnings

→ 경고를 억제하는 애노테이션


"unchecked"

→ 그중 비검사 경고를 억제

무조건 @SuppressWarnings부터 붙이면 안 된다

비검사 경고를 발견했다고 바로 다음처럼 작성하면 안 된다.

@SuppressWarnings("unchecked")
public void execute() {
    ...
}

먼저 해야 할 일은 경고의 원인을 제거하는 것이다.

Unchecked Warning 발견

↓

타입 선언을 수정해서
제거할 수 있는가?

├─ YES
│   ↓
│ 경고 원인 제거
│
└─ NO
    ↓
실제로 타입 안전한지 검증
    ↓
안전하다면
@SuppressWarnings("unchecked")

@SuppressWarnings는 경고를 해결하는 첫 번째 수단이 아니라 마지막 수단에 가깝다.


컴파일러가 증명하지 못한다고 코드가 반드시 위험한 것은 아니다

예를 들어 어떤 내부 구현에서 배열을 사용하고 있다고 생각해보자.

개념적으로 다음과 같은 상황이 있을 수 있다.

E[] elements =
        (E[]) new Object[capacity];

컴파일러는

Object[]

→

E[]

형변환이 실제로 안전한지 확인할 수 없다.

따라서 비검사 형변환 경고를 발생시킬 수 있다.

하지만 클래스 전체 구조를 살펴봤을 때 elements에 오직 E와 호환되는 값만 들어오도록 모든 진입 경로가 통제되어 있다면 개발자는 타입 안전성을 논리적으로 증명할 수도 있다.

이처럼

컴파일러는 증명하지 못함

하지만

개발자는 코드 불변식으로 안전성을 증명 가능

한 경우에만 경고 억제를 고려해야 한다.


ArrayList 같은 제네릭 구현에서도 비슷한 문제가 생길 수 있다

제네릭 컨테이너를 직접 구현하면 내부적으로 타입 소거 등의 제약 때문에 컴파일러가 완벽하게 타입을 확인하지 못하는 코드가 생길 수 있다.

중요한 것은

경고가 귀찮아서 없앤다.

가 아니라

이 코드는 왜 타입 안전한지
개발자가 설명할 수 있다.

는 전제가 있어야 한다는 것이다.


제거할 수 없는 비검사 경고는 억제하는 편이 좋다

처음 보면 이런 의문이 생길 수 있다.

"이미 안전한 걸 알고 있다면
그냥 경고를 남겨두면 되지 않을까?"

하지만 경고를 그대로 남겨두는 것도 좋은 방법은 아니다.

예를 들어 프로젝트가 항상 다음 경고를 발생시킨다고 생각해보자.

warning 1

warning 2

warning 3

warning 4

warning 5

개발자들이

"원래 발생하는 경고야."

라고 생각하기 시작한다.

그런데 나중에 새로운 위험한 코드가 추가되어

warning 6

이 발생한다.

기존 경고가 너무 많으면 새 경고가 묻힐 가능성이 있다.


경고는 신호 역할을 해야 한다

가장 이상적인 상태는 다음과 같다.

평상시

Warning = 0

이 상태에서 새로운 경고가 발생하면 바로 눈에 들어온다.

Warning = 1

↓

새로운 문제가 생겼다.

반면 기존 경고를 계속 방치하면

Warning = 43

↓

44개로 증가

해도 새로운 문제가 추가되었다는 사실을 쉽게 알아차리지 못할 수 있다.

따라서

실제로 안전함을 검증했다.

+

경고를 코드 구조상 제거할 방법이 없다.

면 정확하게 @SuppressWarnings를 적용해서 알고 있는 경고를 제거해두는 것이 오히려 새로운 경고를 발견하는 데 도움이 된다.


@SuppressWarnings의 범위는 가능한 한 작게 만든다

다음과 같은 메서드가 있다고 하자.

@SuppressWarnings("unchecked")
public E[] toArray() {

    // 여러 줄의 로직

    return (E[]) elements;
}

메서드 전체에 애노테이션을 붙이면 이 메서드 안에서 발생하는 모든 unchecked 경고가 억제될 수 있다.

현재 알고 있는 위험한 지점은 하나뿐이라고 해보자.

(E[]) elements

그런데 나중에 누군가 메서드 중간에 새로운 코드를 추가한다.

SomeType value =
        (SomeType) something;

여기에서도 새로운 비검사 경고가 생길 수 있다.

하지만 메서드 전체에

@SuppressWarnings("unchecked")

가 붙어 있으면 새로운 경고까지 함께 숨겨질 가능성이 있다.


경고 억제 범위가 넓을수록 새로운 문제를 숨길 수 있다

구조적으로 다음과 같다.

Method 전체

┌─────────────────────────┐
│ @SuppressWarnings       │
│                         │
│ 안전한 코드             │
│                         │
│ 의도적으로 억제한 코드  │
│                         │
│ 새로 추가된 위험 코드   │
│                         │
└─────────────────────────┘

↓

모두 경고 억제

이것은 좋지 않다.

우리가 원하는 것은 정확히 문제가 되는 한 지점만 억제하는 것이다.


지역 변수로 분리해서 범위를 줄일 수 있다

문제의 표현식에 직접 애노테이션을 붙일 수 없는 경우도 있다.

예를 들어 return 문 자체에는 원하는 방식으로 애노테이션을 붙이기 어려울 수 있다.

그렇다면 반환할 값을 지역 변수에 먼저 담아 범위를 좁힐 수 있다.

@SuppressWarnings("unchecked")
E[] result =
        (E[]) elements;

return result;

이렇게 하면 @SuppressWarnings("unchecked")가 적용되는 범위가 매우 작아진다.

오직 result 변수 초기화 영역

정도로 제한할 수 있다.


메서드 전체보다는 작은 범위가 좋다

다음보다

@SuppressWarnings("unchecked")
public E[] toArray() {

    // 수십 줄의 코드

    return (E[]) elements;
}

가능하다면 다음처럼 작성하는 편이 좋다.

public E[] toArray() {

    // 다른 로직

    @SuppressWarnings("unchecked")
    E[] result =
            (E[]) elements;

    return result;
}

이제 다른 곳에서 새로운 unchecked 경고가 발생하면 컴파일러가 정상적으로 알려줄 수 있다.


@SuppressWarnings를 클래스 전체에 붙이는 것은 더 위험하다

다음과 같은 코드는 특히 신중해야 한다.

@SuppressWarnings("unchecked")
public class GenericContainer<E> {

    // 수백 줄의 코드
}

클래스 전체에 경고 억제가 적용되면 앞으로 이 클래스에 추가되는 모든 비검사 경고가 숨겨질 수 있다.

현재 알고 있는 경고

+

미래에 추가될 경고

↓

모두 억제

따라서 가능한 한

Class

↓

Method

↓

Local Variable

순으로 더 작은 범위를 선호해야 한다.

핵심은

경고를 억제해야 한다면 그 경고가 실제로 발생하는 가장 작은 범위에만 적용한다.

는 것이다.


왜 경고를 억제했는지 주석으로 남긴다

@SuppressWarnings("unchecked")가 붙어 있다는 것은 컴파일러가 안전하다고 증명하지 못한 코드를 개발자가 대신 안전하다고 판단했다는 의미다.

따라서 다음 개발자가 코드를 보면 자연스럽게 궁금해질 수 있다.

왜 이 경고를 무시해도 되는가?

이 질문에 답할 수 있도록 근거를 남겨두는 것이 좋다.

예를 들어 다음과 같이 작성할 수 있다.

// elements에는 오직 E 타입의 값만 저장되므로
// 이 형변환은 타입 안전하다.
@SuppressWarnings("unchecked")
E[] result =
        (E[]) elements;

중요한 것은

"경고가 떠서 붙였다."

가 아니라

"왜 실제로 타입 안전한지"

를 설명하는 것이다.


주석은 미래의 코드를 보호한다

타입 안전성을 설명하는 주석은 단순한 친절 이상의 의미가 있다.

현재 클래스의 불변식이 다음과 같다고 하자.

elements에는 E 타입만 저장한다.

이 조건 때문에 형변환이 안전하다.

그런데 나중에 누군가 코드를 변경해서 elements에 다른 타입을 넣을 수 있게 만들어 버린다면 어떻게 될까?

기존 주석을 읽은 개발자는

"이 형변환의 안전성은
elements에 E만 저장된다는 조건에 의존하고 있구나."

라고 알 수 있다.

즉 주석이 단순 설명을 넘어 타입 안전성을 유지하기 위해 깨뜨리면 안 되는 조건을 문서화하는 역할도 한다.


경고를 제거하는 것과 경고를 숨기는 것은 다르다

두 상황을 반드시 구분해야 한다.

경고 제거

Set set =
        new HashSet();

Set<String> set =
        new HashSet<>();

로 변경한다.

컴파일러가 타입 안전성을 확인할 수 있게 되었기 때문에 경고 자체의 원인이 사라졌다.


경고 숨기기

@SuppressWarnings("unchecked")

를 사용한다.

컴파일러는 여전히 증명하지 못하지만 개발자가 안전하다고 판단하고 경고 표시만 제거하는 것이다.

따라서 항상 첫 번째 방법을 우선해야 한다.

원인 제거

>

경고 억제

좋은 처리 흐름

비검사 경고를 만났다면 다음 순서로 처리하는 것이 좋다.

Unchecked Warning 발생

↓

왜 발생했는지 확인

↓

Raw Type 때문인가?

├─ YES
│
│ 매개변수화 타입으로 변경
│
│ List<String>
│ Set<Integer>
│ Map<K, V>
│
└─ NO
   ↓
타입 선언 개선으로 제거 가능한가?

├─ YES
│
│ 코드 수정
│
└─ NO
   ↓
실제 타입 안전성을
논리적으로 증명 가능한가?

├─ NO
│
│ 코드 설계 재검토
│
└─ YES
   ↓
가장 좁은 범위에
@SuppressWarnings("unchecked")
   ↓
왜 안전한지 주석 작성

비검사 경고를 무시하는 습관이 위험한 이유

제네릭 경고는 프로그램이 당장 실행되지 않는 오류가 아니다.

그래서 다음처럼 생각하기 쉽다.

"컴파일 되는데 굳이 고쳐야 하나?"

하지만 비검사 경고가 알려주는 것은 대개 타입 안전성과 관련된 문제다.

잘못하면

컴파일 성공

↓

서비스 실행

↓

문제 있는 경로는 한동안 실행되지 않음

↓

특정 데이터 입력

↓

ClassCastException

처럼 실제 오류가 늦게 나타날 수 있다.

특히 제네릭은 바로 이런 타입 오류를 컴파일 시점에 잡기 위해 사용하는 기능이다.

비검사 경고를 계속 무시한다면 제네릭을 통해 얻을 수 있는 중요한 장점을 스스로 약화시키게 된다.


실무에서 경고가 많은 코드베이스가 위험한 이유

프로젝트에 다음과 같은 경고가 계속 존재한다고 생각해보자.

100 warnings

개발자들은 점점 경고를 보지 않게 된다.

"원래 저 프로젝트는 경고가 많아."

이 상태가 되면 컴파일러의 경고 시스템이 사실상 의미를 잃는다.

새로운 문제가 발생해도

100 warnings

↓

101 warnings

의 차이를 알아차리기 어렵다.

반대로 경고를 꾸준히 제거해서

0 warnings

을 유지한다면 새 경고 하나가 매우 강한 신호가 된다.

0

↓

1

↓

새로운 코드에 문제가 있을 가능성이 있다.

그래서 경고를 가능한 한 제거하고, 의도적으로 남길 수밖에 없는 경우에는 정확히 억제해두는 것이 중요하다.


@SuppressWarnings는 타입 안전성 보증서가 아니다

다음 코드를 작성했다고 해서

@SuppressWarnings("unchecked")

코드가 안전해지는 것은 아니다.

이 애노테이션은 단순히

컴파일러 경고를 보여주지 않는다.

라는 역할을 한다.

@SuppressWarnings

≠

타입 안전성 확보

다.

안전성을 증명하는 책임은 개발자에게 있다.

따라서 다음과 같은 사고방식은 위험하다.

경고 발생

↓

@SuppressWarnings 붙임

↓

해결 완료

올바른 흐름은 다음과 같다.

경고 발생

↓

코드 분석

↓

타입 안전성 확인

↓

컴파일러만 증명할 수 없는 상황인지 확인

↓

그때만 억제

타입 안전성을 유지하는 제네릭 코드

이번 아이템을 제네릭 전체의 관점에서 보면 중요한 흐름이 보인다.

아이템 26에서는 Raw Type을 사용하지 말라고 했다.

Raw Type

↓

타입 정보 손실

↓

타입 안전성 약화

아이템 27에서는 한 단계 더 나아간다.

제네릭을 사용하고 있더라도

↓

Unchecked Warning이 발생한다면

↓

컴파일러가 타입 안전성을
완전히 보장하지 못하고 있다는 뜻

↓

가능하면 제거

즉 두 아이템 모두 결국 타입 안전성이라는 동일한 목표를 가지고 있다.


아이템 26과 아이템 27 연결하기

아이템 26의 핵심은 다음과 같다.

List

대신

List<String>

또는 필요에 따라

List<?>

를 사용한다.

그리고 아이템 27에서는 그렇게 작성한 제네릭 코드에서도 컴파일러가 보여주는 비검사 경고를 그냥 넘기지 않는다.

아이템 26

타입 정보를 버리지 않는다.

↓

아이템 27

컴파일러가 타입 안전성을
확신하지 못하는 지점도 제거한다.

결국 목표는 하나다.

컴파일러가 가능한 한 많은
타입 오류를 실행 전에 찾아주게 한다.

핵심 정리

  • Java 컴파일 과정에서는 컴파일 에러뿐 아니라 다양한 컴파일 경고가 발생할 수 있다.
  • 컴파일 에러는 프로그램을 만들 수 없는 오류이지만 경고는 컴파일과 실행 자체는 가능하다.
  • 비검사 경고는 컴파일러가 타입 안전성을 확인하는 데 필요한 정보가 충분하지 않을 때 발생하는 제네릭 관련 경고다.
  • unchecked warningchecked exception, unchecked exception과는 별개의 개념이다.
  • Raw Type을 사용하면 제네릭 타입 정보를 잃기 때문에 비검사 경고와 타입 안전성 문제가 발생할 수 있다.
  • 제거할 수 있는 비검사 경고는 모두 제거하는 것이 좋다.
  • Raw Type으로 인한 경고라면 Set<Integer>, List<String>처럼 적절한 매개변수화 타입을 선언해 원인 자체를 제거해야 한다.
  • Java 7부터 다이아몬드 연산자 <>를 이용하면 생성자에서 타입을 반복하지 않고도 제네릭 타입을 안전하게 사용할 수 있다.
  • 모든 비검사 경고를 코드 구조만으로 제거할 수 있는 것은 아니다.
  • 개발자가 타입 안전성을 논리적으로 증명할 수 있지만 컴파일러가 이를 확인할 수 없는 경우 @SuppressWarnings("unchecked")를 사용할 수 있다.
  • @SuppressWarnings는 비검사 경고만을 위한 애노테이션이 아니라 특정 종류의 컴파일 경고를 억제하는 기능이다.
  • 경고를 발견하자마자 @SuppressWarnings를 붙이면 안 되고 먼저 경고 원인을 제거할 수 있는지 확인해야 한다.
  • 제거할 수 없는 비검사 경고를 그대로 방치하면 기존 경고 속에 새로운 경고가 묻힐 수 있으므로 안전성을 확인했다면 정확하게 억제하는 편이 좋다.
  • @SuppressWarnings("unchecked")는 가능한 한 가장 좁은 범위에 적용해야 한다.
  • 메서드나 클래스 전체에 적용하면 이후 추가된 새로운 비검사 경고까지 함께 숨길 수 있다.
  • 표현식에 직접 애노테이션을 적용하기 어려운 경우 지역 변수를 만들어 그 변수 선언에만 경고 억제를 적용할 수 있다.
  • 경고를 억제할 때는 왜 해당 코드가 타입 안전한지를 설명하는 주석을 함께 남기는 것이 좋다.
  • @SuppressWarnings는 코드의 안전성을 만들어주는 기능이 아니라 경고 표시만 억제하므로 실제 타입 안전성을 증명할 책임은 개발자에게 있다.
  • 아이템 26과 아이템 27의 공통 목표는 컴파일러가 가능한 한 많은 타입 오류를 실행 전에 발견할 수 있도록 제네릭의 타입 안전성을 끝까지 유지하는 것이다.

한 줄 정리

비검사 경고는 컴파일러가 제네릭 코드의 타입 안전성을 완전히 증명하지 못하고 있다는 신호이므로 가능한 한 타입 선언을 수정해 모두 제거하고, 정말 제거할 수 없으면서 코드가 안전함을 증명할 수 있는 경우에만 가장 좁은 범위에 @SuppressWarnings("unchecked")를 적용하고 그 근거를 명확히 남겨야 한다.


© 2020. All rights reserved.

SIKSIK