프로젝트에서 DB에 이미지 경로를 저장하려고 했는데 일부분이 중복되어 나중에 데이터가 많아지면 불필요한 저장공간을 차지하게 될거라고 판단하여 해당 부분을 따로 로직을 작성해서 해결하기로 했다.
준비
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0' // v1.18.16 ~
// MapStruct
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
기본적으로는 Lombok과 MapStruct는 build.gradle에 작성하는 순서가 중요한데 lombok-mapstruct-binding을 추가해주면 순서에 따른 오류를 해결해준다고 한다. 지금은 순서를 맞춰서 작성해서 딱히 필요는 없지만 그냥 놔뒀다.
@Builder
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "products")
@Entity
public class Product extends BaseDateTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String description;
private String image;
private Integer price;
private Integer quantity;
@ManyToOne
@JoinColumn(name = "store_id")
private Store store;
public void setQuantity(Integer quantity) {
this.quantity = quantity;
}
}
ㄴ Product 엔티티
@Getter
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Builder
@Table(name = "stores")
@Entity
public class Store extends BaseDateTimeEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@ColumnDefault("0")
private Integer deliveryFees;
@Column(name = "region_code")
private String regionCode; // 법정동 코드
private String address; // 구 주소
@Column(name = "road_address")
private String roadAddress; // 도로명 주소
@Column(name = "logo_image")
private String logoImage;
private String image1;
private String image2;
private String image3;
private String tel;
private String description;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "category_id")
private Category category;
@OneToMany(mappedBy = "store")
private List<Product> productList;
public void setCategory(Category category) {
this.category = category;
}
}
ㄴ Store 엔티티
@Builder
@Getter
public class ResponseStore {
private Long id;
private String name;
private Integer categoryId;
private Integer deliveryFees;
private String address; // 구 주소
private String roadAddress; // 도로명 주소
private String logoImage;
private String image1;
private String image2;
private String image3;
private String tel;
private String description;
private List<ResponseProduct> productList;
}
ㄴ ResponseStore DTO
위 DTO를 보면 ResponseProduct 내의 이미지 경로가 수정되어 있어야 하기 때문에 Store -> ResponseStore로 변환하는 과정에서 Product -> ResponseProduct로 변환하는 과정을 추가로 거쳐야 한다. 그런데 List이기 때문에 List 내의 요소들을 순환하면서 모두 변환해주는 과정을 거쳐야 한다. 상당히 복잡하긴 하지만 다행히 StoreMapper 내에서 위 List 변환을 모두 작성해주어야 하는 것이 아니라 ProductMapper를 가져와 사용하면 된다.
@Mapper(componentModel = ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface ProductMapper {
ProductMapper INSTANCE = Mappers.getMapper(ProductMapper.class);
@Mapping(source = "image", target = "image", qualifiedByName = "mapImageUrl")
ResponseProduct toResponseProduct(Product product);
@Named("mapImageUrl")
default String mapImageUrl(String image) {
if (StringUtils.hasText(image)) return "/images/product/" + image;
else return null;
}
}
ㄴ ProductMapper
DB에는 dessert/1/초콜릿.jpg 와 같이 저장되어 있기 때문에 이를 /images/product/dessert/1/초콜릿.jpg 이렇게 변환해서 반환해야 한다. 그래서 default 메서드를 하나 작성했다. 내용이 없으면 /images/product/null 이렇게 반환되기 때문에 이 경우에는 그냥 null을 반환하도록 했다.
@Mapper(componentModel = ComponentModel.SPRING, unmappedTargetPolicy = ReportingPolicy.IGNORE,
uses = {ProductMapper.class})
public interface StoreMapper {
StoreMapper INSTANCE = Mappers.getMapper(StoreMapper.class);
ResponseStore toResponseStore(Store store);
ResponseListStore toResponseListStore(Store store);
Store toStore(RequestSaveStore requestSaveStore);
ResponseSaveStore toResponseSaveStore(Store store);
}
ㄴ StoreMapper
uses로 ProductMapper를 사용한다고 설정한다. 이렇게 하면 따로 추가적으로 무엇을 작성하지 않아도 Product 관련 매핑은 ProductMapper를 이용해서 진행된다. 다만 ProductMapper 안에 해당 변환을 담당하는 메서드가 선언되어 있어야 한다.

컴파일 시 자동으로 생성되는 StoreMapperImpl 클래스 내에 이렇게 스프링 의존 관계 주입을 이용해서 ProductMapper 구현체를 주입 받는 것을 확인할 수 있다.
문제 발생
분명 틀린 곳 없이 잘 작성했다고 생각했는데 아래와 같은 예외가 발생했다...
java.lang.NullPointerException: Cannot invoke "com.megamaker.storeservice.mapper.ProductMapper.toResponseProduct(com.megamaker.storeservice.entity.Product)" because "this.productMapper" is null
at com.megamaker.storeservice.mapper.StoreMapperImpl.productListToResponseProductList(StoreMapperImpl.java:106) ~[main/:na]
at com.megamaker.storeservice.mapper.StoreMapperImpl.toResponseStore(StoreMapperImpl.java:46) ~[main/:na]
at com.megamaker.storeservice.service.StoreServiceImpl.find(StoreServiceImpl.java:42) ~[main/:na]
at com.megamaker.storeservice.controller.StoreController.find(StoreController.java:48) ~[main/:na]
StoreMapper 내에서 ProductMapper를 가져오지 못한다고 한다. 왜 null일까 한참을 고민하고 검색을 해보았지만 해결 방법은 찾지 못했다...
그러다가 갑자기 무언가 떠올라서 수정해보았더니 해결이 되었다.
이유는 스프링 빈을 생성해놓고 사용하지 않았던 것 때문이었다.

바로 위 부분이 문제이다.

StoreMapper를 이렇게 바로 접근해서 사용하고 있는데 이렇게 하면 스프링에서 관리하는 Mapper를 가져다가 사용하는 것이 아니기 때문에 의존 관계 주입이 되지 않았던 것이다.
해결하기



해결 방법은 간단하다. 위와 같이 스프링 의존 관계를 받도록 수정하면 된다.

이제 오류 없이 경로가 잘 작성되어 응답되는 것을 확인할 수 있다!
'공부 > Spring' 카테고리의 다른 글
| [Spring] 프록시 팩토리(Proxy Factory) 사용하기 (0) | 2024.07.25 |
|---|---|
| [Spring] CGLIB로 프록시 직접 만들기 (0) | 2024.07.24 |
| [Spring] Resolved [org.springframework.web.HttpRequestMethodNotSupportedException: Request method 'POST' is not supported] (0) | 2024.05.24 |
| [Spring Web] Spring MVC 컨트롤러 요청/응답 가능한 여러가지 방법 (0) | 2024.03.21 |
| [Spring Web] http body 내용 가져오기 (MapStruct 안 되는 이유) (0) | 2024.03.16 |