검색 기능 트러블슈팅기 - 동등성 비교

맘시터 매칭 매니저분들은 부모님이 내 아이와 잘 어울리는 시터를 더 빨리, 더 편하게 만날 수 있도록 밤낮으로 노력하고 계십니다. 코어플랫폼개발팀에서는 이런 매칭 매니저분들의 수고를 덜어드리고자 기존 프로세스들을 개선 중인데요, 그 과정에서 겪은 트러블슈팅 내용을 공유해 드리고자 합니다.


🔍 트러블 발생

매칭 매니저분들이 새롭게 사용하실 어드민 페이지 오픈이 코앞까지 다가왔습니다. 어드민 페이지에서 날짜, 신청 상태, 담당 매니저라는 3가지 조건으로 원하는 결과를 빨리 찾으실 수 있도록 검색 기능을 구현 중인데요, 검색 기능 구현을 위해서 QueryDSL 사용을 고려했습니다. 검색 조건으로 사용되는 데이터들이 엔티티 4개를 조합(JOIN)한 분량이라는 점과 그것들을 모두 반환하기엔 불필요하게 노출되는 정보가 너무 많다는 점 때문입니다. QueryDSL을 사용해 조회된 결과를 별도의 data class(CareApplyAndScheduleAndSitterDTO)에 담아 반환하도록 기능을 구현했습니다.

image

조회에 필요한 정보만 프로퍼티로 가진 data class를 준비하고 그 data class에 정보를 담아 전달 할 수 있는 쿼리를 작성한 뒤, 조회가 성공할 것이라는 확신과 함께 기쁜 마음으로 테스트를 실행했는데 예상치 못한 부분에서 테스트가 실패했습니다.

image

QueryDSL은 WHERE절에 null을 전달하는 경우 조건문을 아예 생성하지 않습니다. 때문에 검색 조건을 모두 null로 전달했을 경우 성공할 수밖에 없는 테스트입니다. 마침 조회되어야 할 원소의 개수는 7개고, 조회결과_리스트의 크기도 7인데?! 테스트는 실패했습니다.

data class 내부 모든 원소 역시 primitive type 이거나 data class 였기 때문에 동등성 비교로 문제가 생길 리는 없습니다. 분명 논리적으로 문제가 되는 부분이 없는데, 컴파일러는 계속해서 데이터가 일치하지 않는다고 우기고 있었습니다.

홧김에 ‘테스트고 뭐고 데이터 잘 나오는 건 확실한데 그냥 배포할까…’ 생각도 들었지만, 혹여나 검색 로직이 변경된다면 든든한 방패가 되어줄 테스트임이 분명했습니다. 결국 모든 원소를 하나하나 비교해가며 테스트를 통과시키기 위한 여정을 시작했습니다.


🔍 왜 동등성 비교에 실패하지?

image

우선 ‘어떤 녀석이 일치하지 않는가?’부터 시작했습니다. shouldContainExactlyInAnyOrder의 대상이 되는 원소들을 하나씩 분리 후 shouldContain를 통해 리스트에 포함되어 있는지 테스트를 진행해보았습니다. 그러자 공통으로 돌봄희망일을 가진 경우에 동등성 비교에 실패하는 걸 확인할 수 있었는데요, 돌봄희망일 == 스케줄이었으므로, 스케줄 정보를 가지고 있는 data class에서 동등성 비교가 실패함을 추측 가능했습니다.

이번에는 shouldBe로 대놓고 동등성 비교를 테스트해보았습니다. 그러나 동등성 비교를 테스트하면서 더욱 미궁속으로 빠져들었는데, 동등성 비교 실패로 보여준 정보가 서로 완전히 완전히 동일했기 때문입니다.

image

아주 미치고 펄쩍 뛸 노릇!!

일반 class가 아닌 data class라서 동등성 비교를 진행할 게 분명하고, 보기엔 완전히 동일한 데이터인데 동등성 비교에 실패한다니. ‘혹시 data class를 잘못 사용한 게 아닐까?’ 걱정되어 완전히 동일한 data class 인스턴스를 2개 만들어 비교도 해보았습니다.

image

그러자 이번에는 통과하는 모습을 보여줬습니다. 이즈음부터 ‘영속성 컨텍스트를 다녀온 data class에는 무언가 변화가 생기는가?’ 의심이 피어났지만, ‘영속성 컨텍스트를 다녀와 봐야 data class는 data class지.’ 라며 의심을 접고 다시 data class 내부 원소들을 하나씩 뜯어보기 시작했습니다.

image image

우선 스케줄 data class인 RegularScheduleDto와 그 내부 프로퍼티들을 하나하나 테스트해 보았습니다. 그 결과 RegularSchedule 세부 정보에 해당하는 RegularScheduleOption data class 에서 문제가 발생하고 있었습니다.

image

그러나 RegularScheduleOption data class만 별도로 동등성 비교를 진행해봐도 여전히 원인을 파악할 수 없었습니다. ‘혹시나 DayOfWeek 클래스 간 동등성 비교가 안 되는 건 아닐까?’, ‘LocalTime 소수점 값에 차이가 있던 건 아닐까?’ 등 여러 가지 테스트를 추가로 진행해보았지만, 진전이 없었습니다.

image

RegularScheduleDto 짜증난다…


🔍 팀원분들께 도움을 받자

결국 문제를 해결하지 못하고 하루를 통째로 날렸습니다. 다음 날 점심시간 내려가는 엘리베이터에서 팀원분들께 현 상황을 툴툴거리며 말씀드렸더니 곰곰이 듣고 계시던 20년 차 백엔드 개발자 현웅님께서 여러 가지 접근 방법을 제시해주셨습니다. 점심식사 후 현웅님이라면 해결의 실마리를 잡아주실 수도 있을 거라는 기대감에 무작정 노트북을 들고 찾아갔습니다. 한참 채팅 기능 개선으로 바쁘신 와중에도 흔쾌히 문제를 같이 살펴봐 주셨고, 10분도 안 되어 실마리를 잡아내 주셨습니다.

image

현웅님은 디버깅 모드로 데이터 타입부터 확인해주셨습니다. ‘눈에 보이는 데이터가 모두 일치하는 데 동등성 비교가 실패한다면, 데이터의 타입이 달라서 동등성 비교를 시도조차 하지 않는다.’는 접근이셨던거 같아요. 실제로 RegularScheduleDto 내부 timeSlots 컬렉션의 데이터 타입이 달랐습니다.

image

영속성 컨텍스트에 진입하지 않은 컬렉션은 본래 데이터 타입을 유지하고 있지만,

image

영속성 컨텍스트에 진입한 컬렉션은 PersistentBag으로 감싸집니다. JPA가 영속성 컨텍스트에 속한 컬렉션을 보다 쉽게 관리하기 위해서 감싸는 것!

image

결국 PersistentBagArrayList로 데이터 타입부터가 다르니까 동등성 비교에 실패하는 게 당연합니다.


🔍 해결 방법

원인이 명확히 밝혀지니 해결은 아~주 수월합니다. 엔티티 -> data class로 변환할 때 toList() 메서드를 통해 컬렉션으로 변환을 명시적으로 하거나, 테스트 코드에서 data class간 동등성 비교가 아닌 data class 내부 값을 직접 꺼내서 비교를 진행하면 문제를 해결할 수 있습니다.

저의 경우 실제로 API 호출 시 CareApplyAndScheduleAndSitterDTO를 반환하므로 CareApplyAndScheduleAndSitterDTO를 비교하는 테스트가 더 의미 있다고 생각해서 toList() 메서드를 통해 명시적으로 컬렉션 반환을 하도록 했습니다.

image image

감사합니다 현웅님! 🙇‍♂️


🔍 무얼 배웠는가

‘왜 동등성 비교가 안 될까?’, ‘equalsshouldBe가 다르게 동작하는 부분이 있나?’ 와 같이 익숙한 부분에서만 원인을 찾으려 했던 점이 아쉽습니다. 수 많은 개발자가 믿고 사용하는 equalsshouldBe 등을 의심했고, 의심 과정에서 그간 알고 있던 개념이 흐트러질까 봐 혼란스러워하고 스트레스를 받았어요. 그러나 현웅님께서는 자연스럽게 hashCode 부터 확인하셨습니다. 모두가 믿고 사용하는 라이브러리에는 원인이 없을게 당연하니 디버깅 모드로 문제가 발생할 수 있는 영역만 체크하신 거 같아요. 결국 정리하면 아래와 같습니다.

  • 모두가 믿고 사용하는 것에는 이유가 있다.
  • 디버깅 모드와 더욱 친해져 보자.
  • 모르는 게 있다면 주변 동료분들께 더더욱 적극적으로 도움을 요청하자.

이렇게 해피엔딩이 되나 싶었지만, 배포 후 또 다른 문제를 마주치게 되었습니다…


References

retrospecitive
troubleshooting