SOPT에서 합동세미나를 위해 서버 개발을 하면서 비즈니스 로직에 빌더를 호출하여 엔티티를 DTO로 변환하는 과정이 가독성을 떨어뜨리고 번거롭다는 생각이 들었다.
이를 해결할 수 있는 방법으로 MapStruct가 있다는 것을 알게되어 실제 프로젝트에서 활용한 코드를 바탕으로 소개해보고자 한다.
실제로 프로젝트를 진행하며, private로 서비스 코드 내에서 매핑하는 함수를 일일이 만드는 것은 활용도가 떨어지고, 가독성도 떨어진다고 생각했다.
MapStruct는 빈 매퍼 클래스를 자동으로 생성해주며, 어노테이션 기반으로 작성된다는 장점이 있다.
같은 기능의 라이브러리로 modelMapper라는 것도 존재한다고 하는데, 이 라이브러리는 써본적이 없기도하고, 리플렉션이 존재하여 MapStruct가 더 성능이 좋다고한다
1. MapStruct
1.1 MapStruct 소개
- 유형이 안전한 빈 매핑 클래스 생성을 위한 Java Annotation Processor이다
- 필요한 매핑 방법을 선언하는 매퍼 인터페이스를 정의하면 사용할 수 있다
- 컴파일하는 동안 MapStruct가 이 인터페이스의 구현을 생성한다
- 구현은 소스와 대상 객체 간의 매핑을 위해 일반 Java 메서드 호출을 사용한다
- 즉, 리플렉션이 사용되지 않는다
1.2 리플렉션
- 클래스, 필드, 메소드 등의 정보를 동적으로 검사하고 접근하는 것을 의미한다
- 리플렉션을 사용하면 런타임에 클래스 구조를 검사하고 메서드를 호출해야 한다
- 동적으로 객체를 생성하고 메서드를 호출하는 방법으로, 정적 바인딩된 메서드 호출에 비해 오버헤드를 발생시킨다
- 코드를 작성할 시점에는 어떤 타입의 클래스를 사용할지 모르지만, 런타임 시점에 지금 실행되고 있는 클래스를 가져와서 실행해야 하는 경우에 사용한다
- MapStruct는 같은 기능의 라이브러리인 modelMapper와 비교할 때 리플렉션 없이 메서드 호출만 하기 때문에 성능에 영향을 미치지 않는다
2. MapStruct 설정
- build.gradle에 다음과 같은 코드를 추가해준다
// mapper
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
3. MapStruct 기본 어노테이션
3.1 @Mapper
- 매핑 인터페이스를 정의할 때 사용하는 어노테이션
- @Mapper 어노테이션을 사용하여 인터페이스를 정의하면, MapStruct는 해당 인터페이스의 구현체를 생성한다
3.2 @Mapping
- 매핑을 정의할 때 사용하는 어노테이션
- source 객체와 target 객체 간의 필드를 매핑하는 방법을 지정한다
- 필드 이름이 동일한 경우 자동으로 매핑된다
- 복잡한 매핑의 경우 해당(@Mapping) 어노테이션을 사용하여 명시적으로 소스와 대상을 지정해줄 수 있다
4. 프로젝트 예제 코드
4.1 예제 인터페이스 코드
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface ClubMapper {
ClubMapper INSTANCE = Mappers.getMapper(ClubMapper.class);
@Mapping(target = "clubCategoryContent", source = "clubCategory.name")
ClubResponseDTO toClubListResponseDTO(Club club);
List<ClubResponseDTO> toClubListResponseDTOList(List<Club> clubList);
}
- INSTACNE 생성
- MapStruct는 컴파일 타임에 코드를 생성하여 매핑 기능을 제공하므로 이를 위해 인터페이스의 구현체가 필요하다
- 여기서 Mappers.getMapper() 메서드가 구현체를 생성하고 반환하는 역할을 해준다
- 이와 같이 인터페이스의 인스턴스를 생성하지 않으면 컴파일 타임에는 해당 인터페이스의 매핑 기능에 접근할 수 없다
- 인터페이스 자체에는 실행 가능한 코드가 존재하지 않으며, 이에 따라 구현한 클래스가 필요하다
- MapStruct는 컴파일 타임에 코드를 생성하여 매핑 기능을 제공하는데, 인터페이스와 구현 클래스 간의 매핑 규칙을 생성하고, 매핑 코드를 구현 클래스에 추가하는 과정을 의미한다
- 따라서 INSTANCE를 생성하는 이유는 컴파일 타임에 인터페이스의 매핑 기능에 접근할 수 있게 하기 위함이다.
- Mapping(target="clubCategoryContent", source = "clubCategory.name")
- Club에는 ClubCategory 타입의 enum이 clubCategory라는 이름의 필드로 선언되어 있다
- ClubResponseDTO에는 이 enum의 값에서 name 값만 가져와 clubCategoryContent라는 필드로 사용해야 한다
- 이에 따라 다음과 같은 코드를 통해 source와 target을 지정해주었다
4.2 생성된 인터페이스 구현체
- 다음과 같이 build 폴더 하위에서 mapper 패키지를 열어보면 다른 mapper에 대한 구현체들도 확인이 가능하다
- 예제 인터페이스 구현체는 CarrotServer/build/classes/java/main/CarrotServer/mapper/ClubMapperImpl.class 경로에서 확인할 수 있었다
@Component
public class ClubMapperImpl implements ClubMapper {
public ClubMapperImpl() {
}
public ClubResponseDTO toClubListResponseDTO(Club club) {
if (club == null) {
return null;
} else {
String clubCategoryContent = null;
Long clubId = null;
String clubName = null;
String clubImg = null;
int participantCount = false;
String participantsImg = null;
String town = null;
clubCategoryContent = this.clubClubCategoryName(club);
clubId = club.getClubId();
clubName = club.getClubName();
clubImg = club.getClubImg();
int participantCount = club.getParticipantCount();
participantsImg = club.getParticipantsImg();
town = club.getTown();
ClubResponseDTO clubResponseDTO = new ClubResponseDTO(clubId, clubCategoryContent, clubName, clubImg, participantCount, participantsImg, town);
return clubResponseDTO;
}
}
public List<ClubResponseDTO> toClubListResponseDTOList(List<Club> clubList) {
if (clubList == null) {
return null;
} else {
List<ClubResponseDTO> list = new ArrayList(clubList.size());
Iterator var3 = clubList.iterator();
while(var3.hasNext()) {
Club club = (Club)var3.next();
list.add(this.toClubListResponseDTO(club));
}
return list;
}
}
private String clubClubCategoryName(Club club) {
if (club == null) {
return null;
} else {
ClubCategory clubCategory = club.getClubCategory();
if (clubCategory == null) {
return null;
} else {
String name = clubCategory.getName();
return name == null ? null : name;
}
}
}
}
- 내부적으로 toClubListResponseDTOList 함수가 toClubListResponseDTO를 사용하고 있음을 확인할 수 있다
- toClubListResponseDTO에서 clubCategoryContent를 매핑해 주지 않았을 때 다음과 같은 에러가 발생했었는데, 매퍼의 내부적인 동작을 확인하고 왜 toClubResponseDTO를 인터페이스에 추가해주어야 했는지 이해할 수 있었다
/Users/kim-sung-eun/Desktop/project/carrot/Carrot-Server/CarrotServer/src/main/java/CarrotServer/mapper/ClubMapper.java:19: error: Unmapped target property: "clubCategoryContent". Mapping from Collection element "Club club" to "ClubResponseDTO clubResponseDTO".
List<ClubResponseDTO> toClubListResponseDTOList(List<Club> clubList);
- Mapping 어노테이션을 사용하여 target, source를 지정해준 부분인 Mapping(target="clubCategoryContent", source = "clubCategory.name")도 private 접근 제어자를 가진 clubClubCategoryName 함수로 생성된 것을 확인할 수 있다
4.3 인터페이스 내 메서드 정의
- 모임이 아닌 동네 생활과 관련된 엔티티를 응답 DTO로 바꾸는데, 여러 필드를 하나로 합치는 함수가 필요했다
- 코드를 다음과 같이 작성하여 인터페이스 내에서 메서드를 정의할 수 있었다
@Mapper(componentModel = "spring", unmappedTargetPolicy = ReportingPolicy.ERROR)
public interface LifeMapper {
LifeMapper INSTANCE = Mappers.getMapper(LifeMapper.class);
@Mapping(target = "lifeCategoryContent", source = "lifeCategory.name")
@Mapping(target = "contentInformation", expression = "java(buildContentInformation(life.getTown(), life.getCreatedAt(), life.getViewCount()))")
LifeResponseDTO toLifeListResponseDTO(Life life);
default String buildContentInformation(String town, LocalDateTime createdAt, int viewCount) {
String timeDiff = calculateTimeDifference(createdAt);
return town + " · " + timeDiff +" · "+ "조회 " + viewCount;
}
private String calculateTimeDifference(LocalDateTime createdAt) {
LocalDateTime now = LocalDateTime.now();
int yearDiff = now.getYear() - createdAt.getYear();
int monthDiff = now.getMonthValue() - createdAt.getMonthValue();
int dayDiff = now.getDayOfMonth() - createdAt.getDayOfMonth();
int hourDiff = now.getHour() - createdAt.getHour();
int minuteDiff = now.getMinute() - createdAt.getMinute();
if (yearDiff > 0) {
return yearDiff + "년 전";
} else if (monthDiff > 0) {
return monthDiff + "달 전";
} else if (dayDiff > 0) {
return dayDiff + "일 전";
} else if (hourDiff > 0) {
return hourDiff + "시간 전";
} else {
return minuteDiff + "분 전";
}
}
List<LifeResponseDTO> toLifeListResponseDTOList(List<Life> lifeList);
}
- 처음에 buildContentInformation을 calculateTimeDifference와 마찬가지로 private 접근 제어자를 사용했다가 오류가 발생했다
/Users/kim-sung-eun/Desktop/project/carrot/Carrot-Server/CarrotServer/build/generated/sources/annotationProcessor/java/main/CarrotServer/mapper/LifeMapperImpl.java:39: error: cannot find symbol
String contentInformation = buildContentInformation(life.getTown(), life.getCreatedAt(), life.getViewCount());
^
symbol: method buildContentInformation(String,LocalDateTime,int)
location: class LifeMapperImpl
- 다음 게시글을 참고하여 접근 제어자를 default로 바꾸어주니 아래와 같이 인터페이스 구현체가 잘 만들어지는 것을 확인할 수 있었다
@Component
public class LifeMapperImpl implements LifeMapper {
public LifeMapperImpl() {
}
public LifeResponseDTO toLifeListResponseDTO(Life life) {
if (life == null) {
return null;
} else {
String lifeCategoryContent = null;
Long lifeId = null;
String lifeTitle = null;
String lifeContent = null;
int likeCount = false;
int commentCount = false;
lifeCategoryContent = this.lifeLifeCategoryName(life);
lifeId = life.getLifeId();
lifeTitle = life.getLifeTitle();
lifeContent = life.getLifeContent();
int likeCount = life.getLikeCount();
int commentCount = life.getCommentCount();
String contentInformation = this.buildContentInformation(life.getTown(), life.getCreatedAt(), life.getViewCount());
LifeResponseDTO lifeResponseDTO = new LifeResponseDTO(lifeId, lifeCategoryContent, lifeTitle, lifeContent, contentInformation, likeCount, commentCount);
return lifeResponseDTO;
}
}
public List<LifeResponseDTO> toLifeListResponseDTOList(List<Life> lifeList) {
if (lifeList == null) {
return null;
} else {
List<LifeResponseDTO> list = new ArrayList(lifeList.size());
Iterator var3 = lifeList.iterator();
while(var3.hasNext()) {
Life life = (Life)var3.next();
list.add(this.toLifeListResponseDTO(life));
}
return list;
}
}
private String lifeLifeCategoryName(Life life) {
if (life == null) {
return null;
} else {
LifeCategory lifeCategory = life.getLifeCategory();
if (lifeCategory == null) {
return null;
} else {
String name = lifeCategory.getName();
return name == null ? null : name;
}
}
}
}
- default 메서드를 사용하면 기존에 인터페이스를 구현한 클래스가 새로운 메서드에 대한 구현을 제공하지 않아도 되므로 업데이트할 필요가 없다고 한다
- default 메서드가 기본 구현이 없이 인터페이스에 추가되면 해당 인터페이스를 구현하는 모든 클래스가 새로운 메서드에 대한 구현을 해야하는데, 우리는 지금 인터페이스 내에 구현을 이미 해두었기 때문에 해당 인터페이스를 구현하는 구현체에서 이 함수를 그대로 사용하기를 바라고 있다. 때문에 default 접근 제어자를 사용한다고 이해하면 될 것 같다
오늘은 SOPT 합동 세미나에서 사용했던 MapStruct에 간단히 정리해보았다 어노테이션 하나하나를 설명하기에는 비효율적인 것 같아 프로젝를 진행하며, 오류를 만났던 부분을 위주로 정리해보았는데 더 구체적인 사용법은 아래 공식문서와 깃허브를 참고하면 좋을 것 같다 :)
🥕 DO SOPT 33기 합동 세미나 - 당근 마켓 리디자인 서버 레포지토리
'Study > Backend Note' 카테고리의 다른 글
[Spring Boot] Custom Error Handling을 해보자! (2) | 2023.11.28 |
---|---|
[SOPT] 파이썬과 Mysql 그리고 구글스프레드 시트를 연동하여 기획이랑 소통하는 법 (0) | 2023.07.21 |
[Spring Intro] Section 07. AOP (0) | 2023.03.07 |
[Spring Intro] Section 06. 스프링 DB 접근 기술 (0) | 2023.03.06 |
[Spring Intro] Section 05. 회원 관리 예제 - 웹 MVC 개발 (0) | 2023.03.04 |