MapStruct
MapStruct 소개
-
https://mapstruct.org/
-
dto -> entity 또는 entity -> dto로 변환할 때 쓰이는 라이브러리.
MapStruct 이외에도 ModelMapper, Orika, Jmapper 등이 있음.
-
Mapper Interface를 구현하고 @Mapper를 Annotated하면 빌드 시점에 자동으로 구현체 클래스를 생성해줍니다.
빌드(컴파일) 시점에 오류를 알 수 있기 때문에 개발하기 편리함.
리플렉션을 사용하지 않고 구현체 클래스를 호출 하기 때문에 속도가 빠름.
Intellij 기준으로 src/main/generated 아래 interface와 동일한 구조로 생성됨.
-
@Mapping 애노테이션으로 지정된 property는 JavaBeans spectification에 만족해야 됩니다.
-
Mapper Interface를 구현할 때 entity와 dto의 property name(변수명)이 같으면 암묵적(자동)으로 맵핑 됩니다.
property name(변수명)이 다른 경우에는 @Mapping 애노테이션을 사용할 수 있습니다.
dto나 entity에서 직접 변환하는 builder를 만드는 경우에는 변수의 추가, 삭제될 때 builder를 수정해야 됩니다.
-
여러 Mapper Interface에서 반복되는 @Mapping은 Meta Annotation을 정의하여 사용할 수도 있습니다.
@Retention(RetentionPolicy.CLASS) @Mapping(target = "id", ignore = true) @Mapping(target = "creationDate", expression = "java(new java.util.Date())") @Mapping(target = "name", source = "groupName") public @interface ToEntity { }
-
CDI(Context Denpency Injection)와 같은 종속성 주입 프레임워크(Spring framework 등)로 작업 주인 경우 DI를 통해 개체를 가져오는 것이 좋습니다. @Mapper 애노테이션의 componentModel 속성값을 사용할 수 있습니다.
- default
@Mapper public interface PostalMapper { PostalMapper INSTANCE = Mappers.getMapper(PostalMapper.class); D toDto(E e); } PostalMapper.INSTANCE.toDto(e);
- cdi
@Mapper(componentModel = "cdi") public interface PostalMapper { //... } @Inject
- spring
@Mapper(componentModel = "spring") public interface PostalMapper { //... } @Autowired
- jsr330
@Mapper(componentModel = "jsr330") public interface PostalMapper { //... } @Singleton
설정 및 테스트
테스트 환경
Intellij Ultimate Edition
AdoptOpenJDK 11
spring boot 2.5.3
mapstruct 1.4.2.Final
junit 5 (jupiter)
프로젝트 설정에서 Gradle(Default)에서 Intellij IDEA로 변경. (Setting > Builde, … > Build Tools > Gradle)
build.gradle
dependencies {
implementation "org.mapstruct:mapstruct:1.4.2.Final"
implementation 'org.projectlombok:lombok'
implementation 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor "org.mapstruct:mapstruct-processor:1.4.2.Final"
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
...
BankDto.java
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
public class BankDto {
@NotBlank
private String bankCd;
@NotBlank
private String bankName;
}
BankEntity.java
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = "BANK")
@Entity
public class BankEntity implements Serializable {
@Id
private String bankCd;
@NotNull
private String bankName;
}
GenericMapper.java
public interface GenericMapper<D, E> {
D toDto(E entity);
E toEntity(D dto);
}
BankMapper.java
@Mapper(componentModel = "spring"
, injectionStrategy = InjectionStrategy.CONSTRUCTOR
, unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface BankMapper extends GenericMapper<BankDto, BankEntity> {
}
BankService.java
@Service
@RequiredArgsConstructor
public class BankService {
private final MemberMapper memberMapper;
...
}
BankMapperTest.java
@DisplayName("BankMapper 테스트")
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
class BankMapperTest {
BankMapper bankMapper;
@BeforeAll
void setUp() {
this.bankMapper = Mappers.getMapper(BankMapper.class);
}
@Test
@DisplayName("entity -> dto 변환이 정상적으로 된다.")
void test_toDto() {
// given
BankEntity bankEntity = BankEntity.builder()
.bankCd("A100")
.bankName("기업은행")
.build();
// when
BankDto resultDto = this.bankMapper.toDto(bankEntity);
// then
assertEquals(resultDto.getBankCd(), "A100");
assertEquals(resultDto.getBankName(), "기업은행");
}
@Test
@DisplayName("dto -> entity 변환이 정상적으로 된다.")
void test_toEntity() {
// given
BankDto bankDto = BankDto.builder()
.bankCd("A100")
.bankName("기업은행")
.build();
// when
BankEntity bankEntity = this.bankMapper.toEntity(bankDto);
// then
assertEquals(bankEntity.getBankCd(), "A100");
assertEquals(bankEntity.getBankName(), "기업은행");
}
}
순환 참조 오류
Spring JPA로 구현할 때 양방향 관계를 가지는 엔티티를 DTO로 변환하는 경우 순환 참조로 오류가 발생합니다.
FormEntity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@SuperBuilder
@Entity
@Table(name = "TB_FORM")
public class FormEntity extends BaseEntity implements Serializable {
@Id
private Long formSeq;
private String formName;
@OneToMany(mappedBy = "form", fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@Builder.Default
private List<FormHistoryEntity> historys = new ArrayList<>();
public void addHistory(FormHistoryEntity history) {
this.historys.add(history);
history.setForm(this);
}
}
FormHistoryEntity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
@Builder
@Table(name = "TB_FORM_HISTORY")
@Entity
public class FormHistoryEntity implements Serializable {
@Id
private Long historySeq;
private String historyName;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "form_seq", foreignKey = @ForeignKey(name = "FK_FORM_HISTORY"))
private FormEntity form;
public void setForm(FormEntity form) {
this.form = form;
}
}
FormDto
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FormDto {
private Long formSeq;
private String formName;
@Builder.Default
private List<FormHistoryDto> historys = new ArrayList<>();
}
FormHistoryDto
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class FormHistoryDto {
private Long historySeq;
private String historyName;
private FormDto form;
}
FormMapper
@Mapper(componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
public interface FormMapper extends GenericMapper<FormDto, FormEntity> {
}
테스트 코드
class FormMapperTest {
FormMapper formMapper;
@BeforeEach
void setUp() {
this.formMapper = Mappers.getMapper(FormMapper.class);
}
@Test
@DisplayName("entity -> dto 정상적으로 변환 된다.")
void test_toDto() {
// given
FormEntity formEntity = FormEntity.builder()
.formSeq(0L)
.formName("테스트폼")
.build();
FormHistoryEntity history1 = FormHistoryEntity.builder()
.historySeq(1L)
.historyName("히스토리1")
.form(formEntity)
.build();
FormHistoryEntity history2 = FormHistoryEntity.builder()
.historySeq(2L)
.historyName("히스토리2")
.form(formEntity)
.build();
formEntity.addHistory(history1);
formEntity.addHistory(history2);
// when
FormDto formDto = this.formMapper.toDto(formEntity);
// then
assertEquals(0L, formDto.getFormSeq());
assertEquals(2, formDto.getHistorys().size());
assertEquals(1L, formDto.getHistorys().get(0).getHistorySeq());
}
}
테스트를 실행하면 아래와 같이 순환 참조 오류로 StackOverflowError가 발생합니다.
원인은 자동으로 생성된 Mapper 구현체를 보면 알 수 있는데요.
public class FormMapperImpl implements FormMapper {
@Override
public FormDto toDto(FormEntity arg0) {
if ( arg0 == null ) {
return null;
}
FormDtoBuilder formDto = FormDto.builder();
formDto.formSeq( arg0.getFormSeq() );
formDto.formName( arg0.getFormName() );
formDto.historys( formHistoryEntityListToFormHistoryDtoList( arg0.getHistorys() ) );
return formDto.build();
}
protected List<FormHistoryDto> formHistoryEntityListToFormHistoryDtoList(List<FormHistoryEntity> list) {
if ( list == null ) {
return null;
}
List<FormHistoryDto> list1 = new ArrayList<FormHistoryDto>( list.size() );
for ( FormHistoryEntity formHistoryEntity : list ) {
list1.add( formHistoryEntityToFormHistoryDto( formHistoryEntity ) );
}
return list1;
}
protected FormHistoryDto formHistoryEntityToFormHistoryDto(FormHistoryEntity formHistoryEntity) {
if ( formHistoryEntity == null ) {
return null;
}
FormHistoryDtoBuilder formHistoryDto = FormHistoryDto.builder();
formHistoryDto.historySeq( formHistoryEntity.getHistorySeq() );
formHistoryDto.historyName( formHistoryEntity.getHistoryName() );
formHistoryDto.form( toDto( formHistoryEntity.getForm() ) );
return formHistoryDto.build();
}
}
Form 엔티티에 포함된 List<FormHistoryEntity> historys를 Dto로 변환하는 formHistoryEntityToFormHistoryDto() 메서드에서 toDto( … )를 재귀호출 하기 때문에 순환참조 오류가 발생하는 것입니다.
해결방법
관계를 맺는 두 개 엔티티를 분리하여 생성하고 다시 결합하는 형태로 수정합니다.
이외 고려해야될 사항들
- LazyInitializationException: @Transactional이 명시되지 않는 메서드에서 영속성 Entity 객체를 Dto로 변환하기 위해 FetchType.LAZY Entity를 액세스하면 발생하는 오류.
- Infinite recursion nested exception: Entity에서 Dto로 변환 후 클라이언트로 전송할 때 JSON으로 변환하는 과정에서 순환 참조로 인해 발생하는 오류.
수정된 버전
@Mapper(componentModel = "spring",
injectionStrategy = InjectionStrategy.CONSTRUCTOR,
unmappedTargetPolicy = ReportingPolicy.IGNORE)
// , builder = @Builder(disableBuilder = true))
public interface FormMapper extends GenericMapper<FormDto, FormEntity> {
// dto -> entity 변환 메서드. (Mapper 구현체에 생성되지 않음.)
default FormDto toDto(FormEntity entity) {
FormDto formDto = createFormDto(entity);
if(formDto == null) {
return null;
}
if(entity.getHistorys() != null) {
try {
for (FormHistoryEntity formHistoryEntity : entity.getHistorys()) {
formDto.getHistorys().add(createHistoryDto(formHistoryEntity));
}
} catch (LazyInitializationException ignored) {}
}
return formDto;
}
// entity -> dto 변환 메서드 (Mapper 구현체에 생성되지 않음.)
default FormEntity toEntity(FormDto dto) {
FormEntity formEntity = createFormEntity(dto);
if(formEntity == null) {
return null;
}
if(dto.getHistorys() != null) {
for (FormHistoryDto history : dto.getHistorys()) {
formEntity.addHistory(createHistoryEntity(history));
}
}
return formEntity;
}
// FormEntity -> FormDto로 변환하고 historys 속성은 무시한다. (Mapper 구현체에 자동 생성됨.)
@Mapping(target = "historys", ignore = true)
FormDto createFormDto(FormEntity entity);
// FormHistoryEntity -> FormHistoryDto로 변환하고 form 속성은 무시한다. (Mapper 구현체에 자동 생성됨.)
@Mapping(target = "form", ignore = true)
FormHistoryDto createHistoryDto(FormHistoryEntity entity);
// FormDto -> FormEntity 변환하고 historys 속성은 무시한다. (Mapper 구현체에 자동 생성됨.)
@Mapping(target = "historys", ignore = true)
FormEntity createFormEntity(FormDto dto);
// FormHistoryDto -> FormHistoryEntity 변환하고 historys 속성은 무시한다. (Mapper 구현체에 자동 생성됨.)
@Mapping(target = "form", ignore = true)
FormHistoryEntity createHistoryEntity(FormHistoryDto dto);
}
Entity나 Dto에서 직접 구현하는 코드수보다는 적기 때문에 사용하지만… 다소 아쉬운 부분이 있는건 사실입니다.
댓글남기기