얼마 전 스터디 그룹원끼리 토이 프로젝트 리뷰 중 Long 타입에 대한 피드백을 받았습니다.
@Entity
public class MemberVO {
private long id;
private String name;
private String password;
//...
}
위의 VO 클래스에서 id 필드에 대해 long 타입으로 사용하는 것보다 Long 타입을 사용하는 것이 더 좋다는 피드백을 받았습니다만, 한 가지 의문점이 생겼습니다. 자바에서는 박싱된 기본타입, 즉 Wrapper 클래스보다 기본형 타입을 쓰는 것을 더 권장하는데 왜 VO(Entity) 객체 내에서 Long 타입을 사용하는 것을 추천하는 것일까요?
이 글은 이에 대한 의문점에서 출발했습니다. 이러한 의문점을 해결하기 위해서는 기본 타입과 박싱된 기본타입(Wrapper class)에 대해 먼저 살펴봅시다.
위의 제목은 이펙티브 자바에서 박싱된 기본타입 챕터이기도 합니다. 그렇다면, 박싱된 기본타입은 무엇인지 한 번 살펴봅시다.(이펙티브 자바의 내용을 참고, 요약 정리했습니다.)
위의 예시처럼 각각의 기본 타입은 1대1로 대응하는 참조 타입(객체)를 가지고 있으며 이를 박싱된 기본타입이라고 부릅니다.
첫번째 주요 차이점은 식별성의 유무입니다.
기본 타입은 순수하게 값만 가지고 있지만 박싱된 기본 타입은 값에 더해 식별성이라는 추가적인 값을 더 가지고 있습니다. 즉, 박싱된 기본 타입의 인스턴스들은 값은 같지만 서로 다르다고 식별될 가능성이 있습니다. 보다 쉽게 말하자면, 박싱된 기본 타입이 가지는 식별성은 메모리 주소로 볼 수 있습니다. 박싱된 기본 타입의 인스턴스는 객체의 인스턴스로 취급해 별도의 메모리 주소를 할당 받는 것을 의미합니다.
그렇다면 아래의 예제 코드를 통해 명확하게 비교를 해봅시다.
Integer a = new Integer(10);
Integer b = new Integer(10);
System.out.println(a == b); //false
System.out.println(a.equals(b)); //true
박싱된 기본 타입을 산술 연산했을 때 둘은 다른 값으로 나오지만, equals를 통해 객체 간 비교를 했을 때에는 true, 즉 같은 객체임을 반환합니다.
두번째 차이점은 null을 가질 수 있는지에 대한 차이입니다.
기본 타입은 언제나 유효한 값을 가지고 있으나 박싱된 기본 타입은 null을 가질 수 있습니다. 이는 객체 인스턴스가 가지고 있는 특징을 따라가는데요, 박싱된 기본 타입은 객체이기 때문에 당연히 null을 가질 수 있습니다. 그런데, 이러한 특징으로 인해 VO 객체에서 박싱된 기본 타입을 사용하게 되는 큰 이유가 되기도 합니다. (이 부분은 잠시 후에 다뤄보겠습니다.)
또한, 객체 인스턴스의 특징을 가지고 있는 박싱 타입은 기본 타입보다 시간과 메모리 사용면에서 비효율적이기도 합니다.
이러한 차이점의 핵심은 박싱된 기본 타입은 객체 인스턴스라는 것입니다. 그렇다면 왜, 박싱된 기본 타입 대신 기본 타입의 사용이 권장되는지 박싱된 기본타입이 가지고 있는 문제점을 살펴봅시다.
Comparator<Integer> intComparator = (i, j) -> (i < j) ? -1 : (i == j? 0 : 1);
System.out.println(intComparator.compare(new Integer(10), new Integer(10))); ///???
위의 예제 코드는 int 타입간의 비교를 위해 만든 comparator로 i가 j보다 작으면 -1, i가 j보다 크면 1을 리턴하는 comparator입니다. 이러한 comparator를 이용해 new Integer(10)을 비교 해봅시다. 본격적인 비교에 앞서 결과값을 예상하자면 같은 10끼리의 비교이기 때문에 0을 리턴할 것입니다.
System.out.println(intComparator.compare(new Integer(10), new Integer(10))); ///1
하지만 결과값은 우리의 예상과는 다르게 1을 리턴하고 있습니다. 10과 10끼리 비교했는데 2번째 인자 10이 더 크다는 어처구니 없는 결과를 내놓고 있습니다.
이런 괴상한 결과값은 어디서 나온 걸까요?
박싱된 기본 타입은 식별성을 가지고 있습니다. 첫 번째 단계에서 i < j
가 false로 나오게 되고 다음 조건인 i == j
로 넘어가는데 이 때 박싱된 기본 타입은 해당 단계에서 식별성(객체 참조)를 검사하게 됩니다. 이에 따라 false가 return되면서 잘못된 결과인 1을 반환하게 됩니다.
즉, 박싱된 기본 타입끼리 직접적인 비교는 우리가 예상하지 못한 잘못된 동작을 하게 될 가능성이 있습니다. 이로 인해 박싱된 기본 타입끼리 비교를 할 때에는 로컬 변수를 사용해 박싱된 인스턴스를 기본 타입으로 다시 저장해 비교해야 합니다. 쉽게 말해 Integer로 선언된 변수를 int로 다시 변환 후 비교를 시작해야 합니다.
추가적으로 이러한 기본 타입으로 전환하는 과정을 언박싱이라고 부릅니다. 이 때 박싱된 기본 타입은 null을 가질 수 있다는 특징으로 인해 이러한 언박싱 시 nullPointException을 일으킬 소지가 있습니다. 박싱된 기본 타입은 초기값을 null로 가지고 있기 때문에 박싱된 기본 타입 인스턴스를 별도로 초기화 작업을 해주지 않았다면 연산 시 기본 타입은 null 처리를 할 수 없기 때문에 nullPointException을 뿜어내게 됩니다.
이러한 연산 문제도 있지만 중요한 문제점 중 하나는 박싱과 언박싱이 연달아 수행되는 과정에서 일어납니다. 박싱과 언박싱이 연달아 수행되면 코드의 가독성을 저해할 뿐만 아니라 성능을 급격하게 느리게 만드는 문제점이 생기게 됩니다.
Long sum = 0L;
long i = 0;
while(i <= Integer.MAX_VALUE) {
i++;
sum += i;
}
System.out.println(sum);
위의 예제 코드를 보면 로컬 변수 sum이 연산되는 과정에서 박싱과 언박싱이 계속해서 일어나고 있음을 볼 수 있습니다. (Long으로 선언 → long i와 연산하기 위해 언박싱 진행 → 결과를 넣기 위해 sum이 다시 박싱 → … 이 과정이 연산이 끝날 때 까지 반복)
한 두어번의 연속된 박싱, 언박싱의 경우 성능에 크게 영향을 미치지 않지만, 반복적으로 일어날 경우, 성능에 큰 영향을 미치게 됩니다.
아래의 이미지는 실제 위의 코드를 실행했을 때의 결과인데, sum의 타입을 Long, long으로 나눠 실행했을 때의 결과입니다.
(Long sum으로 처리 시 소요 시간 : 5836 ms)
(long sum으로 처리 시 소요 시간 : 1358 ms)
비교에 따른 오동작 가능성과 null 처리 문제, 성능 이슈의 문제를 방지하고자 이펙티브 자바에서는 박싱된 기본 타입 대신 기본 타입의 사용을 추천하고 있습니다. 저 역시 기본 타입의 사용을 굉장히 중요하게 생각하고 있었습니다.
이펙티브 자바에서 살펴본 내용에 의하면 박싱된 기본 타입인 Long을 쓰는 것은 리스크 있는 선택임을 알 수 있습니다.
그런데 hybernate 공식 문서를 보면 persistent class에는 기본 타입이 아닌 타입을 사용하기를 추천하고 있습니다…???!
(이미지 출처 : hybernate 공식 문서 발췌 - 기본타입 대신 wrapper class 쓰세요)
이처럼 박싱된 기본 타입을 jpa에서 사용하는 이유는 재미있게도 박싱된 기본 타입에서 문제로 지적된 null 허용과 관련이 있습니다. 사용하지 말아야할 이유가 어느새 사용해야할 이유가 되버린 것입니다.
특정 id값의 타입 지정 시 mybatis를 사용하나 jpa를 사용하나 기본적으로 int보다 long이 더 큰 값 범위를 가지고 있기 때문에 int 대신 long을 사용하는데, 여기서 발생하는 문제는 기본 타입을 사용했을 때의 초기값입니다.
기본 타입의 경우 초기값이 0입니다. 예를 들어 id의 타입을 long(기본 타입)으로 지정했을 경우 실제로 사용하는 id 값이 없는 것인지, 0인건지 구분할 수 없게 됩니다. 이 때 박싱된 기본 타입(wrapper class)인 Long을 사용하게 되면 id가 0인지 없는 것인지 null을 통해 명확하게 구분할 수 있게 되는 것입니다. 즉, null로 인해 id값이 없다는 것을 보장할 수 있게 되는 것입니다.
단, 이렇게 Long 타입을 사용할 경우 nullPointException에 대한 확실한 예외 처리, 연산과정에서 발생하는 성능 이슈가 발생하지 않도록 과도한 auto-boxing, unboxing 문제를 고려하면서 개발해야 합니다.
이펙티브 자바 스터디를 진행하면서 배운 내용을 기반으로 최대한 기본 타입을 선언해서 사용하고자 했습니다. 그래서 이 부분에 대해 피드백이 들어왔을 때 기본 타입에 대한 점검을 하라는 것에서 나온 피드백인 알고, 일부러 틀린 피드백을 해서 공부를 하라는 것으로 잠깐 오해했던 시간이 있었습니다. 그러나 hybernate 공식 문서를 보면서 db와 상호작용 시 값의 존재 여부를 명확하게 구분할 수 있는 Long 타입을 사용하라는 피드백의 내용을 깊게 이해할 수 있었던 좋은 기회였습니다.