동등성 비교
사이드프로젝트 테스트 코드 작성 중 경험한 이슈가 있다.
각각 객체마다 의존성을 끊어내는 테스트로 변경할 예정이지만, 당장은 controller를 호출하고 서비스까지 쭉 로직 태워서 결과값이 일치하는지 확인하는 간단한 코드다
@RestController
@Slf4j
@RequestMapping("/area/v1")
@RequiredArgsConstructor
public class AreaController {
private final AreaService areaService;
@GetMapping("/areacode")
public ResponseEntity<Mono<List<AreaCodeResponse>>> getAreaCode() {
return ResponseEntity
.ok().
body(areaService.getAreaCode());
}
@Service
@Slf4j
@RequiredArgsConstructor
public class AreaServiceImpl implements AreaService {
private final AreaApi areaApi;
private final ReactiveStringRedisTemplate reactiveStringRedisTemplate;
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public Mono<List<AreaCodeResponse>> getAreaCode() {
return reactiveStringRedisTemplate.opsForValue().get(redisCacheKeyFormatter(AREA_CODES))
.doOnNext(data -> {log.info("AREA_CODES result: {}", data);})
.flatMap(data -> {
try {
List<AreaCodeResponse> areaCodeList = objectMapper.readValue(data, new TypeReference<List<AreaCodeResponse>>() {
});
return Mono.just(areaCodeList);
} catch (JsonProcessingException e) {
return Mono.error(new RuntimeException("Failed to deserialize data", e));
}
}).switchIfEmpty(Mono.defer(() -> {
log.info("Cache miss: Fetching data from the API");
return areaApi.getAreaCode();
}));
}
@SpringBootTest
public class AreaControllerTest {
@Autowired
AreaController areaController;
WebTestClient webTestClient;
@BeforeEach
void testSetup() {
webTestClient = WebTestClient.bindToController(areaController).build();
}
@Test
void 사용자는_지역코드를_조회할_수_있다() {
// given
List<AreaCodeResponse> areaCodeList = TestAreaCodeProvider.getAreaCodeList();
// when && then
webTestClient.get()
.uri("/area/v1/areacode")
.exchange()
.expectStatus().isOk()
.expectStatus()
.is2xxSuccessful()
.expectBodyList(AreaCodeResponse.class)
.hasSize(10)
.contains(areaCodeList.get(0), areaCodeList.get(1), areaCodeList.get(2));
}
}
redis에 cache처리된 데이터를 가져와서 비교하는 로직이다.
문제가 생겼던 부분은, contains로 객체 값을 비교하는 부분이다.
2024-11-25T22:11:54.967+09:00 INFO 5762 --- [ioEventLoop-6-1] c.example.tour.service.AreaServiceImpl : AREA_CODES result: [{"code":"1","name":"서울","rnum":"1"},{"code":"2","name":"인천","rnum":"2"},{"code":"3","name":"대전","rnum":"3"},{"code":"4","name":"대구","rnum":"4"},{"code":"5","name":"광주","rnum":"5"},{"code":"6","name":"부산","rnum":"6"},{"code":"7","name":"울산","rnum":"7"},{"code":"8","name":"세종특별자치시","rnum":"8"},{"code":"31","name":"경기도","rnum":"9"},{"code":"32","name":"강원특별자치도","rnum":"10"}]
로그를 보면 service에서 json형태로 값을 내려준다.
TestAreaCodeProvider도 동일한 형태로 값을 내려준다.
(service 로직을 보면 동일하게 ObjectMapper를 통해 List 형태로 반환한다.)
TestAreaCodeProvider areaCode : [AreaCodeResponse(code=1, name=서울, rnum=1), AreaCodeResponse(code=2, name=인천, rnum=2), AreaCodeResponse(code=3, name=대전, rnum=3), AreaCodeResponse(code=4, name=대구, rnum=4), AreaCodeResponse(code=5, name=광주, rnum=5), AreaCodeResponse(code=6, name=부산, rnum=6), AreaCodeResponse(code=7, name=울산, rnum=7), AreaCodeResponse(code=8, name=세종특별자치시, rnum=8), AreaCodeResponse(code=31, name=경기도, rnum=9), AreaCodeResponse(code=32, name=강원특별자치도, rnum=10)]
테스트를 돌려보면 List의 값은 동일한데, 테스트가 통과하지 못한다.
Response body does not contain [AreaCodeResponse(code=1, name=서울, rnum=1), AreaCodeResponse(code=2, name=인천, rnum=2), AreaCodeResponse(code=3, name=대전, rnum=3)]
java.lang.AssertionError: Response body does not contain [AreaCodeResponse(code=1, name=서울, rnum=1), AreaCodeResponse(code=2, name=인천, rnum=2), AreaCodeResponse(code=3, name=대전, rnum=3)]
이유가 무엇인지에 대해 작성해보고자 한다.
원인1. 자바에서 contains에 비교 방식 때문이다.
이유는 자바에서 contains에 비교 방식, 즉 동등성 비교 방식 때문이다.
자바에서 equals()와 hashCode()는 기본적으로 최상위 객체 Object 클래스에서 제공된다. Object에서 제공되는 equals(), hashCode는 참초주소를 기본적으로 비교한다. 이 말은 비교하려는 대상들의 주소값이 같아야 같은 객체로 본다는 얘기다.
AreaCodeResponse areaCodeResponse1 = new AreaCodeResponse("1", "서울", "1");
AreaCodeResponse areaCodeResponse2 = new AreaCodeResponse("1", "서울", "1");
System.out.println("객체는 동일한가요? " + areaCodeResponse1.equals(areaCodeResponse2));
결과는 false가 나온다. 위에서 언급했듯이 각각 생성한 객체의 주소값이 다르기 때문이다.
원인2. 테스트에서 contains()의 동작 방식
WebTestClient의 contains 메서드는 Java의 equals()를 사용하여 반환된 결과에 특정 객체가 포함되어 있는지 확인한다. 따라서, eqauls() 메서드를 재정의하지 않으면 객체의 참조 값이 동일해야 contains() 메서드가 동작한다.
내부 구조를 한번 들어가보자.
public WebTestClient.ListBodySpec<E> contains(E... elements) {
List<E> expected = Arrays.asList(elements);
List<E> actual = (List)this.getResult().getResponseBody();
String message = "Response body does not contain " + expected;
this.getResult().assertWithDiagnostics(() -> {
AssertionErrors.assertTrue(message, actual != null && actual.containsAll(expected));
});
return this;
}
전달받은 요소들을 리스트로 만들고, 실제 응답의 ResponseBody를 List로 변환한다.
변환한 객체들을 containsAll로 비교한다.
public boolean containsAll(Collection<?> c) {
for (Object e : c)
if (!contains(e))
return false;
return true;
}
containsAll은 contains를 반복해서 호출한다.
public boolean contains(Object o) {
Iterator<E> it = iterator();
if (o==null) {
while (it.hasNext())
if (it.next()==null)
return true;
} else {
while (it.hasNext())
if (o.equals(it.next()))
return true;
}
return false;
}
contains는 전달받은 Object가 null이 아니면 equals로 비교한다.
결과적으로, 테스트코드에서 사용된 contains는 containsAll을 호출하고, containsAll은 내부적으로 반복문으로 contains를 사용한다. 그런데, contains는 equals로 주소값을 비교하고 있기 때문에 통과가 되지 못한것이다.
해결 방법
해결방법은 자바의 모든 클래스는 Object를 상속하고 있기 때문에, equals, hashCode 메서드를 재정의할 수 있다.
지금과 같은 경우, AreaCodeResponse의 내부에서 재정의하면 된다는것이다. 그리고, Lombok의 @EqualsAndHashCode를 사용해도 된다.
만약 equals메서드를 따로 재정의해도 결국 필드값을 비교하는 로직을 추가하는건 동일하기 때문에 나는 EqualsAndHashCode를 사용했다.
@Getter
@ToString
@EqualsAndHashCode
public class AreaCodeResponse {
private String code;
private String name;
private String rnum;
}
깔끔하게 해결할 수 있다. 내부적으로 어떻게 구현해주는지 궁금하니까.
디롬복을 해보자
public boolean equals(final Object o) {
if (o == this) return true;
if (!(o instanceof AreaCodeResponse)) return false;
final AreaCodeResponse other = (AreaCodeResponse) o;
if (!other.canEqual((Object) this)) return false;
final Object this$code = this.getCode();
final Object other$code = other.getCode();
if (this$code == null ? other$code != null : !this$code.equals(other$code)) return false;
final Object this$name = this.getName();
final Object other$name = other.getName();
if (this$name == null ? other$name != null : !this$name.equals(other$name)) return false;
final Object this$rnum = this.getRnum();
final Object other$rnum = other.getRnum();
if (this$rnum == null ? other$rnum != null : !this$rnum.equals(other$rnum)) return false;
return true;
}
protected boolean canEqual(final Object other) {
return other instanceof AreaCodeResponse;
}
코드를 보면,
- 참조 주소를 먼저 비교한다.
- InstanceOf를 사용해서 객체 타입을 체크한다.
- 상속된 클래스 즉, 확장된 클래스에 대한 타입을 체크한다.
- 필드 값을 비교한다.
- 결과를 반환한다.
결론적으로, 오류의 원인은 동등성 비교 방식이고, 단순한 필드 값을 비교하고 싶다면 따로 재정의하거나 롬복을 사용해야한다. 가 결론이다.
따로 정리해둔 compareTo, comparable 도 있는데, 조만간 포스팅 해보도록 하겠다..
'planB' 카테고리의 다른 글
[planB] docker 초기 환경 구성 (0) | 2025.02.13 |
---|