[Section 3] DI와 Mapper

Kim·2022년 10월 24일
0

Boot Camp

목록 보기
35/64
post-thumbnail
post-custom-banner

이번엔 Spring의 DI를 이용해서 API 계층과 비즈니스 계층을 연동하고, API 계층에서 전달 받은 DTO 객체를 비즈니스 계층의 도메인 Entity 객체로 변환해 전달하는 실습을 해보았다.
아직 데이터베이스 연동에 대해 배우지 않아서 서비스 계층에서 샘플 응답 데이터를 클라이언트에 전송하는 로직을 추가하여 진행했다.

코드 자체를 전부 올릴 수는 없을 것 같아, 흐름 정도만을 작성해볼까 한다.


DI를 통한 API 계층과 서비스 계층 연동

API 계층과 서비스 계층을 연동한다는 것은 API 계층에서 구현한 Controller 클래스가 서비스 계층의 Service 클래스와 메서드 호출을 통해 상호작용하는 것이다.

애플리케이션에서 Service란 도메인 업무 영역을 구현하는 비즈니스 로직과 관련이 있다.
비즈니스 로직을 처리하기 위한 서비스 계층은 대부분 도메인 모델을 포함한다.
도메인 모델은 빈약한 도메인 모델(anemic domain model)풍부한 도메인 모델(rich domain model)로 구분할 수 있다. 도메인 모델은 DDD(Domain Driven Design, 도메인 주도 설계)와 관련이 깊은데, DDD의 관한 학습은 추후에 할 것 같다.

우선 지금은 Service라 하면 단순히 비즈니스 로직을 처리하는 Service 클래스라 생각하고 학습을 진행하면 된다고 한다.

비즈니스 로직을 처리할 Service 클래스

API 계층에서 클라이언트의 요구 사항을 만족하는 Controller 클래스를 구현했다면, Controller를 기반으로 이와 연동되는 Service 클래스의 틀을 만들 수 있다.

@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
    @PostMapping
    public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
        return new ResponseEntity<>(memberDto, HttpStatus.CREATED);
    }

    @PatchMapping("/{member-id}")
    public ResponseEntity patchMember(@PathVariable("member-id") @Positive long memberId, @Valid @RequestBody MemberPatchDto memberPatchDto) {
        ...
        return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
    }

    @GetMapping("/{member-id}")
    public ResponseEntity getMember(@PathVariable("member-id") @Positive long memberId) {
        ...
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @GetMapping
    public ResponseEntity getMembers() {
        ...
        return new ResponseEntity<>(HttpStatus.OK);
    }

    @DeleteMapping("/{member-id}")
    public ResponseEntity deleteMember(@PathVariable("member-id") @Positive long memberId) {
        ...
        return new ResponseEntity(HttpStatus.NO_CONTENT);
    }
}

MemberService와 연동되기 전의 MemberController(v1)의 모습은 대략 이렇다.

  • postMember() : 1명의 회원 등록을 위한 요청을 전달 받는다.

  • patchMember() : 1명의 회원 수정을 위한 요청을 전달 받는다.

  • getMember() : 1명의 회원 정보 조회를 위한 요청을 전달 받는다.

  • getMembers() : N명회원 정보 조회를 위한 요청을 전달 받는다.

  • deleteMember() : 1명의 회원 정보 삭제를 위한 요청을 전달 받는다.

다섯 개의 핸들러 메서드가 전달 받은 요청을 처리하는 메서드를 MemberService 클래스에 작성한다.

MemberController 클래스의 핸들러 메서드 정보를 바탕으로 MemberService 클래스의 큰 틀을 작성한다.
MemberService 클래스는 MemberController 클래스가 전달 받은 요청을 실제로 처리한다.

Member 클래스의 역할

MemberController의 핸들러 메서드에서 Request Body(클라이언트의 요청 데이터)를 전달받을 때, MemberPostDto 같은 DTO 클래스를 사용했다.

DTO가 API 계층에서 전달 받은 요청 데이터를 기반으로 서비스 계층에서 비즈니스 로직을 처리하기 위해 필요한 데이터를 전달 받는다.
비즈니스 로직을 처리한 이후, 결과 값을 다시 API 계층으로 리턴해주는 역할을 한다.

서비스 계층에서 데이터 액세스 계층과 연동하며 비즈니스 로직을 처리하기 위해 필요한 데이터를 담는 역할을 하는 클래스를 도메인 엔티티(Entity) 클래스라 한다.

@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
    private long memberId;
    ...
}
  • @Getter , @Setter : lombok 라이브러리에서 제공하는 애너테이션
    DTO 클래스를 작성하면서 각 멤버 변수에 해당하는 getter/setter 메서드를 일일히 작성하지 않아도 된다.

  • @NoArgsConstructor : 피라미터가 없는 기본 생성자를 자동으로 생성

  • @AllArgsConstructor : 현재 Member 클래스에 추가된 모든 멤버 변수를 피라미터로 갖는 Member 생성자를 자동으로 생성

Member 클래스에는 보이지 않지만 내부적으로 getter/setter 메서드가 작성되어 있다고 생각하면 된다.

DI를 적용한 비즈니스 계층과 API 계층 연동

DI를 사용하지 않고 MemberController와 MemberService를 연동하면 강한 결합(Tight Coupling)이 될 것이다.
DI를 사용하면 클래스 간의 결합을 느슨한 결합(Loose Coupling)으로 만들 수 있다.

@RestController // (2)
@RequestMapping("/v3/members")
@Validated
public class MemberController {
    private final MemberService memberService;

	// (1)
    public MemberController(MemberService memberService) {
        this.memberService = memberService;
    }
		
		...
		...
}

DI를 사용해서 MemberController 생성자 피라미터로 MemberService의 객체를 주입받았다.
Spring에서 DI로 어떤 객체를 주입받으려면 주입 받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 하므로, MemberController에는 @RestController 애너테이션을 추가했다.
마찬가지로 MemberService에도 @Service 애너테이션을 추가해주었다.

@Service
public class MemberService {
    ...
}

MemberController의 문제점

MemberController는 잘 동작하지만 두 가지 개선점이 있다.

  1. 핸들러 메서드의 역할은 클라이언트로부터 전달 받은 요청 데이터를 Service 클래스로 전달하고, 응답 데이터를 클라이언트로 다시 전송해주는 단순한 역할만 하는 것이 좋다.
    하지만, 현재 MemberController는 핸들러 메서드가 DTO 클래스를 엔티티 객체러 변환하는 작업까지 하고 있다.

  2. Service 계층에서 사용되는 엔티티(Entity) 객체를 클라이언트의 응답으로 전송하고 있다.
    DTO 클래스는 API 계층에서만 데이터를 처리하는 역할을 하고, 엔티티(Entity) 클래스는 서비스 계층에서만 데이터를 처리하는 역할을 해야 한다.
    그러나 엔티티 클래스의 객체를 클라이언트 응답으로 전송하면서 계층 간의 역할 분리가 이루어지지 않았다.


Mapper를 이용한 DTO 클래스와 Entity 클래스 Mapping

위에서 실습한 MemberController에는 문제점이 있다고 했었는데, 문제를 해결하려면 어떻게 해야할까?

첫 번째 문제는 DTO 클래스를 엔티티 클래스로 변환하는 작업을 핸들러 메서드가 아닌 다른 클래스에게 요청하면 된다.
두 번째 문제는 클라이언트이 응답으로 엔티티 클래스를 전송하지 않고, 엔티티 클래스 객체를 DTO 클래스의 객체로 다시 바꿔주면 된다.
결국, DTO 클래스와 엔티티 클래스를 서로 변환해주는 Mapper가 필요하다.

Mapper 클래스 구현

@Component  // (1)
public class MemberMapper {
	// (2) MemberPostDto를 Member로 변환
    public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
        return new Member(0L,
                ... );
    }

	// (3) MemberPatchDto를 Member로 변환
    public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
        return new Member(memberPatchDto.getMemberId(), null, 
                ... );
    }

    // (4) Member를 MemberResponseDto로 변환
    public MemberResponseDto memberToMemberResponseDto(Member member) {
        return new MemberResponseDto(member.getMemberId(),
                ... );
    }
}
  • (1) MemberMapper를 Spring의 Bean으로 등록하기 위해 @Component 애너테이션을 추가했다. 등록된 Bean은 MemberController에서 사용된다.

  • (2) MemberPostDto 클래스를 Member 클래스로 변환하는 메서드다.

  • (3) MemberPatchDto 클래스를 Member 클래스로 변환하는 메서드다.

  • (4) Member 클래스를 MemberResponseDto 클래스로 변환하는 메서드다.

MemberController 핸들러 메서드에 Mapper 클래스 적용하기

// 코드 생략
  • (1) Spring Bean에 등록된 MemberMapper 객체를 MemberController에서 사용하기 위해 DI로 주입 받는다.

  • (2) MemberMapper 클래스를 이용하여 MemberPostDtoMember로 변환한다.

  • (3) MemberMapper 클래스를 이용하여 MemberMemberResponseDto로 변환한다.

  • (4) MemberMapper 클래스를 이용하여 MemberPatchDtoMember로 변환하고 있습니다.

  • (5), (6) MemberMapper 클래스를 이용하여 MemberMemberResponseDto로 변환한다.

  • (7) memberService.findMembers()를 통해 리턴되는 값은 List 이다.
    List 안의 Member 객체들을 하나씩 꺼내서 MemberResponseDto 객체로 변환해주어야 하는데, 이 작업을 Java의 Stream이 하고 있다.

MemberController의 문제점 해결

Mapper 클래스를 사용함으로써 MemberController의 문제점이 해결되었다.

  1. MemberMapper에게 DTO 클래스를 엔티티 클래스로 변환하는 작업을 위임하여 MemberController는 더이상 두 클래스의 변환 작업에 신경쓰지 않아도 된다.

  2. MemberMapper가 엔티티 클래스를 DTO 클래스로 변환해주므로 서비스 계층에 있는 엔티티 클래스를 API 계층에서 직접적으로 사용하는 문제가 해결되었다.

MapStruct를 이용한 Mapper 자동 생성

Mapper 클래스를 이용하면 DTO 클래스와 엔티티 클래스의 변환 작업을 깔끔하게 처리할 수 있다.

어떠한 도메인의 업무 기능이 늘어날 때마다 지금처럼 Mapper 클래스를 직접 만드는 작업은 굉장히 비효율적이다.
MapStructMapper 클래스를 자동으로 구현해줌으로써 개발자의 생산성을 향상 시켜줄 수 있다.

dependencies {
	implementation 'org.mapstruct:mapstruct:1.4.2.Final'
	annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
    ...
}

MapStruct 기반의 Mapper를 자동 생성하기 위해서는 위와 같이 MapStruct 관련 의존 라이브러리를 bulid.gradle에 추가해야 한다.
의존 라이브러리를 추가하고 Gardle 프로젝트를 reload 해야한다.

IDE 우측의 Gradle 탭을 클릭하고 프로젝트명 위에서 마우스 우클릭을 하고 Reload Gradle Project(또는 Refresh Gradle Dependencies)를 클릭하여 프로젝트 혹은 의존 라이브러리를 갱신한다.

@Mapper(componentModel = "spring")  // (1)
public interface MemberMapper {
    Member memberPostDtoToMember(MemberPostDto memberPostDto);
    Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
    MemberResponseDto memberToMemberResponseDto(Member member);
}

DTO 클래스를 변환하는 Mapper를 자동 생성하기 위해 위와 같이 Mapper 인터페이스를 먼저 정의해야 한다.

MemberMapper 인터페이스에 정의한 세 메서드가 이전에 직접 작성했던 MemberMapper 클래스를 대체해준다.
@Mapper 애너테이션을 추가함으로써 해당 인터페이스는 MapStruct의 Mapper 인터페이스로 정의가 되었다. @Mapper 애너테이션의 애트리뷰로 componebtModel = "spring"을 지정해주면 Spring의 Bean으로 등록이 된다.

IDE의 우측의 Gradle 탭을 클릭하고 프로젝트명 -> Tasks -> build -> build(또는 build task) 를 더블 클릭하면 MemberMapperImpl 클래스가 생성된다.

MemberMapperImpl 클래스의 위치는 IDE 좌측의 Project 탭에서 프로젝트명 -> build 내에 MemberMapper 인터페이스가 위치한 패키지 안에 생성된다. 나의 경우, 사진상의 경로에서 MemberMapperImpl 클래스를 찾을 수 있었다.

MemberController의 핸들러 메서드에 MapStruct 적용

// import com.···.MemberMapper;
import com.···.mapstruct.mapper.MemberMapper; // (1) 패키지 변경

@RestController
@RequestMapping("/v5/members") // (2) URI 버전 변경
@Validated
public class MemberController {
    ...
		...
		...
}

직접 구현했던 Mapper를 MapStruct 기반의 Mapper로 변경했다. Mapstruct 인터페이스가 위치를 import문으로 알려주고, Controller의 URI 버전을 바꿔줬다.
클래스 내부는 건드릴 필요가 없고 사용하고자 하는 Mapper만 바꿔주면 끝이다!

Mapstruct vs ModelMapper

MapStruct와 ModelMapper는 Java에서 Object를 Mapping하는 라이브러리 중에 가장 많이 사용되는 라이브러리다.
ModelMapper는 런타임시 Java의 리플렉션 API를 이용해 Mapping을 진행한다. 그렇기에 컴파일 할 때 이미 Mapper가 생성되는 MapStruct보다 성능면에서 밀릴 수 있다.


DTO 클래스와 Entity 클래스의 역할 분리가 필요한 이유

1. 계층별 관심사의 분리

DTO와 Entity 클래스의 역할 분리가 필요한 이유는 우선, 서로 사용되는 계층이 다르기 때문이다. 즉, 기능에 대한 관심사가 다르다는 의미다.

DTO 클래스는 API 계층에서 요청 데이터를 전달 받고 응답 데이터를 전송하는 것이 주 목적이다.
Entity 클래스는 서비스 계층에서 데이터 액세스 계층과 연동해 비즈니스 로직의 결가로 생성된 데이터를 다루는 것이 주 목적이다.

하나의 클래스나 메서드 내에서 여러 기능들을 구현하고 있는 것은 객체지향 코드 관점에서 리팩토링의 대상이 된다.

2. 코드 구성의 단순화

DTO 클래스에서 사용하는 유효성 검사 애너테이션이 Entity 클래스에서 사용된다면, JPA에서 사용하는 애너테이션과 뒤섞여버린 상태가 될 것이다. 그렇게 되면 유지봇하기에 상당히 어려운 코드가 된다.

3. REST API 스펙의 독립성 확보

데이터 액세스 계층에서 전달 받은 데이터로 채워진 Entity 클래스를 클라이언트의 응답으로 그대로 전달하면, 원하지 않는 데이터까지 클라이언트에게 전송될 수 있다.
대표적인 예시로는 회원의 로그인 패스워드가 있다.
DTO 클래스를 사용하면 회원의 로그인 패스워드 같은 정보를 클라이언트에게 노출하지 않고 원하는 정보만을 제공할 수 있다.

post-custom-banner

0개의 댓글