본문 바로가기

삽질

Wrapper Class, Primitive Type == 비교와 NPE 및 HashSet 관련 삽질

Wrapper Class 캐싱과 HashSet에 대한 지식이 부족해서 삽질을 했던 경험을 정리하고자 한다.

틀린 내용이 많이 포함되어 있을 수도 있다.

지하철 미션 진행 중 다음과 같은 테스트 코드가 문제였다.

@Test
void addLineStation() {
    Line line = new Line(1L, "2호선");
    line.addLineStation(new LineStation(null, 1L));
    line.addLineStation(new LineStation(1L, 2L));
    line.addLineStation(new LineStation(null, 3L));

    LineStation lineStation = line.getStations().stream()
            .filter(it -> it.getPreStationId() == 3L)
            .findFirst()
            .orElseThrow(RuntimeException::new);
    assertThat(lineStation.getStationId()).isEqualTo(1L);
}
public class Line {
    @Id
    private Long id;
    private String name;
    private Set<LineStation> stations = new HashSet<>();
}
public class LineStation {
    private Long preStationId;    // 이전역 Id
    private Long stationId;    // 현재역 Id
}

Line 객체는 Set<LineStation> stations 를 가지고있다.
LineStation 은 이전역 Id와 현재역 Id를 가지고있는 간접 참조를 위한 객체이다. 이전역 Id에 null을 추가하면 첫 번째 역이다.

테스트는 Line 객체에 LineStation을 추가하는 기능의 단위 테스트였다.

line.addLineStation(new LineStation(null, 1L));
line.addLineStation(new LineStation(1L, 2L)); 

line 객체의 현재 LineStation 들은 (null, 1) -> (1, 2) 이다.

line.addLineStation(new LineStation(null, 3L)); 

위의 코드가 실행 됨으로써 (null, 3) -> (3, 1) -> (1, 2) 로 잘 바뀌는지 테스트 한 것이다.

테스트 코드를 다시 한번 살펴보자.

@Test
void addLineStation() {
    Line line = new Line(1L, "2호선");    // 2호선 추가
    line.addLineStation(new LineStation(null, 1L));    // null -> 1
    line.addLineStation(new LineStation(1L, 2L));    // null -> 1-> 2
    line.addLineStation(new LineStation(null, 3L));    // null -> 3 -> 1 -> 2

    LineStation lineStation = line.getStations().stream()
            .filter(it -> it.getPreStationId() == 3L)    // 이전역이 3인 것을 찾는다.
            .findFirst()
            .orElseThrow(RuntimeException::new);
    assertThat(lineStation.getStationId()).isEqualTo(1L); // 현재역이 1인지 검증한다.
}

신기하게 위 테스트는 어쩔 때는 pass하고 어쩔 때는 fail했다.

RepeatedTest 에노테이션으로 10회 테스트를 실행한 결과는 다음과 같다.

fail할 때는 NPE가 터진다.

일관되지 않은 테스트 결과에 교육장에서도 꽤 오랜 시간 삽질을 하고 집에 와서 다시 자세히 알아봤다.

NPE가 발생되는 부분은 아래와 같다.

.filter(it -> it.getPreStationId() == 3L

첫 번째로 일관되지 않은 테스트 결과가 나오는 이유는 HashSet 자료구조를 사용했기 때문이다.

public class Line {
    ...
    private Set<LineStation> stations = new HashSet<>();
}

Line 객체에서 LineStation 객체들을 저장할 때 사용한 자료구조는 HashSet이다.

HashSet은 다음과 같은 특징을 가지고 있다.

  • 중복된 값 허용 X
  • null 값 저장 O
  • 내부적으로 HashMap 사용
  • 순서 보장 X

HashSet은 순서를 보장하고 있지 않기 때문에 Test 결과가 일관적이지 않았던 것이다.

it.getPreStationId() 했을 때 어쩔 때는 null이 3L보다 먼저 나와서 NPE가 터지고
어쩔 때는 3L이 null보다 먼저 나와서 pass 했던 것이다.

자료구조를 학습하며 공부했던 부분인데, 이렇게 보니까 알아채기가 힘들었다.

그렇다면 왜 NPE가 터질까?

.filter(it -> it.getPreStationId() == 3L)

Wrapper class와 Primitive type의 == 비교는 오토 박싱/언박싱을 통해 가능하다.

처음엔 Long Class의 캐싱 때문에 == 비교가 가능한 줄 알았는데 열심히 Long을 까보니 캐시에서도 primitive type이 아닌 Long 객체로 리턴을 한다. 고로 Wrapper Class 끼리의 == 비교가 아닌 Wrapper Class와 Primitive Type의 == 비교는 캐시와는 상관없다.

그래서 NPE가 터지는 이유는 it.getPreStationId()의 return 값이 null일 때
3L과 비교하기 위해 null 참조를 언박싱을 하는 과정에서 터지는 것이었다.

stack overflow 글을 보면 자세히 설명이 나와있다.

사실 Objects.equals를 쓰면 다 해결될 일이다. 해결 방법을 몰라서 삽질한 것은 아니다. 그냥 궁금했고 알아보다보니 재밌었다.

내용은 없지만 확실히 알아보기 위해 시간을 많이 썼다. 삽질한 시간이 조금 아깝지만 글 내용 이외에도 잔잔하게 알게된 지식들이 많다.
나중에 다 도움이 되리라 믿는다.

지식 수준이 얕아서 틀린 내용이 많을 수도 있는 글이다.
고수님께서 댓글로 지적해줬으면 좋겠다.