Fascination
article thumbnail

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로 바꾸어주니 아래와 같이 인터페이스 구현체가 잘 만들어지는 것을 확인할 수 있었다

 

편리한 객체 간 매핑을 위한 MapStruct 적용기 (feat. SENS)

Ncloud 문자/알림 발송 서비스 SENS 개발 과정에서 MapStruct를 활용해 보았습니다.

medium.com

@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에 간단히 정리해보았다 어노테이션 하나하나를 설명하기에는 비효율적인 것 같아 프로젝를 진행하며, 오류를 만났던 부분을 위주로 정리해보았는데 더 구체적인 사용법은 아래 공식문서와 깃허브를 참고하면 좋을 것 같다 :)

 

 

MapStruct 1.5.5.Final Reference Guide

If set to true, MapStruct in which MapStruct logs its major decisions. Note, at the moment of writing in Maven, also showWarnings needs to be added due to a problem in the maven-compiler-plugin configuration.

mapstruct.org

 

 

GitHub - mapstruct/mapstruct: An annotation processor for generating type-safe bean mappers

An annotation processor for generating type-safe bean mappers - GitHub - mapstruct/mapstruct: An annotation processor for generating type-safe bean mappers

github.com

 

🥕 DO SOPT 33기 합동 세미나 - 당근 마켓 리디자인 서버 레포지토리

 

GitHub - DOSOPT-CDS-APP-TEAM5/Carrot-Server: 당근 맛업더 ㅋ.ㅋ

당근 맛업더 ㅋ.ㅋ. Contribute to DOSOPT-CDS-APP-TEAM5/Carrot-Server development by creating an account on GitHub.

github.com

 

profile

Fascination

@euna-319

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!