Entity to DTO, DTO to Entity 그리고 ModelMapper

2021. 10. 6. 18:56 Spring Data/Spring Data JPA Querydsl

Entity 클래스란 JPA에서 실제 데이터베이스의 테이블과 매칭되는 클래스입니다. JPA를 사용하면서 Entity 클래스를 작성하였고, 프로젝트 초기에는 Entity로 Repository 뿐만 아니라 Service, Controller 영역까지 사용하였습니다.

 

Entity를 화면에 띄우는 데까지 사용하다 보니 양방향으로 연결된 엔티티는 순환 참조 문제가 발생하였고, 다른 Entity를 참조하고 있는 경우 현재 Entity 뿐만 아니라 다른 Entity에도 원치 않는 변경이 일어나거나, 무거운 양의 데이터를 들고 여러 영역을 오가는 것이 성능 상에도 좋지 않을 것으로 생각하였습니다.

 

따라서 DB Layer에는 Entity, View Layer에서는 DTO를 사용하여 역할을 분리하여 Entity와 DTO가 각자만의 역할을 충실히 수행할 수 있게 하였습니다.

다음과 같이 실제 DB와 mapping되고 데이터의 저장, 수정, 조회, 삭제에 대해 데이터를 관리하고 보관하는 Persistence Layer까지는 Entity를 사용하였으며, Service 영역에서부터 클라이언트 영역까지는 DTO를 사용했습니다.

 

 

이렇게 분리하고 나니, 비슷하지만 조금은 다른 이 두 클래스를 어떻게 매핑시킬 것인가가 문제가 되었습니다. 처음에는 DTO와 Entity가 바뀌는 지점인 Service에서 DtoToEntity, EntityToDto로 하나씩 set을 시켜주어야 하는건가 하고 생각했습니다. 하지만 이와 비슷한 사례가 있을 것으로 생각해 검색해본 결과 ModelMapper라는 것이 있어 사용해보기로 했습니다.

 

 

 

ModelMapper 도입 초기와 발생한 문제 

처음에 구글링을 통해 ModelMapper라는 것을 알게 되어 ModelMapper를 사용하는 방법에 대한 글을 보고 따라해보는 식으로 도입해 보았습니다.

 

gradle에 ModelMapper 의존성을 추가한 뒤에 다음과 같이 Bean으로 등록해서 사용했습니다.

@Bean
public ModelMapper modelMapper() {
	return new ModelMapper();
}

 

그리고 Service 클래스에서 private 메서드를 만들어 PfmEntity를 PfmDto로, List<TransactionEntity>를 List<TransactionDto>로 convert할 수 있도록 했습니다. 

 

이 방법에는 많은 문제점이 존재했고, 다음과 같이 개선해 나갔습니다.

 

문제 1. Bean으로 등록한 ModelMapper

스프링에서 Bean으로 등록된 이후로부터는 해당 Bean은 스프링이 직접 관리하게 됩니다. 그런데 ModelMapper와 같은 용도라면 굳이 Bean으로 등록해 주지 않고 다음과 같이 사용하는 방법으로 바꾸었습니다.

public class ModelMapperUtils {

	private static ModelMapper modelMapper = new ModelMapper();

	public static ModelMapper getModelMapper() {
		return modelMapper;
	}
}

 

문제 2. Service 클래스에 존재하는 매핑 관련 메서드

위와 같이 서비스 클래스에 convertEntityToDto와 같은 메서드를 작성하면서 느낀 점은 서비스 로직과 관련 없는 매핑 관련 메서드가 너무 많다는 점이었습니다.  따라서, 다음 예시와 같이 각 DTO 클래스, Entity 클래스에 static으로 작성해서 분리했습니다.

class PfmEntity {

	// ...
	// 필드 생략
    // ... 

	public static PfmEntity of(PfmDto pfmDto) {
    
    		// 이름이 같은 필드명끼리는 ModelMapper를 통해 매핑
    		PfmEntity pfmEntity = ModelMapperUtils.getModelMapper().map(pfmDto, PfmEntity.class);
        
       		// 이름이 다른 필드는 직접 set을 통해 매핑
        	pfmEntity.setBalance(pfmDto.getIncome()-pfmDto.getExpense());
        
        	return pfmEntity;
        
    }
}

그래서 서비스 로직에서 Entity와 DTO 간 매핑이 필요할 때는 다음과 같이 사용할 수 있었습니다.  

public PfmDto createPfm(PfmDto pfmDto) {

	// 1. Repository에 인자로 넘겨주기 위한 DTO to Entity
	PfmEntity pfmEntity = PfmEntity.of(pfmDto);

	// 2. DB에 저장
	pfmEntity = pfmRepository.save(pfmEntity);
    
    	// 3. 저장한 결과를 DTO로 반환하기 위한 Entity to DTO
   		return PfmDto.of(PfmEntity);

}

 

 

문제 3. 매핑이 필요할 때마다 작성했던 수 많은 메서드 

ModelMapper와 관련 없는 내용이지만, 어떤 상황에서 매핑을 시켜주어야 할지 명확하게 정의하지 못한 상태에서 매핑을 하게 되어 발생한 문제입니다.

1 ) View Layer에서 사용할 DTO가 하나씩 생겨날 때마다 그에 맞는 매핑이 필요했습니다. 

2 ) 데이터베이스 설계를 변경했을 때도 또 그에 맞는 매핑이 필요했습니다.

 

이전에는 PfmEntity, TransactionEntity만 가지고 데이터베이스를 설계했습니다.

하지만 개인 가계부와 그룹 가계부를 명확하게 분리해야겠다는 생각이 들어 PfmEntity를 PersonalPfmEntity, GroupPfmEntity로, TransactionEntity는 PersonalTransactionEntity, GroupTransactionEntity로 분리하였습니다.

그래서 DTO도 PersonalPfmDto, GroupPfmDto, PersonalTransactionDto, GroupTransactionDto를 작성했습니다. 데이터베이스 간의 관계를 조금 더 명확하게 하고자 분리하였는데, 이에 따라 코드 양은 훨씬 많아졌고 if문도 많아졌습니다.

또한, 개인 가계부와 그룹 가계부에는 공통적인 속성들이 많기 때문에 두 개의 엔티티를 공통의 DTO로 띄워줄 수 있도록 PfmDto, TrnasactionDto도 작성했습니다.

 

간략하게 entity와 dto의 변화를 보여드리자면, 처음에는 이렇게 구성되어 있었는데

엔티티를 추가하고 나니 이렇게 수많은 DTO를 작성하게 되었습니다. 물론 이 많은 Entity와 DTO의 매핑을 모두 해주었습니다.

 

이 문제는 ModelMapper 관련 문제가 아니라, DTO를 명확하게 정의하지 않았고 필요할 때마다 DTO를 막 만들어냈기 때문이라고 생각합니다.

 

그래서 저는 다음과 같이 클라이언트가 보내거나, 화면에서 볼 때 사용되는 DTO는 PfmDto, TransactionDto로 통일시키기로 하였고 pfmType이라는 필드를 추가해 Personal인지 Group인지 구분할 수 있도록 하였습니다. 

 

이 문제를 개선하면서, 실제 Entity와 DTO를 명확하게 정의하고 개발하는 것이 중요하다고 느꼈습니다. 항상 개발하기 전에는 이번에는 정말 제대로 설계하고 계획해야지 하는데 미래의 일을 모두 예측할 수 없기 때문에 이런 시행착오를 경험하는 것 같습니다.

 

 

 

문제 4. List<Entity> 와 List<Dto> 간의 매핑 (Stream에 대한 이해 부족)

저는 PfmDto, TransactionDto 이외에도 PfmDetailDto 라는 DTO를 만들었습니다. 이것의 용도는 한 가계부와 그 가계부에서 발생한 거래 내역을 한 DTO로 하여 반환하는 것입니다.

class PfmDetailDto {
	
    // 가계부 DTO
    private PfmDto pfm;
    
    // 거래 DTO List
    private List<TransactionDto> transactionList;
    
}

PfmEntity와 List<TransactionEntity>를 PfmDetailDto에 매핑하기 위해서는 

1 ) PfmEntity => PfmDto

2) List<TransactionEntity> => List<TransactionDto>

두 번의 매핑이 필요합니다.

class PfmDetailDto {

	// ...
    
    public static PfmDetailDto of(PfmEntity, List<TransactionEntity>) {
    	
        PfmDetailDto pfmDetailDto = new PfmDetailDto();
        
        // PfmEntity to PfmDto
        PfmDto pfmDto = PfmDto.of(PfmEntity);
        
        // List<TransactionEntity> to List<TransactionDto>
        ???? ㅠㅠ
        
        
       return pfmDetailDto;
    }
}

 

위와 같이 PfmEntity to PfmDto는 저렇게 하면 되는데, List<TransactionEntity>에서 List<TransactionDto>로 변환하려고 하니 문제에 봉착하였습니다. Stream을 많이 써보지 못해서 List 간의 매핑은 다음과 같이 하면 modelmapper를 이용해서 매핑할 수 있다고 하여 따라하기만 했습니다. 다음과 같이 매핑했을 때의 단점은 기본적으로 매핑에 사용할 필드들을 이름으로 비교하여 매핑하는 ModelMapper로 매핑하기 때문에 이름이 다른 경우에는 매핑되지 않는 점이었습니다.

transactionDto = transactionEntityList
		.stream()
		.map(transactionEntity -> ModelMapperUtils.getModelMapper().map(transactionEntity, TransactionDto.class))
		.collect(Collectors.toList());

 

그래서 위의 동작을 이해해 보면서 Stream을 공부했습니다. 

 

 

******* 뜬금 Stream 정리하기 :p

만약 제가 의도하는 대로 (Stream을 사용하지 않고) List<Entity> to List<Dto>를 하기 위해서 코드를 짠다면,

Entity to Dto를 할 수 있는 public static Dto of(entity)라는 메서드가 있기 때문에 다음과 같이 작성할 수 있습니다.

List<Dto> dtoList = new ArrayList<Dto>();

for(Entity entity : entityList) {
	Dto dto = Dto.of(entity);
    dtoList.add(dto);
}

 

하지만 Stream도 모르면서 저렇게 코딩하고 싶진 않은데, 더 나은 방법이 있을텐데 이런 고민을 하다보니 방법은 Stream 사용을 이해하고 쓰는 수 밖에 없다고 생각했습니다. 그래서 짧게나마 공부한 Stream에 대해 적어보도록 하겠습니다.

 

Stream을 사용한다면 배열 또는 컬렉션 인스턴스에 함수 여러 개를 조합해 필터링하거나 가공해서 원하는 결과를 얻을 수 있습니다. 또한, 람다식을 적용하여 코드의 양을 줄이고 간결하게 표현할 수 있습니다. 그리고 스레드를 이용해서 많은 요소를 간단하고 빠르게 병렬 처리할 수 있습니다.

 

List<Entity> to List<Dto>를 위해 제가 작성한 코드를 보면서 세 가지 과정으로 분류해서 살펴보겠습니다.

List<Entity> entityList 
		= EntityList.stream()		// 1. 스트림 생성
            	.map( entity -> Dto.of(entity))	// 2. 가공
                .collect(Collectors.toList());	// 3. 결과 반환

 

1. list.stream() : List와 같은 컬렉션 인스턴스를 스트림으로 생성합니다.

2. map(entity -> Dto.of(entity) : 저는 매핑을 했지만, 필터링과 같이 스트림을 가공하는 여러 가지 방법이 존재합니다. 아무튼 스트림 데이터를 가공하여 결과를 반환하기 전에 거치는 과정입니다.

3. collect(Collectors.toList()) : 가공된 Stream을 List라는 최종 결과로 만들어 반환합니다.

 

따라서, 제가 이전에 따라한 ModelMapper를 이용한 스트림 처리인 이 부분은

map(transactionEntity -> ModelMapperUtils.getModelMapper().map(transactionEntity, TransactionDto.class))

Entity를 modelMapper로 매핑한 결과(DTO)로 매핑하는 작업이었습니다.

 

알고나니 정말 별거 아니여서 잽싸게 적용했습니다. 그리고 람다식의 '메서드 참조'를 통해 좀 더 코드를 간결하게 할 수 있었습니다.

메서드 참조는 '클래스명::메서드명', '참조변수::메서드'와 같이 작성해서 람다식이 하나의 메서드만을 호출하는 경우 람다식을 간단하게 할 수 있는 방법입니다.

 

그래서 결과적으로 이런식으로 사용할 수 있게 되었습니다.

 

ModelMapper를 사용하면서 발생한 오류 

나름 이런식으로 문제를 개선해가다 보니, 코드를 좀 더 간결하게 쓸 수 있게 되었습니다. 그래서 다음으로는 ModelMapper를 사용하면서 발생한 예외나 에러를 처리했던 경험을 써보겠습니다.

 

예외 1. No Default Constructor

서비스 클래스에 @Transactional 어노테이션을 붙이고 DB에 insert를 해야 하는 서비스 기능에 대한 코드를 작성하고 테스트를 했는데, 분명히 잘못한 부분이 없을텐데 insert가 되지 않는 현상을 겪었습니다.

 

로그를 확인하니 rollback되었다는 것만 보이고 어떤 부분에서 문제가 발생한지는 찾을 수 없었습니다. 그래서 브레이크 포인트를 써가면서 어떤 부분에서 이상한 점이 있는지 꼼꼼히 찾아보았습니다. 

 

다음 코드에서 첫 번째 코드(insert)는 정상적으로 수행되는데 return이 있는 부분에서 예외가 발생하는 것 같았습니다.

// insert
TransactionEntity transactionEntity = transactionRepository.save(transaction);

// Entity to DTO 및 return
return TransactionDto.of(transactionEntity);

TransactionDto.of()가 잘못되었다고 생각이 들어서, 해당 메서드 내부로 들어가 브레이크 포인트를 찍어보았습니다. 메서드는 다음과 같은 코드가 있는데 저는 ModelMapper를 이용해 이름이 같은 필드들을 먼저 매핑한 후에 이름이 다른 필드들은 setter를 이용해 별도로 매핑하였습니다.

public static TransactionDto of(TransactionEntity transactionEntity) {
	
    // ModelMapper를 이용해 이름이 같은 필드들은 먼저 매핑
    TransactionDto transactionDto = ModelMapperUtils.getModelMapper().map(transactionEntity, TransactionDto.class);
    
    // setter를 이용해 이름이 다른 필드들은 별도로 매핑
    ...
    
    return transactionDto;
}

 

찾아 보니 이런 예외를 발견하였습니다.

Failed to instantiate instance of destination *.dto.TransactinoDto. Ensure that *.dto.TransactionDto has a non-private no-argument constructor.

 

ModelMapper의 레퍼런스를 찾아보니 다음과 같이 나와있었습니다.

 

Providers allow you to provide your own instance of destination properties and types prior to mapping as opposed to having ModelMapper construct them via the default constructor. They can be configured globally, for a specific TypeMap, or for specific properties.

 

ModelMapper는 해당 클래스의 기본 생성자를 이용해 객체를 생성하고 setter를 이용해 매핑을 하는데, 이전의 TransactionDto에 기본 생성자가 아닌 다른 생성자를 만들고 기본 생성자가 없어 발생한 문제입니다. 따라서, DTO에 기본 생성자를 만들어 문제를 해결하였습니다.

 

 

 

오류 2. ModelMapper의 매핑 전략에 따른 오류

TransactionEntity를 PersonalTransactionEntity와 GroupTransactionEntity로 분리하고, Dto는 TransactionDto로 공통으로 사용하기로 결정했을 때 각 Entity와 DTO에는 다음과 같은 필드가 있었습니다.

 

PersonalTransactionEntity의 personalPfm

- PersonalTranscationEntity는 개인 가계부(personalPfm)에서 발생한 거래에 대한 데이터베이스이기 때문에, 해당 거래가 속하는 개인 가계부를 필드로 두었습니다.

@ManyToOne(optional = false)
@JoinColumn(name = "personalPfmSeq")
private PersonalPfmEntity personalPfm;

 

GroupTransactionEntity의 groupPfm과 personalPfm

- 그룹 가계부(groupPfm)에서 발생한 거래에 대한 데이터베이스이기 때문에, 해당 거래가 속하는 그룹 가계부(groupPfm)를 필드로 두고,

- 해당 거래를 행한 회원의 가계부인 개인 가계부(personalPfm)를 필드로 두었습니다.

@ManyToOne
@JoinColumn(name = "groupPfmSeq")
private GroupPfmEntity groupPfm;

@ManyToOne
@JoinColumn(name = "personalPfmSeq")
private PersonalPfmEntity personalPfm;

 

TransactionDto의 pfmSeq과 personalSeq

- pfmSeq : 해당 거래가 속하는 가계부의 seq

- personalSeq : 해당 거래를 행한 회원 개인 가계부의 seq 

=> PersonalTransactionEntity인 경우에는 pfmSeq와 personalSeq가 동일합니다.

=> GroupTransactionEntity인 경우에는 pfmSeq에는 groupPfmSeq, personalSeq에는 personalPfmSeq가 들어갑니다.

private long pfmSeq;

private long personalSeq;

 

여기에서 다음과 같은 오류가 발생했습니다. 

org.springframework.web.util.NestedServletException: Request processing failed; nested exception is org.modelmapper.ConfigurationException: ModelMapper configuration errors:

ModelMapper의 configurationException이라고 하는데, 연결 전략이 기본 값의 경우 같은 타입을 사용하는 조건을 만족하는 ID 값을 자동으로 매핑해주어 발생하는 문제라고 합니다.

따라서 ModelMapper의 연결 전략을 STRICT로 변경하여, Source, Destination의 property와 완전히 같은 경우에만 매핑될 수 있도록 설정하였습니다.

modelMapper.getConfiguration().setMatchingStrategy(MatchingStrategies.STRICT);

 

출처 : https://dbbymoon.tistory.com/4