
문제 상황
나는 DTO(Data Transfer Object)를 활용하여 레이어 간의 데이터를 옮기고 있었다.
도메인 객체 자체를 그대로 나르게 된다면 필요 없는 정보나 숨기고 싶은 정보까지 과도하게 노출될 수 있다. 그래서 DTO에 딱 원하는 정보만 담아서 옮기는 방식을 택했다.
데이터의 흐름은 보통 이렇다.
클라이언트의 요청 -> Request -> 컨트롤러 -> (Request를 DTO로 변환) -> 서비스 -> (DTO를 바탕으로 객체 저장/조회 등 수행)
여기서 '변환하는 작업'을 어디서, 어떻게 할지에 대한 고민이 시작되었다.
1. DTO 자체에 변환 로직 넣기?
DTO 안에 toEntity()나 from() 같은 메서드를 만드는 방식이다. 많이들 쓰지만 나는 이게 썩 맘에 들지 않았다. DTO는 이름 그대로 데이터를 옮기는 객체인데, 여기에 로직이 들어가면 객체의 역할과 책임이 애매모호해진다고 느꼈다.
2. 서비스 레이어에서 변환?
그렇다고 서비스 레이어에 넣자니, DTO가 추가될 때마다 변환하는 메서드를 하나씩 서비스 코드에 추가해야 한다. 비즈니스 로직만으로도 바쁜 서비스 코드가 지저분해질 것 같았다.
3. 수동 변환 (Boilerplate)
무엇보다 가장 큰 문제는 보일러 플레이트다. 필드가 3개면 할 만한데, ide가 도와주긴 하지만 10개, 20개가 넘어가면 이걸 일일이 set, get 하는 코드를 짤순 없었다.
public record HostResponse(
String imgUrl,
String hostName,
Integer maxPeople,
String hostManagerName,
String hostPhoneNumber,
Double latitude,
Double longitude,
String keyword,
String description,
LocalDateTime startTime,
LocalDateTime endTime) {
public static HostResponse from(Host host) {
return new HostResponse(
host.getImgUrl(),
host.getHostName(),
host.getMaxPeople(),
host.getHostManagerName(),
host.getHostPhoneNumber(),
host.getLatitude(),
host.getLongitude(),
host.getKeyword(),
host.getDescription(),
host.getStartTime(),
host.getEndTime()
);
}
}
그래서 Mapper 라이브러리를 도입하기로 했고, 대표적인 두 가지(ModelMapper, MapStruct)를 비교해 보았다.
ModelMapper
ModelMapper.map(source, destination.class) 처럼 한 줄이면 끝나서 정말 편하다.
ModelMapper.map(Object source, Class<D> destinationType)
하지만 치명적인 단점이 있었다.
바로 리플렉션(Reflection)을 사용한다는 점이다. 컴파일 시점이 아니라 프로그램이 돌아가는 런타임에 "얘는 필드가 뭐가 있지?" 하고 하나하나 분석해서 매핑한다.
당연히 성능 저하가 발생할 수밖에 없다. 편하긴 하지만, 성능을 갉아먹는 방식이라 최근에는 많이 안 쓰는 추세다.
그래서 난 MapStruct를 사용하기로 하였다.
MapStruct
MapStruct는 Mapper로 끝나는 인터페이스를 만들어서 메서드를 선언만 해주면 컴파일시점에 MapperImpl이라는 클래스를 만들어주는 라이브러리이다.
또한 Lombok과 호환되기때문에 매우 간단하게 설정 가능하다. 물론 객체의 정보를 가져와야 하기 때문에 변환하고자 하는 객체에 @Getter와 @Setter 혹은 @Data나 @Bulider 같은 어노테이션을 붙여주면 되고 붙인 어노테이션에 따라 알아서 생성된다.
@Mapper(componentModel = "spring")
public interface CourseMapper {
@Mapping(target = "like", constant = "0") // 또는 다른 기본값
DetailCourseResponseDto courseDetailToDto(CourseDetail courseDetail);
List<DetailCourseResponseDto> courseToDto(List<CourseDetail> courseDetail);
}
하나씩 보면 Mapper로 끝나는 인터페이스를 생성한다.
그다음 @Mapper 어노테이션을 붙여주고 메서드를 선언해주기만 하면 된다.
만약 변환하는 객체간의 필드가 일치하지 않거나 기본값을 설정해야 할 경우 @Mapping 어노테이션을 사용하여 ignore 옵션을 추가하던가 constant를 추가하는 식으로 target을 정해서 하면 된다.
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2025-04-30T17:09:49+0900",
comments = "version: 1.6.3, compiler: IncrementalProcessingEnvironment from gradle-language-java-8.12.1.jar, environment: Java 17.0.11 (Oracle Corporation)"
)
@Component
public class CourseMapperImpl implements CourseMapper {
@Override
public DetailCourseResponseDto courseDetailToDto(CourseDetail courseDetail) {
if ( courseDetail == null ) {
return null;
}
String name = null;
String description = null;
String imgPath = null;
name = courseDetail.getName();
description = courseDetail.getDescription();
imgPath = courseDetail.getImgPath();
Integer like = 0;
DetailCourseResponseDto detailCourseResponseDto = new DetailCourseResponseDto( name, description, imgPath, like );
return detailCourseResponseDto;
}
@Override
public List<DetailCourseResponseDto> courseToDto(List<CourseDetail> courseDetail) {
if ( courseDetail == null ) {
return null;
}
List<DetailCourseResponseDto> list = new ArrayList<DetailCourseResponseDto>( courseDetail.size() );
for ( CourseDetail courseDetail1 : courseDetail ) {
list.add( courseDetailToDto( courseDetail1 ) );
}
return list;
}
}
빌드를 하면 build.generated.source.annotationProcessor 폴더 밑에 생성된 Impl클래스의 모습이다.
주의점
두 라이브러리 모두 자바의 컴파일러 일부인 Annotation Processor를 사용하는데, Lombok은 컴파일 시점에 AST(Abstract Syntax Tree)를 수정하여 Getter/Setter를 생성하고 MapStruct는 이 Getter/Setter를 읽어서 매핑코드를 생성한다. 그렇기에 Lombok이 먼저 AST를 수정해놓지 않으면, MapStruct는 Getter가 없다고 판단하고 매핑 코드를 만들지 못한다.
그렇기에 Lombok 하고 같이 사용이 되는 만큼 bulid.gradle 파일에서 종속성을 추가할 때 항상 Lombok 밑에다가 추가해야 한다!
마치면서
사실 처음에는 HostResponse.from(Host host) 같은 정적 팩토리 메서드(Static Factory Method) 패턴을 사용할까도 심각하게 고민했다. "객체는 자신의 데이터를 가장 잘 안다"는 객체지향적 관점에서 본다면, DTO가 스스로 엔티티를 받아 변환하는 로직을 갖는 것이 응집도(Cohesion) 면에서 더 자연스러워 보일 수 있기 때문이다.
하지만 이 방식에는 간과하기 쉬운 맹점이 있다. 바로 DTO가 도메인 엔티티를 직접 의존하게 된다는 점이다. 만약 도메인 엔티티의 필드명이 바뀌거나 구조가 변경되면, 순수하게 데이터를 나르는 역할만 해야 할 DTO의 코드까지 뜯어고쳐야 한다. 이는 두 객체 간의 결합도(Coupling)가 불필요하게 높아짐을 의미한다.
나는 이 시기에 DTO가 엔티티의 변화에 휘둘리지 않고, 오직 '데이터 전달'이라는 본연의 책임에만 집중하길 원했다. MapStruct를 도입했었던 결정적인 이유가 바로 여기에 있다. 변환 로직을 DTO나 엔티티가 아닌 제3의 객체(Mapper)에게 위임함으로써 엔티티와 DTO 사이의 결합을 끊어내고, 도메인 변경의 여파를 최소화하여 선언적으로 관리하고 싶었기 때문이다.
MapStruct는 정말 편하다. 보일러 플레이트 코드를 획기적으로 줄여주고, 무엇보다 컴파일 타임에 오류를 잡아주니 휴먼 에러도 줄어든다.
자동 생성이라 믿음직스럽긴 하지만, 매핑 로직이 복잡해지면 의도한 대로 동작하지 않을 수도 있다. 다행히 MapStruct는 Mappers.getMapper()를 통해 스프링 없이도 쉽게 단위 테스트가 가능하다.
앞으로는 복잡한 매핑이 들어갈 땐 테스트 코드도 꼼꼼히 챙기면서, 공식 문서를 통해 좀 더 고급 기능(매핑 전략 등)을 익혀봐야겠다.
'탐구' 카테고리의 다른 글
| Copy on Write로 알아본 계층을 관통하는 철학 (1) | 2025.12.26 |
|---|---|
| 영속성 컨테이너의 플러시 타이밍 문제에 관하여... (0) | 2025.07.01 |
| CORS가 협업을 자꾸 힘들게해요 (0) | 2025.04.12 |
