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를 쓰면 다 해결될 일이다. 해결 방법을 몰라서 삽질한 것은 아니다. 그냥 궁금했고 알아보다보니 재밌었다.
내용은 없지만 확실히 알아보기 위해 시간을 많이 썼다. 삽질한 시간이 조금 아깝지만 글 내용 이외에도 잔잔하게 알게된 지식들이 많다.
나중에 다 도움이 되리라 믿는다.
지식 수준이 얕아서 틀린 내용이 많을 수도 있는 글이다.
고수님께서 댓글로 지적해줬으면 좋겠다.
'삽질' 카테고리의 다른 글
Spring Data Jpa의 LazyInitializationException과 OSIV (0) | 2020.09.09 |
---|---|
Reflection API와 JPA, Spring Bean (0) | 2020.07.22 |
JPA, EntityManager와 persist 관련 알게 된 사실 (0) | 2020.07.15 |
Spring Data JDBC with..메소드와 final에 관한 의문 (2) | 2020.05.28 |
RestAssured와 기본 생성자에 관련된 삽질 (0) | 2020.05.20 |