Develog
코드스테이츠 43일차 본문
학습 목표
- DI(Dependency Injection)를 사용해서 API 계층과 서비스 계층을 연동할 수 있다.
- API 계층의 DTO 클래스와 서비스 계층의 엔티티(Entity) 클래스를 매핑할 수 있다.
- API 계층에서 전달받은 DTO 객체를 서비스 계층의 도메인 엔티티(Entity) 객체로 변환할 수 있다.
서비스 계층은 API 계층에서 전달 받은 클라이언트의 요청 데이터를 기반으로 실질적인 비즈니스 요구사항을 처리하는 계층이다.
DI를 통한 서비스 계층 ↔ API 계층 연동
API 계층과 서비스 계층을 연동한다는 의미는 API 계층에서 구현한 Controller 클래스가 서비스 계층의 Service 클래스와 메서드 호출을 통해 상호 작용한다는 것을 의미한다.
여기서 Service란?
애플리케이션에 있어 Service의 의미는 도메인 업무 영역을 구현하는 비즈니스 로직과 관련이 있다.
애플리케이션의 비즈니스 로직을 처리하기 위한 서비스 계층은 대부분 도메인 모델을 포함하고 있다.
도메인 모델은 다시 빈약한 도메인 모델(anemic domain model)과 풍부한 도메인 모델(rich domain model)로 구분할 수 있는데, 이러한 도메인 모델은 DDD(도메인 주도 설계, Domain Driven Design)와 관련히 깊다.
현재 수준에서는 단순히 비즈니스 로직을 처리하는 Service 클래스이다. 정도로만 생각하자
비즈니스 로직을 처리하는 Service 클래스
API 계층에서 클라이언트의 요구 사항을 잘 만족하는 Controller 클래스를 구현해 두었다면 이 Controller를 기반으로 해당 Controller와 연동하는 Service 클래스의 큰 틀을 만들 수 있다.
DTO가 API 계층에서 클라이언트의 Request Body를 전달 받고 클라이언트에게 되돌려 줄 응답 데이터를 담는 역할을 한다면, Service 클래스 내에 있는 클래스는 API 계층에서 전달 받은 요청 데이터를 기반으로 서비스 계층에서 비즈니스 로직을 처리하기 위해 필요한 데이터를 전달 받고, 비즈니스 로직을 처리한 후에는 결과 값을 다시 API 계층으로 리턴해주는 역할을 한다.
서비스 계층에서 데이터 액세스 계층과 연동하면서 비즈니스 로직을 처리하기 위해 필요한 데이터를 담는 역할을 하는 클래스를 도메인 엔티티(Entity) 클래스라고 부른다.
- @Getter, @Setter 애너테이션은 lombok이라는 라이브러리에서 제공하는 애너테이션으로서 우리가 DTO 클래스를 작성하면서 각 멤버 변수에 해당하는 getter/setter 메서드를 일일이 작성하는 수고를 덜어주는 편리한 유틸리티성 라이브러리이다.
- @AllArgsConstructor 애너테이션은 현재 ~~클래스에 추가된 모든 멤버 변수를 파라미터로 갖는 ~~생성자를 자동으로 생성해준다.
- @NoArgsConstructor는 파라미터가 없는 기본 생성자를 자동으로 생성해준다.
DI(Dependency Injection)없이 비즈니스 계층과 API 계층 연동
만약 MemberController와 MemberService가 있다고 칠 때, 이 둘을 어떻게 연동해야 할까?
@RestController
@RequestMapping("/v2/members")
@Validated
public class MemberController {
private final MemberService memberService;
public MemberController() {
this.memberService = new MemberService(); // (1)
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
// (2)
Member member = new Member();
member.setEmail(memberDto.getEmail());
member.setName(memberDto.getName());
member.setPhone(memberDto.getPhone());
// (3)
Member response = memberService.createMember(member);
return new ResponseEntity<>(response, HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(
@PathVariable("member-id") @Positive long memberId,
@Valid @RequestBody MemberPatchDto memberPatchDto) {
memberPatchDto.setMemberId(memberId);
// (4)
Member member = new Member();
member.setMemberId(memberPatchDto.getMemberId());
member.setName(memberPatchDto.getName());
member.setPhone(memberPatchDto.getPhone());
// (5)
Member response = memberService.updateMember(member);
return new ResponseEntity<>(response, HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(
@PathVariable("member-id") @Positive long memberId) {
// (6)
Member response = memberService.findMember(memberId);
return new ResponseEntity<>(response, HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers() {
// (7)
List<Member> response = memberService.findMembers();
return new ResponseEntity<>(response, HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(
@PathVariable("member-id") @Positive long memberId) {
System.out.println("# delete member");
// (8)
memberService.deleteMember(memberId);
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
- (1)은 MemberService 클래스를 사용하기 위해 MemberService 클래스의 객체를 생성하고 있다.
- (2)는 클라이언트에서 전달 받은 DTO 클래스의 정보를 MemberService의 updateMember() 메서드의 파라미터로 전달하기 위해 MemberPostDto 클래스의 정보를 Member 클래스에 채워넣고 있다.
- (3)은 회원 정보 등록을 위해 MemberService 클래스의 createMember() 메서드를 호출한다.
- ⭐ 서비스 계층과의 연결 지점이다.
- (4)는 클라이언트에서 전달 받은 DTO 클래스의 정보를 MemberService의 createMember() 메서드의 파라미터로 전달하기 위해 MemberPatchDto 클래스의 정보를 Member 클래스에 채워넣고 있다.
- (5)는 회원 정보 수정을 위해 MemberService 클래스의 updateMember() 메서드를 호출한다.
- ⭐ 서비스 계층과의 연결 지점이다.
- (6)은 한 명의 회원 정보 조회를 위해 MemberService 클래스의 findMember() 메서드를 호출한다. 특정 회원의 정보를 조회하는 기준인 memberId를 파라미터로 넘겨준다.
- ⭐ 서비스 계층과의 연결 지점이다.
- (7)은 모든 회원의 정보를 조회하기 위해 MemberService 클래스의 findMembers() 메서드를 호출한다.
- (8)은 한 명의 회원 정보를 삭제하기 위해 MemberService 클래스의 deleteMember() 메서드를 호출한다. 특정 회원의 정보를 삭제하는 기준인 memberId를 파라미터로 넘겨준다.
DI를 적용한 비즈니스 계층과 API 계층 연동
위에서의 방식은 DI 기능을 사용하지 않았기때문에 MemberController와 MemberService가 강하게 결합(Tight Coupling)되어 있는 상태이다.
Spring의 DI를 사용하면 클래스 간의 결합을 느슨한 결합(Loose Coupling)으로 손쉽게 만들 수 있다.
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
위와같이 코드를 작성한다면 객체를 주입해줄 수 있는데, 여기서 객체를 주입하는 역할을 누가 해주는 걸까?
바로 Spring이다. Spring은 애플리케이션 로드시, ApplicationContext에 있는 MemberService 객체를 주입 해준다.
그런데 Spring에서 DI를 통해서 어떤 객체를 주입 받기 위해서는 주입을 받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 한다.
그렇기에 MemberService 클래스에 @Service 애너테이션을 추가함으로써 MemberService 클래스를 Spring Bean으로 등록해줄 수 있다.
총정리
- 애플리케이션에 있어 Service는 도메인 업무 영역을 구현하는 비즈니스 로직을 처리하는 것을 의미한다.
- Controller 클래스에 @RestController 애너테이션을 추가하면 Spring Bean으로 등록된다.
- Service 클래스에 @Service 애너테이션을 추가하면 Spring Bean으로 등록된다.
- 생성자 방식의 DI는 생성자가 하나일 경우에는 @Autowired 애너테이션을 추가하지 않아도 DI가 적용된다.
매퍼(Mapper)를 이용한 DTO 클래스 ↔ 엔티티(Entity) 클래스 매핑
위에서의 정리된 내용은 두가지의 단점이 있다.
- MemberController의 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 클래스로 변환하는 작업까지 도맡아서 하고 있다.
- 엔티티(Entity) 클래스의 객체를 클라이언트의 응답으로 전송함으로써 계층 간의 역할 분리가 이루어지지 않았다.
아래에서 개선점을 찾아 개선해보자
먼저 첫 번째 문제는 DTO 클래스를 엔티티 클래스로 변환하는 작업을 핸들러 메서드가 하지 않고, 다른 클래스에게 변환해 달라고 요청하면 된다.
그리고 두 번째 문제는 클라이언트의 응답으로 엔티티 클래스를 전송하지 말고, 이 엔티티 클래스의 객체를 DTO 클래스의 객체로 다시 바꿔주면 된다.
결국 DTO 클래스와 엔티티(Entity) 클래스를 서로 변환해주는 매퍼(Mapper)가 필요한 상황이다.
아까와 같이 MemberPostDto 클래스와 Member 클래스가 있고, 이 둘을 변환해보자
@Component // (1)
public class MemberMapper {
// (2) MemberPostDto를 Member로 변환
public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
return new Member(0L,
memberPostDto.getEmail(),
memberPostDto.getName(),
memberPostDto.getPhone());
}
// (3) MemberPatchDto를 Member로 변환
public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
return new Member(memberPatchDto.getMemberId(),
null,
memberPatchDto.getName(),
memberPatchDto.getPhone());
}
// (4) Member를 MemberResponseDto로 변환
public MemberResponseDto memberToMemberResponseDto(Member member) {
return new MemberResponseDto(member.getMemberId(),
member.getEmail(),
member.getName(),
member.getPhone());
}
}
- (1)은 MemberMapper를 Spring의 Bean으로 등록하기 위해서 @Component 애너테이션을 추가한다. 등록된 Bean은 MemberController에서 사용된다.
- (2)는 MemberPostDto 클래스를 Member 클래스로 변환해주는 메서드이다.
- (3)은 MemberPatchDto 클래스를 Member 클래스로 변환해주는 메서드이다.
- (4)는 Member 클래스를 MemberResponseDto 클래스로 변환해주는 메서드이다.
MemberController의 핸들러 메서드에 매퍼(Mapper) 클래스 적용
@RestController
@RequestMapping("/v4/members")
@Validated
public class MemberController {
private final MemberService memberService;
private final MemberMapper mapper;
// (1) MemberMapper DI
public MemberController(MemberService memberService, MemberMapper mapper) {
this.memberService = memberService;
this.mapper = mapper;
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberDto) {
// (2) 매퍼를 이용해서 MemberPostDto를 Member로 변환
Member member = mapper.memberPostDtoToMember(memberDto);
Member response = memberService.createMember(member);
// (3) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(
@PathVariable("member-id") @Positive long memberId,
@Valid @RequestBody MemberPatchDto memberPatchDto) {
memberPatchDto.setMemberId(memberId);
// (4) 매퍼를 이용해서 MemberPatchDto를 Member로 변환
Member response =
memberService.updateMember(mapper.memberPatchDtoToMember(memberPatchDto));
// (5) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(
@PathVariable("member-id") @Positive long memberId) {
Member response = memberService.findMember(memberId);
// (6) 매퍼를 이용해서 Member를 MemberResponseDto로 변환
return new ResponseEntity<>(mapper.memberToMemberResponseDto(response),
HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers() {
List<Member> members = memberService.findMembers();
// (7) 매퍼를 이용해서 List<Member>를 MemberResponseDto로 변환
List<MemberResponseDto> response =
members.stream()
.map(member -> mapper.memberToMemberResponseDto(member))
.collect(Collectors.toList());
return new ResponseEntity<>(response, HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(
@PathVariable("member-id") @Positive long memberId) {
System.out.println("# delete member");
memberService.deleteMember(memberId);
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
- (1)에서는 Spring Bean에 등록된 MemberMapper 객체를 MemberController에서 사용하기 위해 DI로 주입 받고 있다.
- (2)에서는 MemberMapper 클래스를 이용해서 MemberPostDto를 Member로 변환하고 있다.
- (3)에서는 MemberMapper 클래스를 이용해서 Member를 MemberResponseDto로 변환하고 있다.
- (4)에서는 MemberMapper 클래스를 이용해서 MemberPatchDto를 Member로 변환하고 있다.
- (5), (6)에서는 MemberMapper 클래스를 이용해서 Member를 MemberResponseDto로 변환하고 있다.
- (7)의 경우, memberService.findMembers()를 통해 리턴되는 값이 List 이므로 List 안의 Member 객체들을 하나씩 꺼내어서 MemberResponseDto 객체로 변환해주어야 하는데, 이 작업은 Java의 Stream이 해주고 있다.
Mapper 클래스를 사용함으로써 앞에서 살펴본 MemberController의 문제점이 아래와 같이 해결되었다.
- MemberController의 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 클래스로 변환하는 작업까지 도맡아서 하고 있는 문제
- MemberMapper에게 DTO 클래스 → 엔티티(Entity) 클래스로 변환하는 작업을 위임함으로써 MemberController는 더이상 두 클래스의 변환 작업을 신경쓰지 않아도 된다.
- 역할 분리로 인해 코드 자체가 깔끔해졌다.
- 엔티티(Entity) 클래스의 객체를 클라이언트의 응답으로 전송하는 문제
- MemberMapper가 엔티티(Entity)클래스를 DTO 클래스로 변환해주기때문에 서비스 계층에 있는 엔티티(Entity) 클래스를 API 계층에서 직접적으로 사용하는 문제가 해결되었다.
MapStruct를 이용한 Mapper 자동 생성
매퍼(Mapper) 클래스를 사용하면 DTO 클래스와 엔티티(Entity) 클래스의 변환 작업을 깔끔하게 처리할 수 있다.
하지만 어떤 도메인 업무 기능이 늘어날때 마다 개발자가 일일이 수작업으로 매퍼(Mapper) 클래스를 만드는 것은 비효율적이다.
이럴 때를 위한 MapStruct가 매퍼 클래스를 자동으로 구현해줌으로써 개발자의 생산성을 향상 시켜줄 수 있다.
@Mapper(componentModel = "spring")
public interface MemberMapper {
Member memberPostDtoToMember(MemberPostDto memberPostDto);
Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
MemberResponseDto memberToMemberResponseDto(Member member);
}
@Mapper 애너테이션을 추가함으로써 해당 인터페이스는 MapStruct의 매퍼 인터페이스로 정의가 되는 것이다.
그런데 ‘인터페이스는 정의 되었긴한데 이 인터페이스의 구현 클래스는 어디 있지?’란 생각이 들 수 있는데
MapStruct가 MemberMapper 인터페이스를 기반으로 매퍼(Mapper) 구현 클래스를 자동으로 생성해준다.
MemberController의 핸들러 메서드에서 MapStruct 적용
Matstruct 인터페이스가 위치한 인터페이스의 위치만 import문으로 알려주고, Controller URI의 버전 번호만 맞게 변경해주면 된다.
즉 MemberController 클래스의 내부는 손댈 필요가 없다.
DTO 클래스와 엔티티 클래스의 역할 분리가 필요한 이유는?
✔ 계층별 관심사의 분리
우선 서로 사용되는 계층이 다르기 때문에 기능에 대한 관심사가 다르다.
DTO 클래스는 API 계층에서 요청 데이터를 전달 받고, 응답 데이터를 전송하는것이 주 목적인 반면에 Entity 클래스는 서비스 계층에서 데이터 액세스 계층과 연동하여 비즈니스 로직의 결과로 생성된 데이터를 다루는 것이 주 목적이다.
굳이 Java의 Object Mapping 관점으로 생각하지 않아도 하나의 클래스나 메서드 내에서 여러 개의 기능들을 구현하고 있는 것은 객체 지향 코드 관점에서도 리팩토링 대상이 된다는 사실을 기억하면 좋을 것 같습니다.
✔ 코드 구성의 단순화
DTO 클래스에서 사용하는 유효성 검사 애너테이션이 Entity 클래스에서 사용이 된다면 JPA에서 사용하는 애너테이션과 뒤섞인 상태가 되어 유지보수하기 상당히 어려운 코드가 된다.
✔ REST API 스펙의 독립성 확보
데이터 액세스 계층에서 전달 받은 데이터로 채워진 Entity 클래스를 클라이언트의 응답으로 그대로 전달하게되면 원치 않는 데이터까지 클라이언트에게 전송될 수 있다.
대표적인 예로 회원의 로그인 패스워드라고 할 수 있다.
DTO 클래스를 사용하면 회원의 로그인 패스워드 같은 정보를 클라이언트에게 노출하지 않고, 원하는 정보만 제공할 수 있다.
총정리
- Mapper를 사용해서 DTO 클래스와 Entity 클래스 간의 관심사를 분리할 수 있다.
- Mapper를 개발자가 직접 구현하기 보다는 MapStruct 같은 매핑 라이브러리를 사용하는 것이 생산성 측면에서 더 나은 선택이다.
'코드스테이츠' 카테고리의 다른 글
코드스테이츠 45일차 (0) | 2022.06.29 |
---|---|
코드스테이츠 44일차 (0) | 2022.06.28 |
코드스테이츠 42일차 (0) | 2022.06.24 |
코드스테이츠 41일차 (0) | 2022.06.23 |
코드스테이츠 40일차 / Section 2 회고 (2) | 2022.06.22 |