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)

image-20210727110316522

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로 변환하는 경우 순환 참조로 오류가 발생합니다.

image-20220114094016920

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가 발생합니다.

image-20220112161046025

원인은 자동으로 생성된 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에서 직접 구현하는 코드수보다는 적기 때문에 사용하지만… 다소 아쉬운 부분이 있는건 사실입니다.

댓글남기기