자바를 사용하면서 primitive타입과 primitive타입에 대응하는 래핑된 object 타입 중 어떤 것을 선택할지는 간단해 보이지만, 중요한 문제이다. 기본적으로 성능을 생각하면 heap 영역에 영향이 없는 primitive 타입을 선택하지만, 어쩔 수 없이 (없는 값의 기본을 null로 해야 명세에 부합하여 사이드 이펙트가 없을 때) 래퍼 클래스를 사용하는 경우도 있었다. 이번에는 이 부분에 대해 더 자세히 알아보자.
자바에서는 int, double, boolean과 같은 기본 타입에 대응하는 박싱된 기본 타입 (Integer, Double, Boolean 등) 들이 있다. 오토박싱과 오토언박싱 덕분에 두 타입을 크게 구분하지 않고 사용할 수는 있지만, 차이가 사라지는 것은 아니다. 둘 사이에는 분명한 차이가 있으니 어떤 타입을 사용하는지는 상당히 중요하다. 주의해서 선택해야 한다는 말이다. 기본 타입과 박싱된 기본 타입의 주된 차이는 크게 세 가지다. 첫 번째, 기본 타입은 값만 가지고 있으나, 박싱된 기본 타입은 값에 더해 식별성이란 속성을 갖는다. 달리 말하면 박싱된 기본 타입의 두 인스턴스는 값이 같아도 서로 다르다고 식별될 수 있다. 두 번째, 기본 타입의 값은 언제나 유효하나, 박싱된 기본 타입은 유효하지 않은 값, 즉 null을 가질 수 있다. 세 번째, 기본 타입이 박싱된 기본 타입보다 시간과 메모리 사용 면에서 더 효율적이다. 이상의 세 가지 차이 때문에 주의하지 않고 사용하면 진짜로 문제가 발생할 수 있다. Integer값을 오름차순으로 정렬하는 비교자를 보자. Integer는 그 자체로 순서가 있으니 이 비교자가 실질적인 의미는 없지만, 흥미로운 점을 하나 보여준다.
Comparator<Integer> naturalOrder = (i, j) -> (i < j) ? -1 : (i == j ? 0 : 1);
별다른 문제를 찾기 어렵고, 실제로 이것저것 테스트해봐도 잘 통과한다. 예컨대 Collections.sort에 원소 백만 개짜리 리스트와 이 비교자를 넣어 돌려도 아무 문제가 없다. 리스트에 중복이 있어도 상관없다. 하지만 심각한 결함이 숨어 있으니 이 결함을 눈으로 확인하고 싶다면 naturalOrder.compare(new Integer(42), new Integer(42))의 값을 출력해보자. 두 Integer 인스턴스의 값이 42로 같으므로 0을 출력해야 하지만, 실제로는 1을 출력한다. 즉, 첫 번째 Integer가 두 번째보다 크다고 주장한다. 원인이 뭘까? naturalOrder의 첫 번째 검사(i <j)는 잘 작동한다. 여기서 i와 j가 참조하는 오토박싱된 Integer 인스턴스는 기본 타입 값으로 변환된다. 그런 다음 첫 번째 정숫값이 두 번째 값보다 작은지를 평가한다. 만약 작지 않다면 두 번째 검사(i == j)가 이뤄진다. 그런데 이 두 번째 검사에서는 두 객체 참조의 식별성을 검사하게 된다. 비록 값은 같더라도 i와 j가 서로 다른 Integer 인스턴스라면 이 비교의 결과는 false가 되고, 비교자는 1을 반환한다. 즉, 첫 번째 Integer 값이 두 번째보다 크다는 것이다. 이처럼 박싱된 기본 타입에 == 연산자를 사용하면 오류가 일어난다. 실무에서 이와 같이 기본 타입을 다루는 비교자가 필요하다면 Comparator.naturalOrder()를 사용하자. 비교자를 직접 만들면 비교자 생성 메서드나 기본 타입을 받는 정적 compare 메서드를 사용해야 한다. 그렇더라도 이 문제를 고치려면 지역변수 2개를 두어 각각 박싱된 Integer 매개변수의 값을 기본 타입 정수로 저장한 다음, 모든 비교를 이 기본 타입 변수로 수행해야 한다. 이렇게 하면 오류의 원인인 식별성 검사가 이뤄지지 않는다.
Comparator<Integer> naturalOrder = (iBoxed, jBoxed) -> {
int i = iBoxed, j = jBoxed;
return i < j ? -1 : (i == j ? 0 : 1);
};
Integer와 int를 비교할때를 보면, 거의 예외 없이 기본 타입과 박싱된 기본 타입을 혼용한 연산에서는 박싱된 기본 타입의 박싱이 자동으로 풀린다. 그리고 null 참조를 언박싱하면 NullPointerException이 발생한다. 이 예에서 보듯, 이런 일은 어디서든 벌어 질 수 있다. 해법은 i를 int로 바꿔주면 된다. 또한, 실수로 for문을 돌면서 더하는 연산을 할때, sum의 값을 박싱된 기본 타입으로 선언하면 박싱과 언박싱이 반복해서 일어나기 때문에, 치명적으로 느려지는것을 체감할 수 있다. 모든 문제의 원인은 하나다. 프로그래머가 기본 타입과 박싱된 기본 타입의 차이를 무시한 대가를 치른 것이다. 그렇다면 박싱된 기본 타입은 언제 써야 하는가? 적절히 쓰이는 경우가 몇 가지 있다. 첫 번째, 컬렉션의 원소, 키, 값으로 쓴다. 컬렉션은 기본 타입을 담을 수 없으므로 어쩔 수 없이 박싱된 기본 타입을 써야만 한다. 더 일반화해 말하면, 매개변수화 타입이나 매개변수화 메서드의 타입 매개변수로는 박싱된 기본 타입을 써야 한다. 자바 언어가 타입 매개변수로 기본 타입을 지원하지 않기 때문에다. 예컨대 변수를 ThreadLocal<int> 타입으로 선언하는 건 불가능하며, 대신 ThreadLocal<Integer>를 써야 한다. 마지막으로, 리플렉션을 통해 메서드를 호출할 때도 박싱된 기본 타입을 사용해야 한다.
요약하자면,
기본 타입과 박싱된 기본 타입 중 하나를 선택해야 한다면 가능하면 기본 타입을 사용하라. 기본 타입은 간단하고 빠르다. 박싱된 기본 타입을 써야 한다면 주의를 기울이자. 오토박싱이 박싱된 기본 타입을 사용할 때의 번거로움을 줄여주지만, 그 위험까지 없애주지는 않는다. 두 박싱된 기본 타입을 == 연산자로 비교한다면 식별성 비교가 이뤄지는데, 이는 원한 게 아닐 가능성이 크다. 같은 연산에서 기본 타입과 박싱된 기본 타입을 혼용하면 언박싱이 이뤄지며, 언박싱 과정에서 NullPointerException을 던질 수 있다. 마지막으로, 기본 타입을 박싱하는 작업은 필요 없는 객체를 생성하는 부작용을 낳을 수 있다.
출처: 이펙티브 자바 3판
'IT 개발 > Effective Java' 카테고리의 다른 글
일반적으로 통용되는 명명 규칙을 따르기 (0) | 2023.02.25 |
---|---|
자바에서 문자열 사용과 연결 이야기 (0) | 2023.02.24 |
메서드 시그니처를 신중히 설계하기 (0) | 2023.02.22 |
적시에 방어적 복사본 만들기 (0) | 2023.02.21 |
배열보다는 리스트를 사용하기 (0) | 2023.02.20 |