[Design Pattern] MapStruct vs Builder

송영호·2025년 3월 12일

Spring Boot

목록 보기
4/8

개요

개발을 하다 보면 객체 간 변환이 필요할 때가 많다. 예를 들어, Entity ↔ DTO 변환을 해야 할 때, 일반적으로 Setter를 사용하거나, 생성자를 활용한 변환 로직을 직접 작성할 수도 있다. 하지만 유지보수성과 가독성을 위해, Static Method Factory, MapStruct, Builder 패턴 같은 변환 패턴을 고려할 수 있다.

이번 글에서는 MapStruct와 Builder 패턴을 비교하고, 각각의 장단점과 구현 방법을 살펴보려한다.

MapStruct

객체 매핑을 자동화하는 라이브러리로,
컴파일 시점에 코드를 자동 생성(@Mapper를 붙인 인터페이스의 구현체)하여 성능이 뛰어나며, 코드의 가독성을 높여준다.
➡️ 주요 목적 : 변환

장점

✅ 컴파일 시점에 변환 코드를 자동 생성하여, 런타임 리플렉션 기반의 변환 방식보다 빠르고, 안전함.

✅ 어노테이션만 추가하면 자동 변환 코드가 생성되며, DTO <-> Entity 변환을 직관적으로 정의할 수 있다.

✅ DTO의 필드가 변경될 경우, MapStruct가 컴파일 시점에 오류 감지


단점

🚨 상위 클래스(BaseDto)에 매핑할 필드가 있을 경우, 자동으로 매핑하지 않음.

🚨 Lombok과 충돌 가능성(builder, getter가 만들어지기 전에 mapstruct annotation processor가 먼저 동작하여, mapping 안될 수 있음.)

🚨 특정 조건에 따라 mapping 필요한 경우, 변환 메서드를 직접 구현해야함.


구현

1️⃣ 의존성 추가

build.gradle📜

implementation 'org.mapstruct:mapstruct:1.6.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.6.0'

2️⃣ DTO 정의

@Getter
@SuperBuilder
public class BaseDto extends BaseTimeDto {
    @Schema(description = "생성자 ID", example = "1")
    private Long createdBy;

    @Schema(description = "수정자 ID", example = "1")
    private Long updatedBy;
}

@Getter
@SuperBuilder
public abstract class BaseTimeDto {
    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @Schema(example = "2025-01-01 00:00:00", description = "생성시간")
    private LocalDateTime createdDateTime;

    @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd HH:mm:ss", timezone = "Asia/Seoul")
    @JsonSerialize(using = LocalDateTimeSerializer.class)
    @JsonDeserialize(using = LocalDateTimeDeserializer.class)
    @Schema(example = "2025-01-01 00:00:00", description = "수정시간")
    private LocalDateTime updatedDateTime;
}


@Getter
@SuperBuilder
public class MemberResponse extends BaseDto {
    @Schema(description = "사용자 ID", example = "1")
    private Long id;

    @Schema(description = "로그인 ID", example = "user1")
    private String loginId;

    @Schema(description = "사용자명", example = "홍길동")
    private String name;

    @Schema(description = "권한", example = "ROLE_USER")
    private String role;

    @Schema(description = "계정 잠금 여부", example = "false")
    private Boolean isUse;

    @Schema(description = "마지막 로그인 시간", example = "2025-01-01 00:00:00")
    private LocalDateTime lastLoginDateTime;
}

3️⃣ Mapper 정의

MemberMapper📜

  • source - 매핑값을 가져올 대상
  • target - 매핑할 대상
@Mapper
public interface MemberMapper {
    MemberMapper INSTANCE = Mappers.getMapper(MemberMapper.class);

    @Mapping(target = "id", source = "id")
    @Mapping(target = "loginId", source = "loginId")
    @Mapping(target = "name", source = "name")
    @Mapping(target = "role", source = "role")
    @Mapping(target = "lastLoginDateTime", source = "lastLoginDateTime")
    @Mapping(target = "isUse", source = "isUse")
    @Mapping(target = "createdDateTime", source = "createdDateTime")
    @Mapping(target = "updatedDateTime", source = "updatedDateTime")
    @Mapping(target = "createdBy", source = "createdBy")
    @Mapping(target = "updatedBy", source = "updatedBy")
    MemberResponse toDto(Member member);
}

컴파일 시, 자동으로 MemberMapperImpl 클래스가 생성된다.
변환 조건이 필요할 경우, 해당 메서드를 직접 구현해야한다.

MemberMapperImpl📜

@Generated(
    value = "org.mapstruct.ap.MappingProcessor",
    date = "2025-03-12T17:35:08+0900",
    comments = "version: 1.6.0, compiler: javac, environment: Java 21.0.4 (Amazon.com Inc.)"
)
public class MemberMapperImpl implements MemberMapper {

    @Override
    public MemberResponse toDto(Member member) {
        if ( member == null ) {
            return null;
        }

        Long id = null;
        String loginId = null;
        String name = null;
        String role = null;
        LocalDateTime lastLoginDateTime = null;
        boolean isUse = false;

        id = member.getId();
        loginId = member.getLoginId();
        name = member.getName();
        role = member.getRole();
        lastLoginDateTime = member.getLastLoginDateTime();
        if ( member.getIsUse() != null ) {
            isUse = member.getIsUse();
        }

        MemberResponse memberResponse = new MemberResponse( id, loginId, name, role, isUse, lastLoginDateTime );

        memberResponse.setCreatedDateTime( member.getCreatedDateTime() );
        memberResponse.setUpdatedDateTime( member.getUpdatedDateTime() );
        memberResponse.setCreatedBy( member.getCreatedBy() );
        memberResponse.setUpdatedBy( member.getUpdatedBy() );

        return memberResponse;
    }
}

4️⃣ 사용

package com.project.beauty_care.domain.member;

import com.project.beauty_care.domain.mapper.MemberMapper;
import com.project.beauty_care.domain.member.dto.MemberCreateRequest;
import com.project.beauty_care.domain.member.dto.MemberResponse;
import com.project.beauty_care.global.enums.Errors;
import com.project.beauty_care.global.enums.Role;
import com.project.beauty_care.global.exception.EntityNotFoundException;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository repository;
    
    public MemberResponse findMemberById(Long id) {
        Member member = findById(id);

        return MemberMapper.INSTANCE.toDto(member);
    }
    
    private Member findById(Long memberId) {
        return repository.findById(memberId)
                .orElseThrow(() -> new EntityNotFoundException(Errors.NOT_FOUND_MEMBER));
    }
}

Builder

객체의 생성 과정과 표현 방법을 분리하여, 유연하고 가독성 좋은 객체 생성 방식을 제공하는 디자인 패턴
매개변수가 많을 때 사용한다.
➡️주요 목적 : 객체 생성

장점

✅ 필드가 많은 객체를 생성할 때, 생성자의 매개변수를 일일이 지정하지 않아도 됨.

✅ 불변 객체 생성 가능(필드를 한번만 설정 -> Entity에 적합)

✅ 체이닝(Chaining) 방식으로 직관적인 객체 생성

✅ Setter 없이 객체 생성 가능


단점

🚨 변환 로직이 길고, 각각 필드를 수동으로 매핑해야됨.

🚨 객체 생성이 빈번한 경우 MapStruct보다 성능 떨어짐.

🚨 새로운 필드를 추가하면, Builder 클래스도 함께 수정해야 함.


구현

1️⃣ 생성자 정의(적용할 필드만) 및 @Builder 명시

Member📜

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(uniqueConstraints = @UniqueConstraint(name = "UQ_MEMBER_LOGIN_ID", columnNames = {"login_id"}))
public class Member extends BaseEntity {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String loginId;

    @NotNull
    private String password;

    @NotNull
    private String name;

    private String role;

    private Boolean isUse;

    private LocalDateTime lastLoginDateTime;

    @Builder
    public Member(String loginId, String password, String name, Role role, LocalDateTime lastLoginDateTime) {
        this.loginId = loginId;
        this.password = password;
        this.name = name;
        this.role = role.getValue();
        this.isUse = Boolean.TRUE;
        this.lastLoginDateTime = lastLoginDateTime;
    }
}

2️⃣ 사용

필드를 지정 안할 경우, 기본값을 할당한다.

private Member createMember(String loginId) {
        return Member.builder()
                .name("test")
                .loginId(loginId)
                .role(Role.USER)
                .password("qwer1234")
                .build();
    }

MapStruct vs Builder 비교

caseMapStructBuilder
DTO <-> Entity 변환
객체 직접 생성
객체 불변성
가독성
성능 최적화❌(chaining)

Entity 클래스 생성 -> Builder
단순 DTO 변환 -> MapStruct
목적에 맞는 Design Pattern 혼용 필요성

profile
BACKEND 개발자

0개의 댓글