티스토리 뷰

반응형

 

Spring 기반 애플리케이션에서 DTO(Data Transfer Object)와 Entity 간 변환은 대부분의 API 요청과 응답에서 필수적으로 수행되는 작업이다. 이 과정에서 비효율적으로 구현되면 코드 중복, 유지보수의 어려움, 때로는 성능 저하까지 초래할 수 있다.

 

이 글에서 다양한 DTO-Entity 변환 전략과 예시 및 장단점을 비교해보려고한다.

MapStruct와 ModelMapper 학습 소스코드는  깃허브에서 확인할 수 있다.

 

프로젝트 구성

프레임워크 : Spring boot 3.3.9
빌드 도구 : grade
Java 버전 : 17
주요 의존성 :
- Spring Data JPA
- Spring Web
- Lombok
- MapStruct
- ModelMapper
- Postgresql 14 (Docker)

 

DTO-Entity 변환이 필요한 이유

  1. 관심사 분리 : Entity 는 데이터베이스 스키마와 밀접하게 연결되어 있고, DTO는 클라이언트와의 인터페이스를 담당한다. 두 계층을 분리함으로써 각 계층의 변경이 다른  계층에 미치는 영향을 최소화 할 수 있다.
  2. 데이터 은닉 : 모든 엔티티 필드가 API를 통해 외부로 노출되는 것은 보안상 큰 문제이다. DTO를 사용하면 필요한 데이터만 선택적으로 노출할 수 있다.
  3. 유연성 : API 버전 관리나 요구사항 변경 시 데이터베이스 스키마와 독립적으로 API 응답 구조를 조정할 수 있어 유연성이 높아진다.
  4. 성능 최적화 : 필요한 데이터만 주고받음으로써 네트워크 트래픽을 줄이고, 특정 상황에서는 불필요한 데이터베이스 조회를 방지할 수 있다.

 

다양한 변환 방법

1. 수동 변환(직접 매핑)

가장 기본적인 방법으로, 직접 피드를 매핑하는 코드를 작성한다.

// 서비스 레이어에서 변환
public UserDto userToDto(User user){
	if (user == null){
    	return null;
    }
    
    UserDto dto = new UserDto();
    dto.setId(user.getId());
    dto.setName(user.getName());
    dto.setEmail(user.getEmail());
    
    // 기타 필요한 필드 매핑
    
    return dto;
}
장점 단점
- 명시적이고 이해하기 쉬움
- 외부 의존성이 없음
- 필요한 필드만 정확히 매핑할 수 있음.
- 복잡한 변환 로직을 유연하게 구현할 수 있음
- 코드 중복 발생
- 필드 추가/변경 시 여러 곳을 수정해야함
- 코드량이 많아짐
- 오타나 실수로 인한 오류 가능싱이 높음

 

이 방식은 DTO와 Entity 간의 구조가 간단하고, 완전한 커스텀 매핑이 필요한 특수항 상황에서 유용하다.

 

2. 정적 변환 메서드

public class UserDto{
	// 필드 및 기본 메서드 ...
    
    // Entity -> DTO 변환
    public static UserDto from(User user) {
    	if(user == null){
        	return null;
        }
        
        return UserDto.builder()
        		.id(user.getId())
                .name(user.getName())
                .email(user.getEmail())
                .build();
    }
    
    // DTO -> Entity 변환
    public User toEntity(){
    	return User.builder()
                .name(this.name)
                .email(this.email)
                .build();
    }
}

 

장점 단점
- 변환 로직이 관현 클래스와 함께 위치하여 응집도가 높음
- 재사용 가능하고 일관된 변환을 보장
- 빌더 패턴과 결합하여 가독성을 높일 수 있음
- 외부 의존성 없이 구현 가능
- 여전히 필드 추가/변경 시 수동 업데이트가 필요
- DTO와 Entity 간 강한 결합이 발생할 수 있음
- 복잡한 변호나 로직에는 제한적
- 양방향 매핑 시 순환 참조 문제가 발생

 

이 방식은 도메인 주도 설계(DDD) 접근 방식과 잘 어울린다.

코드의 응집도를 높이고 관련 로직을 한 곳에 모을 수 있다는 장점이 있다.

 

3. MapStruct 활용

MapStruct는 컴파일 시점에 변환 코드를 자동 생성해주는 라이브러리다.

@Mapper(componentModel = "spring")
public interface UserMapper{
	UserDto toDto(User user);
    User toEntity(UserDto dto);
    
    // 특정 필드만 업데이트
    @Mapping(target ="id", ignore = true)
    void updateUserFromDto(UserDto dto, @MappingTarget User user);
}

 

장점 단점
- 컴파일 타임에 코드 생성으로 런타임 성능이 우수
- 타입 안정성 보장
- 필드 이름 불일치 처리가 용이
- 중첩 객체 자동 매핑을 지원
- IDE 지원과 에러 검출이 용이
- 반복 작업을 최소화
- 추가 의존성 필요
- 복잡한 매핑에 대한 러닝 커브 존재
- Lombok과 함께 사용시 설정이 복잡
- 커스텀 로직 구현은 제한적

 

MapStruct 는 타입 안정성과 성능을 중시한다면 적용해볼 법한 라이브러리이다.

컴파일 시점에 코드를 생성하므로 리플렉션 기반 라이브러리 보다 성능이 뛰어나다.

 

4. ModelMapper

리플렉션 기반의 라이브러리로, 런타임에 객체 간 매핑을 수행한다.

// ModelMapper 사용
ModelMapper modelMapper = new ModelMapper();
UserDto userDto = modelMapper.map(user, UserDto.Class);

 

장점 단점
- 사용이 매우 간단
- 코드량이 크게 감소
- 설정을 통한 유연한 매핑 가능
- 별도의 매퍼 클래스 관리가 필요하지 않음
- 리플렉션 사용으로 성능이 저하
- 타입 안정성이 부족
- 디버깅이 어려움
- 복잡한 매핑에 제한적
- 런타임에 에러발생 가능성 높음

 

ModelMapper는 빠른 개발과 간결한 코드를 우선시하는 경우에 적합하다.

또한 레거시 코드를 빠르게 리팩토링할 때 유용하게 사용할 수 있다.

 

복합 객체 매핑의 효율적 처리

실제 애플리케이션에서는 단순한 객체뿐만 아니라 중첩된 객체나 컬렉션을 포함하는 복합객체를 다루는 경우가 많다. 이러한 복잡한 상황에서는 각 변환 방식마다 장단점이 더욱 두드러진다.

 

중첩 객체 처리

// MapStructfh 중첩 객체 처리
@Mapper(componentModel = "spring")
public interface OrderMapper{
	@Mapping(source = "custom.name", target = "customerName")
    @Mapping(source = "items", target = "orderItems")
    OrderDto toDto(Order order);
    
    // 중첩 매퍼 사용
    List<OrderItemDto) mapOrderItems(List<OrderItem> items);

}

 

MapStruct는 중첩 객체와 컬렉션 매핑을 자동으로 처리해주어 복잡한 객체 구조에서도 깔끔한 코드를 유지할 수 있다.

반면, 수동 매핑이나 ModelMapper를 사용할 경우 중첩 구조가 복잡해질수록 코드가 복잡해지거나 성능 문제가 발생할 수 있다.

 

더 나은 DTO-Entity 변환을 위한 패턴과 전략

1. 단방향 매핑 활용

많은 경우, 양방향 매핑이 아닌 단방향 매핑만으로 충분하다. 특히 읽기 전용 API 나 명령과 쿼리를 분리하는 CQRS 패턴에서는 단방향 매핑이 더 적합할 수 있다.

// 읽기 전용 DTO
public record UserSummaryDto(Long id, String name){
	public static UserSummaryDto from(User user){
    	return new UserSummaryDto(user.getId(), user.getName());
    }
}

 

2. 역할 기반 DTO 설계

하나의 Entity에 대해 여러 용도의 DTO를 만들어 상황에 맞게 사용하면 각 API 의 요구사항에 더 적합한 데이터 구조를 제공할 수 있다.

// 목록 조회용 (최소 정보)
public record UserListDto(Long id, String name){}

// 상세 조회용 (모든 정보)
public record UserDetailDto(Long id, String name, String email, Address address, List<OrderDto> orders){}

// 생성용 (필수 정보)
public record CreateUserDto(String name, String email, String password){}

 

 

3. 비지니스 로직과 매핑 분리

매핑 로직과 비지니스 로직을 명확히 분리하면 코드의 책임이 명확해지고 테스트가 용이해진다.

@Service
public class UserService {

	private final UserRepository userRepository;
    private final UserMapper userMapper; // 매핑 담당
    private final PasswordEncoder passwordEncoder; // 비지니스 로직 관련
    
    public UserDto createUser(CreateUserDto dto){
    	// 비지니스 로직
        String encodedPassword = passwordEncoded.encode(get.getPassword());
        
        // 매핑
        User user = userMapper.toEntity(dto);
        user.setPassword(encodedPassword);
        
        // 저장 및 결과 반환
        User savedUser = userRepository.save(user);
        return userMapper.toDto(savedUser);
    }
}

 

4. 검증 로직 통합

DTO에 검증 로직을 포함시켜 데이터 무결성을 보장하고, 검증 책임을 명확히 할 수 있다.

public class UserRequestDto {
	
    @NotBlank(message = "이름은 필수 입니다")
    private String name; 
    
    @Email(message = "유효한 이메일 형식이어야 합니다")
    private String email;
    
    // 커스텀 검증 로직
    public void validateBusinessRules(){
    	if (name != null && name.length() > 50){
        	throw new IllegalArgumentException("이름은 50자를 초과할 수 없습니다");
        }
    }
}

 

프로젝트 규모별 추천 전략

추천 방식 이유 주의사항
정적 변환 메서드 또는 ModelMapper - 빠른 개발 속도와 간결한 코드
- 외부 의존성 최소화
- 설정 복잡성 감소
- 프로젝트 성장 시 리팩토링 계획 수립 필요
- 중요한 성능 병목 구간에 대한 모니터링
MapStruct 또는 정적 변환 메서드 + MapStruct 혼합 - 타입 안정성 확보
- 컴파일 타입 검증으로 오류 조기 발견
- 성능과 유지보수성 간 Balance
- 일관된 매핑 전략 문서화
- 복합 객체 매핑에 대한 패턴 확립
MapStruct + 커스텀 매핑 전략 - 엄격한 타입 안정성
- 확장성과 유지보수성
- 고성능 요구사항 충족
- 명확한 매핑 가이드라인 수립
- DTO 버전 관리 전략 수립
- 팀 교육 및 코드 리뷰 강화

 

 

결론

DTO와 Entity 간의 변환은 단순해 보이지만, 애플리케이션의 복잡도와 규모가 커질수록 신경써야할 부분이 많아진다.

개인적인 의견은 MapStruct 와 정적 변환 메서드를 활용하는게 더 나은 방법인것 같다. 이유는 2가지다

- 각 엔티티별로 Mapper 인터페이스에서 변환 관련 로직을 관리
- 컴파일 시점에 에러를 통한 런타임 에러 예방

 

특수 케이스에 대해서는 정적 변환 메서드를 통해 처리하고, 공통적으로 사용되는 로직에서는 Mapstruct를 사용하는게 최선의 방법이지 않을까 싶다.

 

이상 전달 끝!

 

 

 

반응형
반응형
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/04   »
1 2 3 4 5
6 7 8 9 10 11 12
13 14 15 16 17 18 19
20 21 22 23 24 25 26
27 28 29 30
글 보관함