(작성 중) 🙊 나의 컨트롤러와 서비스와 MapStruct의 동거 1화
😳 서비스의 두 얼굴! 믿었던 서비스의 메서드 오버로딩에 얽힌 이야기
🇰🇷 Kor: 지금 보는 중!
🗺 Eng: Not yet!
🇯🇵 Jap: まだです。
🙋♀️ 선생님, DTO는 언제 엔티티로 바꿀까요?
🧐 "맘대로 하려무나."
DTO를 받아서 Entity 객체로 변환하여 데이터베이스에 저장하기까지, 컨트롤러와 서비스 중 어느 단계에서 DTO를 Entity로 변환하는 로직을 수행할까요? 변환 수단은 어떻게 할까요? 🤔
(1) 컨트롤러에 변환 로직을 써서 컨트롤러의 코드를 늘려야 할까요?
(2) 아니면 서비스에서 DTO를 받게 하여 메서드의 확장성을 떨어뜨려야 할까요?
(a) 그리고 DTO에
toEntity()
메서드를 추가해서 데이터 변환을 스스로 수행하게 해야 할까요?(b) 또는 ModelMapper나 MapStruct 같은 기술만 사용하면 어디서 변환하든 괜찮을까요?
이 방식은 모두 실제로 사용되는 방법이죠. 그러나 명쾌하게 해결하는 기분은 아닐 때가 많았을 겁니다. 우리는 납득할 수 있는 구성을 고민하고 적용해 보았습니다.
우선은 각 계층의 역할에 대한 공감대를 공유하기 위해 설명을 준비했습니다.
💃 주요 등장인물 소개
김컨트롤러 (RestController 역)
그는 오랜 경력으로 요청부터 응답의 전체 흐름을 간추려 표현합니다. 그가 왠지 입코딩만 하는 건 기분탓이 아닙니다. 사실 그는 전체 흐름을 간단명료하게 표현할 수 있어야 하며, 세부적인 작업을 그가 수행하도록 하는 것은 이 조직이 원하는 것이 아닙니다.
이서비스 (Service 역)
유능한 그녀는 컨트롤러에게 필요한 주요 작업을 메서드로 제공합니다. 그녀는 엄격한 업무 규칙과 유연한 업무 규칙을 모두 중요시합니다. 그녀의 곁에는 자기 일만 딱 딱 수행하는 유능한 직원들이 있습니다.
남궁레포지터리 (JPA Repository 역)
그는 이 조직의 핵심 협력기관인 데이터베이스와 소통하는 중요 사원입니다. 그는 데이터베이스 측의 까다로운 소통 규칙과 업무 규칙을 숙지하고 있어, 상사의 명령이 하달되면 데이터베이스와의 협업을 아주 능숙하게 처리합니다. 가끔 필요한 데이터 목록을 상사가 미리 얘기해 주지 않으면, 데이터베이스에서 여러 차례 끊어서 받아오는 것 때문에 자신의 업무에 비효율을 느낄 때가 있습니다만, 상사가 미리 얘기하지 않은 데이터를 사용하려 해도 눈치껏 바로바로 데이터베이스에 요청해 가져다 줍니다.
이번 줄거리에서 우리가 관심을 둘 대상은 컨트롤러와 서비스입니다.
그리고 앞선 질문 중 (a), (b) 즉 변환 수단에 관해서는 미리 짧게 정리해 둡시다. DTO에 toEntity()
메서드를 만들어서 변환 로직을 직접 짜는 것보다, 개인적으로 MapStruct
를 통해 자동으로 코드를 생성하고 책임도 구분해 놓는 것을 선호합니다(DTO와 변환 로직을 떼어 놓는 것).
그래서 우리는 데이터 변환을 위해서 매퍼를 준비할 수 있습니다.
🕶 특별 출연
응우옌 반 매퍼 (MapStruct Mapper 역)
그는 반복적인 자료 정리에 아주 탁월한 직원입니다. 낯선 한국 문화에도 곧잘 적응했죠.
🔄 MapStruct 기초
우선 MapStruct의 코드 작성에 먼저 집중해 볼까요? 의존성 추가 등은 이따가 다룹시다. 다음은 MapStruct의 가장 기본적인 사용을 위한 아주 짧은 전달사항입니다.
인터페이스에 파라미터 목록, 반환 타입만 적으면 '이름'을 바탕으로 매핑해 줍니다.
이름이 다른 경우
@Mapping(target = "", source = "")
애노테이션으로 정해 줄 수 있습니다.그렇게 하면 자동으로 변환 코드가 생성됩니다. (빈 등록도 할 수 있어요!)
예를 들면 이렇게 파라미터 목록에 객체나 속성들을 나열하면 됩니다. 그럼 완성이에요. 쉽네요. 😤
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring") // <<< 스프링 빈으로 등록
public interface MemberDtoMapper {
Member toDomain(SignUpRequestDto dto, AccountStatus status, Instant createdAt);
}
만약 필드 이름이 다르다면 이렇게 애노테이션으로 매핑 대상을 정할 수도 있습니다.
import org.mapstruct.Mapper;
import org.mapstruct.Mapping;
@Mapper(componentModel = "spring")
public interface MemberDtoMapper {
@Mapping(target = "password", source = "dto.rawPassword")
@Mapping(target = "name", source = "dto.fullName")
Member toDomain(SignUpRequestDto dto, AccountStatus status, Instant createdAt);
}
🌟 이 외에도 MapStruct가 지원하는 짱짱한 기능들이 많습니다! 복잡한 객체 간 변환이나, 열거 타입 간 변환, 리스트 변환을 위한 전략(불변 리스트 회피 등), default
메서드(JDK 1.8+) 등도 잘 지원하니 생각보다 복잡한 쓰임새에서도 아주 만족도가 높았어요.
(물론 record
도 일반 클래스처럼 편하게 변환할 수 있구요!)
그래서 특히 모듈 간 데이터 변환을 빈번히 수행해야 하는 협업 환경이라면 아주 유용할 거예요.
제가 MapStruct를 처음 사용하려고 조사할 때 걱정하던 것보다 훨씬 잘 만들어 놨었습니다. 대신 기본 설정은 우리가 복잡한 작업에서도 의식을 안 할 만큼 스프링 환경에 맞춰 준 것이 아니어서, 세부 작업 시 설정에 대한 러닝커브가 어느 날 생길 수 있습니다.
MapStruct의 자세한 설명은 다른 포스팅에서 쓰겠습니다! 오늘은 위 정보면 충분할 거예요.
MapStruct 매퍼 객체 사용
만들어 둔 MapStruct Mapper 객체를 사용할 때는 빈 주입만 잘 받으면 됩니다!
앞서 컴포넌트 모델을 스프링으로 했기 때문에, (패키지가 Spring Application의 빈 스캔 범위에만 있다면) 빈으로 잘 등록되었을 겁니다. 괄호가 무슨 말인지 모르겠다구요? 그럼 아마도 문제 없는 걸 겁니다. 문제가 될 거면 괄호 속 이야기를 알 거예요.
@Service
@RequiredArgsConstructor // 생성자 주입을 위해서 사용했어요.
public final ExampleService {
private final MemberDtoMapper mapper;
}
MapStruct 의존성 라이브러리
맵스트럭트를 사용하기 위해서 build.gradle
에 다음 세 항목을 추가하면 됩니다.
dependencies {
// Map Struct
implementation 'org.mapstruct:mapstruct:1.5.3.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.3.Final'
annotationProcessor 'org.projectlombok:lombok-mapstruct-binding:0.2.0'
}
🛠 마지막에 추가한 롬복의 lombok-mapstruct-binding
애노테이션 프로세서는 MapStruct
와 Lombok
의 동작 순서를 조정하여, 맵스트럭트가 롬복 코드를 인식하도록 도와 줍니다. 둘 다 컴파일타임에 코드를 생성하기 때문에, 맵스트럭트가 롬복보다 먼저 동작한다면 맵스트럭트가 롬복 코드를 미인식하는 이슈가 존재할 수 있죠.
🙋♀️🙋♂️ 선생님, 이제 DTO는 언제 엔티티로 바꿀까요?
🧚♀️ "그래, 이쯤 공부해야 잘 알려 주지~"
단서들 (요구사항을 채워야 하니까)
컨트롤러에서 변환 로직을 사용하는 것은 피하고 싶습니다
왜냐 하면 우리가 원하는 컨트롤러 메서드의 로직은 이렇게 심플하면 좋겠거든요.
"음, 이 API는 무엇을 하고, 어떤 것을 한 다음, 거시기를 하고, 머시기를 했구나. 주요 작업만 쓰여 있으니 꽤 한 눈에 읽히네."
컨트롤러의_메서드() {
var 결과1 = 서비스A.무엇을하다(...);
var 결과2 = 서비스B.어떤것을하다(...);
var 결과3 = 서비스C.거시기를하다(...);
var 결과4 = 서비스D.머시기를하다(...);
// ...
return result;
}
데이터 변환은 우리의 프로그래밍에서 '주요' 관심사는 아닙니다. 다른 주된 작업을 하기 위해서 '보조적으로' 수행하는 작업일 뿐이죠. 이런 부가적인 작업을 컨트롤러에 명시하는 것을 피하려고 합니다.
~.signUp(dto, AccountStatus.ACTIVE, now);
서비스의 메서드에는 도메인 엔티티를 받게 하여 확장에 열어 두고 싶습니다
Member signUp(Member member);
이러면 앞서 컨트롤러가 호출하는 메서드와 파라미터 스펙이 다릅니다. 둘을 동시에 충족한다는 게 과욕이었던 걸까요? 아닙니다!
사실 우리는 오버로딩이라는 간단한 메서드 선언 기법을 알고 있죠. 이렇게요.
// Origin Method
Member signUp(Member member);
// Additional Method
Member signUp(SignUpRequestDto dto, AccountStatus status, Instant createdAt);
이러면 컨트롤러가 메서드 B를 호출하게 하고, 또 메서드 B가 메서드 A를 호출하는 식으로 간단히 구성할 수 있습니다. 메서드 B는 데이터를 변환해서 메서드 A에게 넘기는 역할을 하죠.
// Origin Method: core tasks
public Member signUp(Member member) {
// ... 주요 작업 수행 (개인정보 암호화, 데이터 저장 등)
return savedMember;
}
// Overloaded Method: convert dto to entity and deliver it to origin method.
public Member signUp(SignUpRequestDto dto, AccountStatus status, Instant createdAt) {
// [1] 데이터 변환
Member member = mapper.toDomain(dto, status, createdAt);
// (Method A와 별개로 추가적인 전처리가 있다면 이곳에서 수행하세요.)
// [2] Method B에게 넘기기 (별도 후처리가 없다면 그대로 반환)
return signUp(member);
}
이렇게 중간에서 중개하는 메서드 하나로 두 요구사항을 충족할 수 있습니다.
(오버로딩)
(확장성을 열어 두기 위해서 엔티티로 받는 메서드. 얘가 주요 작업을 모두 처리하는 중심이 되는 메서드.)
(사용성을 위해 확장한 추가 메서드. 얘는 이름은 같은데 파라미터가 DTO라서 재사용성은 거의 없는데, 여기는 일종의 엔트리포인트고 엔티티로 변환 후 위 메서드에게 작업을 전달함. 일종의 프록시 구성인 셈.)
public interface SignUpUseCase { // interface for service (피처 단위)
Member signUp(Member member);
Member signUp(SignUpRequestDto dto, ...추가 정보들);
}