오늘 echo 프로젝트를 진행하면서 다양한 에러와 마주했다..... 에러가 발생한 이유와 해결 과정을 트러블 슈팅으로 남겨보자
이번에 dto와 entity 변환에 mapstruct의 mapper를 사용하기로 하였는데 처음 사용하다 보니 테스트를 하며 자꾸만 에러와 마주했다... 특히 문제가 되는 부분은 엔티티에 존재하는 연관관계였다. 매핑이 자동으로 되지 않는 부분, 매퍼를 사용하기 위해서 설정해야 하는 에너테이션이 존재했는데 에러 메세지를 보고 이를 알아내기가 쉽지 않았다.
매퍼를 사용하면 매핑을 자동으로 생성해주기 때문에 코드를 간결하게 쓸 수 있지만 자동 생성시 명시적으로 지정되지 않은 필드 매핑에 대해 에러가 나거나 구현체가 제대로 생성되지 않는 경우가 있다.. 이 때 오류가 발생한다.
아래는 memberProductMapper의 toEntity 메서드이다. 이 메서드를 통해 requestDto를 entity로 변환하였는데 null값이 반환되며 memberProduct엔티티가 제대로 생성되지 않았다는 오류를 마주하게 되었다.
@Mapper
public interface MemberProductMapper {
MemberProductMapper INSTANCE = Mappers.getMapper(MemberProductMapper.class);
MemberProduct toEntity(MemberProductRequestDto requestDto, Member member, Product product);
}
해당 메서드가 원하는대로 구현이 됐는지 확인하기 위해 구현체의 코드를 살펴보았다. 이 구현체는 memberProductMapper 인터페이스를 위와 같이 만들어두었을 때 mapstruct 의존성에 의해 자동으로 생성되는 impl class 이다.
@Override
public MemberProduct toEntity(MemberProductRequestDto requestDto, Member member, Product product) {
if (requestDto == null || member == null || product == null) {
return null;
}
MemberProduct memberProduct = new MemberProduct();
return memberProduct;
}
파라미터로 전달된 dto와 member, product 객체가 하나도 매핑되지 않고 비어있는 memberProduct 객체가 그대로 return 되는 것을 발견할 수 있다.
이를 해결하기 위해 구현체의 코드를 직접 수정할 경우 빌드 시 매퍼의 자동 생성 코드로 덮어쓰기가 되므로 결국 해당 코드를 직접 수정하는 것은 불가능하다. 그렇다면 해당 구현체는 어떻게 객체의 값에 접근해서 수정하는지를 알아야 한다. 사실 이는 우리가 직접 객체를 생성할 때와 동일하게 builder, setter 등을 통해서 이루어진다. 제대로 생성된 다른 메서드를 살펴보자.
@Override
public MemberProductResponseDto toResponseDto(MemberProduct memberProduct) {
if ( memberProduct == null ) {
return null;
}
MemberProductResponseDtoBuilder memberProductResponseDto = MemberProductResponseDto.builder();
memberProductResponseDto.title( memberProductProductTitle( memberProduct ) );
memberProductResponseDto.price( memberProductProductPrice( memberProduct ) );
memberProductResponseDto.productsQuantity( memberProduct.getProductsQuantity() );
return memberProductResponseDto.build();
}
위의 toResopnseDto 메서드는 매퍼가 파라미터로 전달된 memberProduct의 필드와 responseDto의 일치하는 필드를 찾아 매핑하고 bulider를 통해 dto를 생성하여 return해주고 있다.
우리 프로젝트에서는 lombok을 사용하므로 ResponseDto에 다음과 같이 애너테이션이 붙어있다. 매퍼는 이를 활용하여 필드에 접근하는 것이다.
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor
public class MemberProductResponseDto {
}
다만 toEntity 메서드도 MemberProduct 엔티티에 애너테이션을 똑같이 붙여주었는데 매핑이 제대로 되지 않아 오류가 발생했다는 점이 문제가 된다... 그렇다면 다른 문제점이 존재하겠지 원인을 또다시 찾아보자.
위의 toEntity 메서드는 requestDto에 있는 productQuantity와 dto의 productQuantity, 그리고 member와 product 객체를 매핑해주어야 한다. 그런데 아무것도 매핑이 되지 않으니 @Mapping
애너테이션을 이용해 직접 매핑을 해주어보자.
@Mapping(target = "productsQuantity", source = "requestDto.productsQuantity")
@Mapping(target = "member", source = "member")
@Mapping(target = "product", source = "product")
MemberProduct toEntity(MemberProductRequestDto requestDto, Member member, Product product);
@Mapping
애너테이션은 매퍼가 자동으로 코드를 생성할 때 필요한 정보를 제공하여 개발자가 원하는 대로 필드간 매핑이 이루어지도록 도와준다. 특히 source 객체의 필드와 target 객체의 필드 이름이 서로 다를 경우에는 꼭 써줘야 한다.
그러나 이렇게 직접 매핑을 해 주었는데도 다른 오류가 발생하였다.
Several possible source properties for target property "id"
해석해보니 id에 대해 매핑 가능한 소스 프로퍼티가 여러 개 존재해서 발생한 문제라고 한다. 즉 소스 프로퍼티가 명확하지 않아 충돌이 발생한 것이다. 아마도 객체를 파라미터로 전달해주다 보니 MemberProductRequestDto의 id, member의 id, product의 id가 충돌한 것으로 예상된다. 그러나 memberProduct 엔티티의 경우 id 값은 자동 생성되도록 구현했으므로 매핑이 필요하지 않았다. 매핑에서 제외시키기 위해 ignore = ture
로 설정하였다.
(생략...)
@Mapping(target = "id", ignore = true)
MemberProduct toEntity(MemberProductRequestDto requestDto, Member member, Product product);
이제 해결이 되었다. 빌드하니 아래의 코드가 추가된 것을 확인할 수 있다.
@Override
public MemberProduct toEntity(MemberProductRequestDto requestDto, Member member, Product product) {
if ( requestDto == null && member == null && product == null ) {
return null;
}
MemberProductBuilder memberProduct = MemberProduct.builder();
if ( requestDto != null ) {
memberProduct.productsQuantity( requestDto.getProductsQuantity());
}
if ( member != null ) {
memberProduct.member( member );
}
if ( product != null ) {
memberProduct.product( product );
}
return memberProduct.build();
}
테스트하니 정상적으로 값을 반환하는 것을 확인할 수 있었다. 다만 이 해결과정이 글처럼 매끄럽진 않았고... 중간에 @Builder
가 아닌 @Setter
를 사용하여 해결했다가 setter 사용을 지양하기 위해 builder로 다시 바꾸어 빌드를 하기도 했었고, 똑같은 코드였는데 테스트를 통과했다가 재빌드 후 다시 실행하니 테스트를 실패하는 이상한 일이 일어나기도 했었다. 이는 mapper가 구현체를 자동 생성하고 자동으로 매핑을 하다 보니 매핑 필드를 명확하게 지정해주지 않을 경우 빌드할 때마다 조금씩 다르게 구현되는 경우가 있는 듯하다.
그리고 다른 메서드에서도 mapper 오류가 있었는데 이는 totalPrice라는 필드에 매핑할 필드가 없다면서 발생하였다. 해당 필드는 객체 생성 후에 값을 계산하여 넣어주는 로직으로 구현을 했으므로 requetsDto로 들어오지 않는 값이었기 때문에 0이라는 고정값을 주는 것으로 해결하였다.
@Mapping(target = "totalPrice", constant = "0")
결론은 mapstruct mapper 사용 시
1. 매퍼가 필드에 접근할 수 있도록 할 것 (@Builder
, @Setter
등 사용)
2. 매핑 필드가 모호하거나 자동 매핑이 제대로 이루어지지 않을 경우 @Mapping
으로 필드를 직접 지정해 줄 것
mapper에 대해 더욱 이해할 수 있게 되는 시간이었다...
다음은 영속성과 관련된 오류이다. 서비스 로직은 다음과 같이 장바구니에 존재하는 물건들을 구매하는 것이고 총 금액을 계산하고 물건을 모두 저장한 뒤, 장바구니를 비우는 것까지 하나의 메서드에서 이루어진다.
첫 번째 오류는 purchase 객체를 생성 한 뒤 PurchaseProduct 엔티티에서 필드에 접근하였는데, 저장되지 않은 상태의 purchase 인스턴스를 참조하려고 한다며 발생했다. 즉, 영속성 전이 설정이 필요한 상황에서 영속성 컨텍스트에 저장되지 않은 엔티티를 참조하여 발생한 것이다.
org.hibernate.TransientPropertyValueException: object references an unsaved transient instance - save the transient instance before flushing :
이는 purchase 객체 저장을 먼저 해주는 것으로 로직을 변경하여 금방 해결할 수 있었다.
// 수정 전
@Override
@Transactional
public PurchaseResponseDto purchase(PurchaseRequestDto requestDto, Member member) {
// 장바구니 목록 찾아오기
Purchase purchase = PurchaseMapper.INSTANCE.toEntity(requestDto, member);
...
purchase.getPurchaseProductList().addAll(purchaseProductList);
// 총 금액 업데이트
purchase.updateTotalPrice(calTotalPrice(purchaseProductList));
...
purchaseRepository.save(purchase); // 여기에 있던 save를
return purchase.createResponseDto();
}
// 수정 후
// 장바구니 목록 찾아오기
Purchase purchase = PurchaseMapper.INSTANCE.toEntity(requestDto, member);
...
purchaseRepository.save(purchase); // 여기로 이동
purchase.getPurchaseProductList().addAll(purchaseProductList);
// 총 금액 업데이트
purchase.updateTotalPrice(calTotalPrice(purchaseProductList));
// 장바구니 비우기
memberProductRepository.deleteAllByMemberId(member.getId());
return purchase.createResponseDto();
다만 이렇게 되니 또 다른 오류가 발생하였는데 총 구매 금액을 계산한 뒤, 그 값을 response 하는 것은 잘 이루어졌지만 DB에는 저장되지 않았다.
이는 총 금액 계산 메서드가 장바구니(memberProduct)를 통해 계산을 하는 로직을 가지고 있는데 업데이트 후, 장바구니를 비우기 위해 레포지토리에서 삭제 해버렸기 때문에 발생한 오류이다. 즉 영속성 컨텍스트에서의 관리와 관련된 것으로 같은 트랜젝션 내에서 수정과 삭제가 모두 이루어지기 때문에 DB에는 반영되지 않을 수 있다고 한다.
// 총 금액 계산 메서드
private int calTotalPrice(List<PurchaseProduct> purchaseProductList) {
return purchaseProductList.stream()
.mapToInt(purchaseProduct -> purchaseProduct.getProduct().getPrice()
* purchaseProduct.getProductsQuantity())
.sum();
}
업데이트 후, purchase 엔티티를 다시 저장해주고 delete를 하는 것으로 해결하였다.
// 총 금액 업데이트
purchase.updateTotalPrice(calTotalPrice(purchaseProductList));
purchaseRepository.save(purchase); // 한 번 더 저장
// 장바구니 비우기
memberProductRepository.deleteAllByMemberId(member.getId());