
MapStruct란 자바 빈 유형 간의 매핑 구현을 단순화 하는 코드 생성기입니다. 주로 비즈니스 로직 상 DTO와 엔티티간의 변환이 자주 필요하고, 매핑의 책임을 분산해주기 위해서 주로 사용합니다. 제가 진행하고 있는 프로젝트에서도 마찬가지로 필드가 많고 변환도 많았기 때문에 이를 적용하게 되었습니다.
Object Mapping을 위한 라이브러리는 Model Mapper라고 한 가지 더 존재합니다. 다만 Model Mapper는 리플렉션을 사용하며, 컴파일 시에 미리 코드를 만들어두는 MapStruct와는 다르게 느립니다. 따라서 최근에는 Model Mapper를 조금 더 많이 사용하는 추세입니다.
먼저 Dependancy와 관련된 설정은 아래와 같습니다. 이때 Lombok이 무조건 MapStruct와 관련된 설정보다 위에 선언해야 합니다. 이는 Mapstruct 내부에서 Lombok에 관련된 설정을 해주기 때문입니다. 하지만 아래와 같이 lombok-mapstruct-binding 종속성을 추가해준다면 알아서 두 라이브러리 간의 바인딩을 시켜주기 때문에 따로 신경쓸 필요가 없습니다.
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
생각할 수 있는 가장 간단한 예시는 단순 primitive 타입으로만 구성된 경우일 겁니다. 이 경우에는 인터페이스만 만들어준다면 알아서 매핑이 되기 때문에 가장 생각할 거리가 줄어듭니다.
public class UserDto {
private Long id;
private String username;
private String email;
private String firstName;
private String lastName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
위처럼 흔해보이는 DTO를 아래 Entity로 변환한다고 해봅시다.
@Entity
public class UserEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String username;
private String email;
private String firstName;
private String lastName;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
}
@Mapper
public interface UserMapper {
UserMapper INSTANCE = Mappers.getMapper(UserMapper.class);
// UserDto -> UserEntity 매핑
UserEntity toUserEntity(UserDto userDto);
// UserEntity -> UserDto 매핑
UserDto toUserDto(UserEntity userEntity);
}
매퍼를 적용시켜주기 위해서 인터페이스에 @Mapper라고 명시해줄 필요가 있습니다. 만약 혹시나 매퍼를 빈으로 등록하여 재사용 하고 싶다면 @Mapper(componentModel = "spring")와 같이 두어도 좋습니다.
인터페이스에는 Mapper Bean을 만들어 사용하기 위해 INSTANCE를 생성해야 합니다. 내부 메서드에는 Mapper를 적용할 Source 객체를 파라미터로, 반환되는 타입에 Target 객체를 둡니다. 이렇게만 해두면 알아서 구현체가 생성되어 매핑을 진행해 줄 수 있습니다.
생성자가 여러 개라 어떤 것을 선택할 지 모르는 경우를 조심하자.
변환하고자 하는 대상 엔티티에 만약 생성자가 여러 개인 경우 Ambiguouse contructor founds 에러가 발생할 수 있습니다. 당연히 구현체로 변경하려고 하는데 생성자가 여러 개라면 어떤 것을 선택할 지 어려울 수 있습니다.
따라서 저는 @Default어노테이션을 따로 만들어 기본으로 사용할 생성자임을 직접적으로 명시해 주었습니다.
@Target(ElementType.CONSTRUCTOR)
@Retention(RetentionPolicy.CLASS)
public @interface Default {
}
코드는 빌드할 때 생성된다.
MapStruct로 인해 만들어진 구현체 코드는 런타임 시에 만들어지는 것이 아닙니다. 컴파일 시에 만들어지며 미리 빌드된 파일 혹은 인텔리제이 캐시로 인해 변경 사항이 저장되지 않은 경우가 존재합니다. 만약 코드는 진짜 틀린 것이 없는데 왜 이딴 에러가 나는 거지 하는 경우에는 다음을 고려해보는 것이 좋습니다.
gradle clean, gradle build를 적용하자.file -> invalidate caches를 통해 캐시를 지워보자.out 파일을 지워보고 다시 gradle build를 해보자.그냥 Record로 만들자.
사실 Record로 만들면, 귀찮은 어노테이션을 작성할 필요도 없고 간단하게 DTO를 생성할 수 있습니다.
위의 엔티티는 매우 간단한 케이스였습니다. 실제로는 일대다 관계 등의 리스트 같은 것을 포함하는 엔티티도 많습니다. 저의 경우 비즈니스 로직 상 한 번에 OneToMany 연관관계를 맺고 있는 값들이 전부 업데이트 되어야만 했습니다. 이를 위해서는 Request DTO도 static inner class로 설정해 주어야 합니다.
@Getter
@NoArgsConstructor
public class RecruitmentRequest {
private List<RecruitmentJobRequest> recruitmentJobRequests;
private List<RecruitmentWorkTypeRequest> recruitmentWorkTypeRequests;
private List<RecruitmentWorkdayRequest> recruitmentWorkdayRequests;
private RecruitmentCondition recruitmentCondition;
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class RecruitmentJobRequest {
...
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class RecruitmentWorkTypeRequest {
...
}
@Getter
@NoArgsConstructor
@AllArgsConstructor
public static class RecruitmentWorkdayRequest {
...
}
}
Recruitment 엔티티는 RecruitmentJob, RecruitmentWorkType, RecruitmentWorkday와 일대다 관계를 가지고 있다고 가정해보겠습니다. 여기서 Recruitment는 부모 엔티티이며, 나머지 엔티티들은 자식 엔티티입니다.
@Entity
@Table(name = "RECRUITMENT")
public class Recruitment {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long recruitmentId;
@OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecruitmentJob> recruitmentJobs;
@OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecruitmentWorkType> recruitmentWorkTypes;
@OneToMany(mappedBy = "recruitment", cascade = CascadeType.ALL, orphanRemoval = true)
private List<RecruitmentWorkday> recruitmentWorkdays;
// 다른 필드와 메서드들...
}
primitive 타입의 프로퍼티를 매핑할 때는 단순히 메서드 명만 작성해주면 되었지만, 일대다 관계의 컬렉션을 처리할 때는 관계가 잘 연관될 수 있도록 해주어야 합니다.
@Mapper
public interface RecruitmentMapper {
RecruitmentMapper INSTANCE = Mappers.getMapper(RecruitmentMapper.class);
@Mapping(target = "recruitmentJobs", source = "recruitmentJobRequests")
@Mapping(target = "recruitmentWorkTypes", source = "recruitmentWorkTypeRequests")
@Mapping(target = "recruitmentWorkdays", source = "recruitmentWorkdayRequests")
Recruitment toRecruitment(RecruitmentRegisterRequest recruitmentRegisterRequest);
List<RecruitmentJob> toRecruitmentJobList(List<RecruitmentJobRequest> recruitmentJobRequests);
List<RecruitmentWorkType> toRecruitmentWorkTypeList(List<RecruitmentWorkTypeRequest> recruitmentWorkTypeRequests);
List<RecruitmentWorkday> toRecruitmentWorkdayList(List<RecruitmentWorkdayRequest> recruitmentWorkdayRequests);
@Mapping(target = "recruitmentJobId", source = "recruitmentJobId")
@Mapping(target = "jobGroup", source = "jobGroup")
@Mapping(target = "job", source = "job")
RecruitmentJob toRecruitmentJob(RecruitmentJobRequest recruitmentJobRequest);
@Mapping(target = "recruitmentWorkTypeId", source = "recruitmentWorkTypeId")
@Mapping(target = "recruitmentWorkTypeStatus", source = "workType")
RecruitmentWorkType toRecruitmentWorkType(RecruitmentWorkTypeRequest recruitmentWorkTypeRequest);
@Mapping(target = "recruitmentWorkdayId", source = "recruitmentWorkdayId")
@Mapping(target = "recruitmentWorkdayStatus", source = "workday")
RecruitmentWorkday toRecruitmentWorkday(RecruitmentWorkdayRequest recruitmentWorkdayRequest);
}
위와 같이 @Mapping 어노테이션을 통해서 어떤 Source(DTO)로부터, 어떤 Target(Entity)으로 변할 것인지 구체적으로 명시해 주어야 합니다. 만약 저와 같이 static inner class로 DTO를 만들었다면, 작성하는 순서는 다음과 같습니다.
편의를 위해 DTO 안의 내부 static inner class를 자식 DTO 라고 하고, OneToMany 관계를 가지고 있는 엔티티를 자식 엔티티라고 하겠습니다.
1. 부모 DTO에서 부모 엔티티로의 변환 시켜주는 메서드를 작성합니다.
@Mapping(target = "recruitmentJobs", source = "recruitmentJobRequests")
@Mapping(target = "recruitmentWorkTypes", source = "recruitmentWorkTypeRequests")
@Mapping(target = "recruitmentWorkdays", source = "recruitmentWorkdayRequests")
Recruitment toRecruitment(RecruitmentRegisterRequest recruitmentRegisterRequest);
@Mapping은 각 DTO와 엔티티의 프로퍼티를 매핑해주는 역할을 합니다.이것만 있으면 해결될 것 같지만 실제로는 source와 target에 들어가는 프로퍼티가 리스트이기 때문에 제대로 변환되지 않습니다.
2. 자식 DTO 리스트를 각각의 자식 엔티티 리스트로 매핑 시켜주어야 합니다.
List<자식 DTO>를 List<자식 엔티티>로 변환시켜주는 메서드를 작성해줍니다.
List<RecruitmentJob> toRecruitmentJobList(List<RecruitmentJobRequest> recruitmentJobRequests);
List<RecruitmentWorkType> toRecruitmentWorkTypeList(List<RecruitmentWorkTypeRequest> recruitmentWorkTypeRequests);
List<RecruitmentWorkday> toRecruitmentWorkdayList(List<RecruitmentWorkdayRequest> recruitmentWorkdayRequests);
리스트 간의 변환을 위해서는 내부 각각의 자식 DTO와 자식 Entity간의 변환 과정도 필요합니다.
3. 자식 DTO를 자식 Entity로 변환합니다.
@Mapping(target = "recruitmentJobId", source = "recruitmentJobId")
@Mapping(target = "jobGroup", source = "jobGroup")
@Mapping(target = "job", source = "job")
RecruitmentJob toRecruitmentJob(RecruitmentJobRequest recruitmentJobRequest);
@Mapping(target = "recruitmentWorkTypeId", source = "recruitmentWorkTypeId")
@Mapping(target = "recruitmentWorkTypeStatus", source = "workType")
RecruitmentWorkType toRecruitmentWorkType(RecruitmentWorkTypeRequest recruitmentWorkTypeRequest);
@Mapping(target = "recruitmentWorkdayId", source = "recruitmentWorkdayId")
@Mapping(target = "recruitmentWorkdayStatus", source = "workday")
RecruitmentWorkday toRecruitmentWorkday(RecruitmentWorkdayRequest recruitmentWorkdayRequest);
각각의 프로퍼티를 직접 매핑하여 이어줍니다.
이것만으로 끝일까요? 아닙니다. 위의 Entity구조를 보면 연관관계의 주인이 부모 Entity가 아님을 확인할 수 있습니다.
대부분의 경우 다대일 관계를 사용하기 때문에 연관관계의 주인이 다 쪽에 위치합니다. 그럴 경우 아무리 부모 엔티티 쪽에서 값을 매핑하여 설정해주어도 실제로 FK가 위치한 쪽에서 저장해 주지 않았기 때문에 FK에는 null값이 들어가게 됩니다. 물론 일대다 관계라면 달라지긴 하겠죠.
따라서 필드와 컬렉션을 매핑한 이후에 부모와 자식 엔티티 간의 연관관계를 설정해 줄 필요가 있습니다. 즉, 다대일 관계라면 다 쪽에서 연관관계를 맺어주어야 한다는 말입니다.
4. 매핑 후 관계 설정
@AfterMapping
default void setRecruitment(@MappingTarget Recruitment recruitment) {
if (recruitment.getRecruitmentJobs() != null) {
recruitment.getRecruitmentJobs().forEach(job -> job.setRecruitment(recruitment));
}
if (recruitment.getRecruitmentWorkTypes() != null) {
recruitment.getRecruitmentWorkTypes().forEach(workType -> workType.setRecruitment(recruitment));
}
if (recruitment.getRecruitmentWorkdays() != null) {
recruitment.getRecruitmentWorkdays().forEach(workday -> workday.setRecruitment(recruitment));
}
}
@AfterMapping을 통해 매핑 후에 실행될 메서드를 설정해주었습니다. @MappingTarget을 통해 타겟이 되는 엔티티 자체를 불러온 후, 객체 참조로 연관관계 매핑을 직접 처리해주었습니다.
더 간단하게 할 방법은 없을까?
코드가 살짝 난잡해 진 이유는 매핑 후 관계 설정 때문입니다. 해당 과정을 반드시 거쳐야 하는 이유는 연관관계의 주인이 "다" 쪽에 위치했기 때문입니다. 따라서 "다" 쪽에 위치한 엔티티를 설정해주지 않는 다면 FK는 null로 들어올 것입니다.
만약, 위와 같은 상황이라면 "다대일"관계가 아니라 "일대다"관계로 변경해야 하지 않는지 고민해봐야 합니다. 저는 아래와 같은 기준으로 관계를 바꿔주었습니다.
"일대다" 관계로 바꾸게 된다면, 앞선 4의 과정은 필요없어집니다. "일" 쪽 엔티티를 저장하는 순간, "다"쪽도 알아서 설정되기 때문이죠.
...
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "recruitment_job")
private List<RecruitmentJob> recruitmentJobs;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "recruitment_work_type")
private List<RecruitmentWorkType> recruitmentWorkTypes;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "recruitment_work_day")
private List<RecruitmentWorkday> recruitmentWorkdays;
@OneToMany(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "recruitment_skill")
private List<RecruitmentSkill> recruitmentSkills;
...
빌드를 하다보면 매핑하는데 있어 경고가 뜰 때가 있습니다. 이는 DTO, Entity간의 매핑이 이루어지지 않는 필드가 있기 때문입니다.
@Mapper(unmmapedTargetPolicy = ReportingPolicy.{ERROR,WARN,IGNORE})
만약 이렇게 매핑이 이루어지지 않는 경우에 에러를 내고 싶다면, ERROR를 선택하면 되고, 경고를 내고 싶다면 WARN, 아얘 무시하고 싶다면 IGNORE를 선택하면 됩니다.
그 외에도 null관련된 정책도 설정할 수 있는데, 자세한 정보는 공식 가이드에 있습니다.
사실 이 외에도 Mapstruct에는 꿀 기능들이 정말 많습니다. 아래 예시를 통해서 한번 살펴봅시다.
@Mapping(source = "recruitmentPeriod.endDate", target = "endDate")
@Mapping(source = "recruitmentAddress.workAddress", target = "address")
@Mapping(source = "recruitmentCondition.maxSalary", target = "maxSalary")
@Mapping(expression = "java(recruitment.getLogoImage())", target = "logoImage")
RecruitmentScrapResponse toRecruitmentScrapResponse(Recruitment recruitment);
특정 DTO로 변환을 수행하는 Mapping 인터페이스입니다.
.이 중간 중간 섞여 있는 것을 확인 할 수 있는데, 이는 VO 객체에 대한 접근을 말합니다. 뭐 말만 VO 객체이지, 인자로 들어온 recruitment에 있는 모든 멤버 변수에 접근이 가능합니다.
expression = "java(...)"는 특정 자바 함수를 실행할 수 있는 기능입니다. 위에서 표기된 logoImage는 인자로 들어온 recruitment의 멤버 변수가 아니라, 다른 함수(메시지)를 통해서 얻어내는 것이 가능한 요소입니다. 위와 같이 간단하게 expression을 통해서 가져올 수 있습니다.
만약, 임의로 값을 설정해 두고 싶으면 어떻게 할까요?
아래와 같이 contant를 사용하면 됩니다.
@Mapping(target = "viewNumber", constant = "0L")
"DTO의 변환은 어디에서 수행해주어야 하는 가?" 는 많은 논쟁거리가 있습니다. 물론 정답이 있지는 않지만, 여러 가지를 찾아보며 저 나름의 답을 찾았고, 이 과정에서 Mapper의 중요성을 깨달았습니다. 우선 어떤 방법이 있는 지 부터 알아봅시다.
Controller에서 변환작업이 이루어지면, Controller 계층의 복잡성과 Domain 자체를 포함하게 되어 의존성에 위배된다는 단점이 있습니다. 다만, Service 계층에서는 Entity에만 의존하여 재사용성은 높아집니다.
Service 계층에 DTO로 들어가고 DTO로 나오는 구조입니다. Service 계층이 특정 DTO에 의존하기 때문에 재사용성이 낮아지며 비대한 Service가 만들어진다는 단점이 존재합니다. 하지만, Controller 계층에서 비즈니스 로직을 포함하지 않고, DTO만 알기 때문에 도메인을 보호할 수 있습니다. 저는 그나마 Controller 계층에서의 변환보단, Service 계층에서의 변환을 선호하는 편입니다.
일반적으로 요청이 들어오면, @RequestBody를 통해서 RequestDTO를 받을 것입니다. 중요한 것은 RequestDTO는 Http Request에 대한 종속성을 지니고 있다는 것입니다. 이로인해 만약 RequestDTO가 Service단으로 전달된다면 Service 계층에서 Controller 계층을 의존하는 상황이 발생해 버립니다. 즉 단방향 의존성을 지녀야 하는 레이어드 아키텍쳐 설계에 위반합니다.
Command 객체는 요청을 캡슐화하여 Service 단으로 전달해주는 일종의 DTO라고 생각하면 됩니다. 다만, 차이점은 Domain DTO라는 것입니다. Domain DTO이기 때문에 Http Request에 의존적이지 않습니다. 따라서 아키텍처 설계를 지킬 수 있으며 특정 DTO에 의존적인 문제도 해결 가능합니다.
또한 Command 객체로 캡슐화함으로써, 내부에 어떤 값이 들어있는 지 Service 계층은 상관 쓰지 않고 도메인으로 변환이 가능합니다. Service 계층이 훨씬 보기 좋아지는 것입니다.
이렇듯, Domain DTO(Command 객체)와 Presentaion 측의 DTO간의 변환은 많은 이점을 주지만 가장 귀찮은 점이 있습니다. 바로 변환하는 코드가 굉장히 많다는 것입니다. Domain DTO이든, Presentation DTO이든, 엄밀히 말해서는 내부 데이터가 일치할 확률이 높습니다. 이러한 코드를 두 번이나 작성하는 것은 중복을 싫어하는 개발자들에게 있어서는 매우 번거로울 일일 것입니다.
여기서 MapStruct를 사용하여, Domain DTO(Command 객체)와 Presentation DTO간의 변환을 수행해주는 역할을 부여하면 어떨까요? 심지어는 MapStruct는 필드 이름이 동일한 것에 대해서는 @Mapping을 생략해도 좋습니다. 즉, 도메인 DTO와 Presentation DTO간의 변환을 책임지는 Mapper 계층을 중간에 둠으로써 캡슐화, 의존성 위배의 상황을 방지할 수 있다는 것입니다.
물론, 좀.. 번거롭기는 합니다.. 그래서 저는 다음과 같은 기준을 세웠습니다.
MapStruct는 참 편리한 도구입니다. 개발자의 귀찮음을 줄여줄 수 있음이 분명하죠. 앞서 설명하지는 않았지만 매핑 시 커스텀으로 타입을 변환한다던가, 특정 프로퍼티는 무시한다던가 등 다양한 기능이 존재합니다. 앞으로도 자주 사용할 좋은 기능이라는 생각이 듭니다.
https://mapstruct.org/documentation/stable/reference/html/#Preface