
차량 판매등록 기능을 구현하는데, 차량 관련 엔티티 10개에 대한 반복적인 작업을 해야 했다.
각 엔티티 만들고, 서비스 인터페이스 + 구현체 만들고, DTO 만들고... 전부 하나하나 일일이 해야 했다.
최대한 효율적으로 작업할 수 있는 방법은 없을까 많이 고민했지만, 엔티티 자체가 많은 거라 어쩔 수 없이 반복작업이 필요한 부분이었다. 그래도 유지보수성을 조금이라도 올리기 위해, facade 서비스 클래스를 따로 만들어서 여러 엔티티를 거치는 비즈니스 로직은 여기서 처리하고, 나머지 각 엔티티 별 서비스에서는 최대한 단일 책임 원칙(SRP)을 지켜 해당 엔티티에만 영향을 주는 로직을 작성하기로 했다.
그러나 정말 무의미하다고 느껴졌던 부분이, DTO <-> 엔티티를 변환하기 위한 팩토리 메서드를 작성할 때였다.
DTO 10개에 팩토리 메서드 toEntity를 일일이 빌더 패턴으로 전부 작성하는데, 점점 뭔가 잘못 되어감을 느꼈다. 이거 너무 반복작업인데..? 너무 무의미해. 분명히 간소화시킬 방법이 있을 거야. 라는 생각이 들었다.
방법을 찾아보니 매퍼 클래스를 이용하는 방법이 있었다. DTO <-> 엔티티 변환을 위한 코드를 컴파일 시점에 자동으로 작성해 주는 것인데, 이렇게 하면 변환을 위한 보일러플레이트 코드를 획기적으로 줄일 수 있었다. 매퍼 클래스는 MapStruct로 구현할 수 있다.
매퍼 클래스란?
- 서로 다른 객체 모델 간의 데이터 변환을 담당하는 클래스
- 변환 작업을 위한 코드를 전문적으로 담당
MapStruct란?
- Java 객체 간 매핑을 자동화하는 도구
- 주로 DTO <-> 엔티티 간 변환 작업을 간소화하는 데 사용됨
MapStruct의 장점
1. 보일러플레이트 코드 감소
-> 수동으로 작성해야 하는 변환 코드를 자동 생성해 반복 작업을 줄여준다.
2. 컴파일 시점 타입 안전성
-> 런타임이 아닌, 컴파일 시점에 오류를 감지하여 디버깅이 쉽다.
3. 높은 성능
-> 실행 시간에 리플렉션을 사용하지 않고, 메서드 직접 호출 방식으로 동작하여 실행 속도가 빠르다.
결과적으로 MapStruct를 사용한 매퍼 클래스 도입은 보일러플레이트 코드를 감소시켜주며, DTO <-> 엔티티 변환용 팩토리 메서드를 작성하는 귀찮은 반복 작업을 효과적으로 줄일 수 있다.
기존 코드
public class UserProfileResponseDto {
private String userId;
private String email;
private String userName;
private String phoneNumber;
private UserRole role;
// 팩토리 메서드 1
public static UserProfileResponseDto fromUserDetails(CustomUserDetails userDetails) {
return UserProfileResponseDto.builder()
.userId(userDetails.getUserId())
.email(userDetails.getEmail())
.userName(userDetails.getUsername())
.phoneNumber(userDetails.getPhoneNumber())
.role(userDetails.getRole())
.build();
}
// 팩토리 메서드 2
public static UserProfileResponseDto fromUser(User user) {
return UserProfileResponseDto.builder()
.userId(user.getUserId())
.email(user.getEmail())
.userName(user.getUsername())
.phoneNumber(user.getPhoneNumber())
.role(user.getRole())
.build();
}
}
기존엔 이렇게 Dto 또는 Entity로의 변환을 위한 팩토리 메서드를 적으면 1개, 많으면 여러 개까지 DTO 클래스에 반복적으로 작성해야 했었다.
개선 코드
// DTO에서 팩토리 메서드 제거
public class UserProfileResponseDto {
private String userId;
private String email;
private String userName;
private String phoneNumber;
private UserRole role;
}
// 매퍼 인터페이스로 대체 ( 컴파일 시점에 구현체 코드 자동 생성 )
@Mapper(config = BaseMapperConfig.class)
public interface UserMapper {
/** to Entity */
@Mapping(source = "passwordHash", target = "passwordHash")
User fromSignUpRequestDto(SignUpRequestDto dto, String passwordHash);
/** to Dto */
@Mapping(source = "accessToken", target = "accessToken")
LoginResponseDto toLoginResponseDto(User user, String accessToken);
SignUpResponseDto toSignUpResponseDto(User user);
@Mapping(source = "username", target = "userName")
UserProfileResponseDto toUserProfileResponseDto(User user);
@Mapping(source = "username", target = "userName")
UserProfileResponseDto toUserProfileResponseDtoFromUserDetails(CustomUserDetails userDetails);
}
이렇게 매퍼 인터페이스를 작성만 해 두면 코드량이 매우매우 줄어들게 되어 편리하고 가독성도 향상된다.
이제 구현해 보자.
1. MapStruct 의존성 추가
// build.gradle
dependencies {
// 생략 ...
implementation 'org.mapstruct:mapstruct:1.5.5.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.5.Final'
}
가장 먼저 MapStruct 의존성을 추가하고 빌드해 준다.
여기서 신경써야 할 것은, 반드시 MapStruct 의존성이 Lombok 의존성보다 하위에 있어야 한다.
그 이유는 MapStruct가 Lombok이 생성하는 getter/setter 메서드를 사용해야 하기 때문이다. 만약 MapStruct가 먼저 처리되면 Method not found 같은 컴파일 오류가 발생할 수 있다.
2. MapperConfig 인터페이스 작성
package com.yangsunkue.suncar.mapper;
import org.mapstruct.MapperConfig;
import org.mapstruct.ReportingPolicy;
/**
* 매퍼 관련 설정 클래스 입니다.
* - 매핑되지 않은 필드에 대한 경고를 무시하도록 합니다.
*/
@MapperConfig(
componentModel = "spring",
unmappedTargetPolicy = ReportingPolicy.IGNORE
)
public interface BaseMapperConfig {
}
다음으로 MapperConfig 인터페이스를 작성한다. 각 매퍼마다 공통된 설정을 적용하기 위한 인터페이스이다.
여기선 매핑되지 않은 필드에 대한 경고를 무시하는 설정을 추가했다. 이 설정이 필요한 이유는 자동 증가하는 id 필드, 자동 입력되는 created_at 같은 필드를 위한 설정이다.
이 설정을 하지 않으면 dto -> 엔티티 변환할 때 id, created_at 등 필드가 매핑되지 않는다고 경고가 뜬다. 어차피 자동입력되는 필드들이기 때문에 경고를 무시하도록 설정한다.
3. 매퍼 인터페이스 작성
package com.yangsunkue.suncar.mapper;
import com.yangsunkue.suncar.dto.auth.request.SignUpRequestDto;
import com.yangsunkue.suncar.dto.auth.response.LoginResponseDto;
import com.yangsunkue.suncar.dto.auth.response.SignUpResponseDto;
import com.yangsunkue.suncar.dto.user.response.UserProfileResponseDto;
import com.yangsunkue.suncar.entity.user.User;
import com.yangsunkue.suncar.security.CustomUserDetails;
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
/**
* User 엔티티 관련 매퍼 인터페이스 입니다.
*/
@Mapper(config = BaseMapperConfig.class)
public interface UserMapper {
/** to Entity */
@Mapping(source = "passwordHash", target = "passwordHash")
User fromSignUpRequestDto(SignUpRequestDto dto, String passwordHash);
/** to Dto */
@Mapping(source = "accessToken", target = "accessToken")
LoginResponseDto toLoginResponseDtoFromUserDetails(CustomUserDetails userDetails, String accessToken);
SignUpResponseDto toSignUpResponseDto(User user);
@Mapping(source = "username", target = "userName")
UserProfileResponseDto toUserProfileResponseDto(User user);
@Mapping(source = "username", target = "userName")
UserProfileResponseDto toUserProfileResponseDtoFromUserDetails(CustomUserDetails userDetails);
}
이제 실제로 우리가 사용할 매퍼 인터페이스를 작성한다.
이 코드는 User 엔티티와 관련된 변환 메서드를 선언해 둔 것이다.
@Mapper 어노테이션을 인터페이스 레벨에 달아서, 위에서 작성한 config를 적용시킨다.
@Mapping 어노테이션을 메서드 레벨에 달아서, 각 엔티티와 dto의 일치하지 않는 필드명을 매핑시켜 준다.
fromSignUpRequestDto 메서드를 보면 2번째 인자 passwordHash가 있고 source와 target이 같은데,
이것은 2번째 인자를 target(엔티티)의 passwordHash 필드에 매핑한다는 의미이다.
MapStruct는 기본적으로 DTO와 엔티티의 필드명이 같으면 자동으로 매핑하기 때문에, 필드명이 다르거나 인자로 따로 받아야 하는 것만 명시적으로 표기해 주면 된다.
또한 일관성을 위해 네이밍 규칙도 정했다.
DTO -> 엔티티 변환 메서드는 fromXXX
엔티티 -> DTO 변환 메서드는 toXXX 로 네이밍 하였으며,
DTO 변환 메서드의 인자가 엔티티가 아닐 경우에는 FromXXX 를 붙여 어떤 인자가 필요한지 명시했다.
앞서 말했지만, 이렇게 인터페이스만 정의해 두면 컴파일 시점에 자동으로 구현체 코드가 생성된다.
4. 서비스 함수에서 사용
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Slf4j
public class AuthServiceImpl implements AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
private final AuthenticationManager authenticationManager;
private final UserMapper userMapper; // UserMapper 인터페이스 추가
/**
* 일반 회원가입을 진행합니다.
*/
@Override
@Transactional
public SignUpResponseDto createUser(SignUpRequestDto dto) {
// 중복 검사
if (userRepository.existsByUserId(dto.getUserId())) {
throw new DuplicateResourceException(ErrorMessages.DUPLICATE_USER_ID);
}
if (userRepository.existsByEmail(dto.getEmail())) {
throw new DuplicateResourceException(ErrorMessages.DUPLICATE_EMAIL);
}
// 패스워드 해시 및 엔티티로 변환
String hashedPassword = passwordEncoder.encode(dto.getPassword());
User user = userMapper.fromSignUpRequestDto(dto, hashedPassword); // 매퍼 사용
// DB 에 저장 및 DTO로 변환
User saved = userRepository.save(user);
SignUpResponseDto userDto = userMapper.toSignUpResponseDto(saved); // 매퍼 사용
// 리턴
return userDto;
}
}
회원가입 메서드에서 매퍼를 사용해 보았다. 필드에 Repository를 선언하는 것 처럼 UserMapper를 선언해 두고 메서드에서 사용하면 된다.
userMapper.fromSignUpRequestDto(dto, hashedPassword) 이런 식으로 매퍼 클래스를 사용한다.
인터페이스에서 선언한 메서드명과 인자를 넣어주기만 하면 자동으로 변환되기 때문에 매우 편리하다.
이렇게 매퍼를 도입하여 반복적인 보일러플레이트 코드를 줄여볼 수 있었다.
마무리
엔티티가 너무 많으니, DTO마다 변환용 팩토리 메서드를 반복적으로 작성하니 현타가 왔었다. 반드시 간소화 시킬 수 있는 방법이 있을 거라 생각했고 매퍼라는 방법을 찾아 성공적으로 간소화 시킬 수 있었다.
스프링은 이런 편의성과 관련된 기능들이 참 많은 것 같다. 대부분 사람들은 스프링이 백엔드 프레임워크 중 가장 어렵다고 하지만, 어렵다기 보단 편리한 기능들이 너무 많아 진입장벽이 높은 것이고 오히려 숙달되면 다른 프레임워크에 비하여 압도적으로 편한 것 같다. FastAPI로 개발할 때에 비해서, 개발자가 비즈니스 로직에만 집중할 수 있도록 자잘한 것에 신경쓰지 않게 도와주는 기능들이 너무 잘 되어있는 것 같다.
=============
썬카 노션
https://lava-move-d1e.notion.site/SunCar-1a754e6b788180f598cdea3bfaff3139?pvs=4
깃허브
'Development > 썬카(SunCar) - 개인 프로젝트' 카테고리의 다른 글
[썬카/백엔드] 차량 관련 Facade 서비스 인터페이스 세분화 (LSP, ISP 원칙) (0) | 2025.04.04 |
---|---|
[썬카/백엔드] 차량 판매등록 기능 구현 - 더미 데이터 생성과 객체지향적 설계 (0) | 2025.04.03 |
[썬카/정기 회고] 스프린트 2 종료 - N+1 문제 (2) | 2025.03.25 |
[썬카/백엔드] 차량 목록 조회 쿼리 작성 과정에서의 N+1 문제 발생과 해결 (0) | 2025.03.25 |
[썬카/백엔드] Swagger를 이용한 API 명세서 도입 (0) | 2025.03.22 |