[실습] DTO 적용 실습
DTO 실습 개요
- 이번 실습은 실습용 샘플 프로젝트에 포함되어 있는 CoffeeController 클래스에 DTO 클래스를 적용하는 실습입니다.
- MemberController는 여러분들이 이미 학습했으므로 실습에서 제외합니다.
- OrderController는 이미 DTO 코드들이 포함되어 있습니다.
- 지난 챕터까지 학습했던 구현 코드들이 기본적으로 포함이 되어 있으며, 이를 기반으로 CoffeeController 클래스에 DTO 클래스를 요구 사항에 맞게 적용하면 됩니다.
- 실습에 필요한 클래스는 ‘com.springboot’ 내의 기능별 패키지(member, coffee, order)에 포함되어 있습니다.
- 기능별 패키지(member, coffee, order)에 추가적인 하위 패키지는 필요 없습니다.
- 예) member 패키지 내에 MemberController, MemberPostDto, MemberPatchDto 등이 모두 포함되어 있음.
실습 사전 준비
- 실습용 샘플 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 fork합니다.
- fork한 repository를 여러분의 PC에서 git clone 명령으로 local repository에 복제합니다.
- IntelliJ IDE로 clone 받은 forked local repository 디렉토리의 프로젝트를 Open합니다.
- 아래 실습 요구 사항에 따라 실습을 진행합니다.
- 작성한 코드는 main branch에 작성해 주세요.
- main branch가 아닌 별도의 branch를 생성해서 작업을 했다면 작업이 끝난 후, 반드시 main branch로 merge 해야 합니다.
CoffeeController
공통 제한 사항
- 클라이언트에서 전송하는 Request Body의 데이터는 다음으로 제한합니다.
- korName(한글 커피명): String
- engName(영문 커피명): String
- price(가격): int 또는 Integer
- 예) 3500
- 클라이언트에서 요청으로 전송하는 Request Body와 서버에서 응답으로 전송하는 Response Body는 모두 JSON 형식으로만 전달받을 수 있습니다.
postCoffee() 핸들러 메서드에 DTO 클래스 적용
- 구현 내용
- 커피 정보 등록을 위한 요청 데이터를 하나의 DTO 클래스로 전달받을 수 있도록 코드를 수정하세요.
- 아래 구현 조건에 맞게 DTO 클래스에서 전달받는 요청 데이터에 유효성 검증 로직을 추가하세요.
- 구현 조건
- DTO 클래스 적용
- DTO 클래스 생성 시, 클래스명은 CoffeePostDto로 생성합니다.
- 현재 코드에서 @RequestParam으로 전달받고 있는 요청 데이터는 CoffeePostDto 객체 하나로 모두 전달받아야 합니다.
- 현재 코드에서 Map 객체로 전달하는 응답 데이터는 CoffeePostDto 객체로 전달해야 합니다.
- DTO 클래스에 유효성 검증 적용
- korName(한글 커피명)
- Request Body에 포함되는 필수 항목입니다(mandatory).
- 공백만으로 구성되지 않아야 합니다.
- 예) “”, 또는 “ ” 가 아니어야 함.
- engName(영문 커피명)
- Request Body에 포함되는 필수 항목입니다(mandatory).
- 공백만으로 구성되지 않아야 합니다.
- 예) “”, 또는 “ ” 가 아니어야 함.
- 영문(대소문자 모두 가능)만 허용합니다.
- 워드 사이에 한 칸의 공백(스페이스)만 포함될 수 있습니다.
- 예)
- “Cafe Latte” (ㅇ)
- “Ca fe Latte” (ㅇ)
- “Cafe Latte” (X)
- “ Cafe Latte” (X)
- “ Cafe Latte ” (X)
- “ Cafe Latte ” (X)
- 예)
- price(가격)
- Request Body에 포함되는 필수 항목입니다(mandatory).
- 100 이상 50000 이하의 숫자만 허용합니다.
- korName(한글 커피명)
- DTO 클래스 적용
patchCoffee() 핸들러 메서드에 DTO 클래스 적용
- 구현 내용
- 커피 정보 수정을 위한 요청 데이터를 하나의 DTO 클래스로 전달받을 수 있도록 코드를 수정하세요.
- 아래 구현 조건에 맞게 DTO 클래스에서 전달받는 요청 데이터에 유효성 검증 로직을 추가하세요.
- 구현 조건
- DTO 클래스 적용
- DTO 클래스 생성 시, 클래스명은 CoffeePatchDto로 생성합니다.
- 현재 코드에서 @RequestParam으로 전달받는 있는 요청 데이터는 CoffeePatchDto 객체 하나로 모두 전달받아야 합니다.
- 현재 코드에서 Map 객체로 전달하는 응답 데이터는 CoffeePatchDto 객체로 전달해야 합니다.
- DTO 클래스에 유효성 검증 적용
- korName(한글 커피명)
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- 즉, Request Body에 포함되지 않으면 유효성 검증을 하지 않거나 항상 검증을 통과해야 합니다.
- Request Body에 포함되는 경우
- 공백만으로 구성되지 않아야 합니다.
- 예)
- “”(X)
- “ ” (X)
- 예)
- 공백만으로 구성되지 않아야 합니다.
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- engName(영문 커피명)
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- 즉, Request Body에 포함되지 않으면 유효성 검증을 하지 않거나 항상 검증을 통과해야 합니다.
- Request Body에 포함되는 경우
- 공백만으로 구성되지 않아야 합니다.
- 예) “”, 또는 “ ” 가 아니어야 함.
- 영문(대소문자 모두 가능)만 허용합니다.
- 워드 사이에 한 칸의 공백(스페이스)만 포함될 수 있습니다.
- 예)
- “Cafe Latte” (ㅇ)
- “Ca fe Latte” (ㅇ)
- “Cafe Latte” (X)
- “ Cafe Latte” (X)
- “ Cafe Latte ” (X)
- “ Cafe Latte ” (X)
- 예)
- 공백만으로 구성되지 않아야 합니다.
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- price(가격)
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- 즉, Request Body에 포함되지 않으면 유효성 검증을 하지 않거나 항상 검증을 통과해야 합니다.
- Request Body에 포함되는 경우
- 100 이상 50000 이하의 숫자만 허용합니다.
- Request Body에 선택적으로 포함될 수 있습니다(포함될 수도 있고 그렇지 않을 수도 있다).
- korName(한글 커피명)
- @PathVariable("coffee-id") long coffeeId에 유효성 검증 적용
- 양수만 허용해야 합니다.
- DTO 클래스 적용
CoffeeController
package com.springboot.coffee;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.Min;
@RestController
@RequestMapping("/v1/coffees")
@Validated
public class CoffeeController {
// 1. DTO 클래스 및 유효성 검증을 적용하세요.
@PostMapping
public ResponseEntity postCoffee(@Valid @RequestBody CoffeePostDto coffeePostDto) {
return new ResponseEntity<>(coffeePostDto, HttpStatus.CREATED);
}
// 2. DTO 클래스 및 유효성 검증을 적용하세요.
@PatchMapping("/{coffee-id}")
public ResponseEntity patchCoffee(@PathVariable("coffee-id") @Min(1) long coffeeId,
@Valid @RequestBody CoffeePatchDto coffeePatchDto) {
coffeePatchDto.setCoffeeId(coffeeId);
return new ResponseEntity<>(coffeePatchDto, HttpStatus.OK);
}
@GetMapping("/{coffee-id}")
public ResponseEntity getCoffee(@PathVariable("coffee-id") long coffeeId) {
System.out.println("# coffeeId: " + coffeeId);
// not implementation
return new ResponseEntity<>(HttpStatus.OK);
}
@GetMapping
public ResponseEntity getCoffees() {
System.out.println("# get Coffees");
// not implementation
return new ResponseEntity<>(HttpStatus.OK);
}
@DeleteMapping("/{coffee-id}")
public ResponseEntity deleteCoffee(@PathVariable("coffee-id") long coffeeId) {
// No need business logic
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
CoffeePostDto
package com.springboot.coffee;
import lombok.Getter;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
@Getter
public class CoffeePostDto {
@NotBlank //null, "", " ", " " -> false(null 허용X)
@Pattern(regexp = "^[가-힣]+$", message = "한글명은 한글을 입력해야 합니다.(띄어쓰기 허용X)")
private String korName;
@NotBlank
@Pattern(regexp = "^([a-zA-Z])(\\s?[a-zA-Z])*$", message = "영문명은 워드 사이에 한 칸의 공백만 포함될 수 있습니다.")
private String engName;
@NotZero(min = 100, max = 50000)
private int price;
}
CoffeePatchDto
package com.springboot.coffee;
import com.springboot.member.NotSpace;
import lombok.Getter;
import lombok.Setter;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.Pattern;
@Getter
public class CoffeePatchDto {
private long coffeeId;
@Pattern(regexp = "^([가-힣])(\\s?[가-힣])*$", message = "한글명은 한 칸의 공백만 포함될 수 있습니다.") //null 검증X null 통과
private String korName;
@Pattern(regexp = "^([a-zA-Z])(\\s?[a-zA-Z]+)*$", message = "영문명은 워드 사이에 한 칸의 공백만 포함될 수 있습니다.")
private String engName;
@NotZero(min = 100, max = 50000, message = "100 이상 50000 이하의 숫자만 허용합니다.")
private int price;
public void setCoffeeId(long coffeeId) {
this.coffeeId = coffeeId;
}
}
-Custom Annotation : @NotZero
NotZero
package com.springboot.coffee;
import javax.validation.Constraint;
import javax.validation.Payload;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Constraint(validatedBy = {NotZeroValidator.class})
public @interface NotZero {
int min() default Integer.MAX_VALUE;
int max() default Integer.MIN_VALUE;
String message() default "0 이외의 값이 들어와야 합니다.";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
}
NotZeroValidator
package com.springboot.coffee;
import com.springboot.member.NotSpace;
import org.springframework.util.StringUtils;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
public class NotZeroValidator implements ConstraintValidator<NotZero, Integer> {
private int min;
private int max;
@Override
public void initialize(NotZero constraintAnnotation) {
ConstraintValidator.super.initialize(constraintAnnotation);
this.min = constraintAnnotation.min();
this.max = constraintAnnotation.max();
}
@Override
public boolean isValid(Integer value, ConstraintValidatorContext context) {
if (value == null || value == 0) {
return true;
} else if (value < min || value > max){
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate("가격은 " + min + "이상" + max + "이하여야 합니다.")
.addConstraintViolation();
return false;
} else {
return true;
}
}
}
[Spring MVC] 서비스 계층
API 계층의 학습을 끝내고, 서비스 계층까지 내려온 여러분들께 응원의 마음을 전합니다.
이제 여러분들은 Controller의 핸들러 메서드를 통해서 클라이언트의 요청을 어떻게 전달받을 수 있는지 파악했을 거라고 생각합니다.
이번 시간부터는 Controller가 전달받은 클라이언트의 요청을 직접적으로 처리하는 서비스 계층에 대한 학습을 진행해 보도록 하겠습니다.
서비스 계층은 웹 애플리케이션의 비즈니스 요구 사항을 처리하는 핵심 계층입니다.
애플리케이션의 비즈니스 요구 사항이 얼마나 복잡하냐에 따라서 비즈니스 로직의 복잡도 역시 달라질 수 있습니다.
Spring은 개발자가 핵심 비즈니스 로직 개발에 집중할 수 있도록 비즈니스 로직 이외의 작업들을 대신해 줍니다.
이번 시간부터 Spring을 이용해 어떻게 비즈니스 계층을 처리할 수 있는지 천천히 알아보도록 하겠습니다.
[Spring MVC] 서비스 계층 학습을 위한 사전 준비 사항
API 계층 학습을 통해 여러분들이 Controller의 구현에는 어느 정도 익숙해졌을 거라고 생각합니다.
따라서 서비스 계층 학습을 원활하게 진행하기 위해 이번 유닛에서는 API 계층 유닛에서 구현해 본 Controller의 구현 코드들이 포함된 템플릿 프로젝트를 사용하도록 하겠습니다.
- 서비스 계층 학습용 템플릿 프로젝트 복제
- 아래 github 링크에서 실습용 repository를 clone합니다.
- IntelliJ IDE로 clone 받은 local repository 디렉토리의 프로젝트를 Open합니다.
- 학습을 진행하며 학습 내용에 따라 예제 코드를 타이핑해 봅니다.
API 계층 유닛에서 학습한 샘플 애플리케이션의 Controller 구현이 아직 어렵게 느껴지는 분들은 서비스 계층의 학습을 진행하기 전에 API 계층 유닛에 있는 Controller 코드들을 한번 더 따라서 타이핑해 보고 난 후에 서비스 계층의 학습을 진행하길 권장합니다.
[Spring MVC] 서비스 계층 학습 참고용 레퍼런스 코드
이번 유닛에서 학습한 예제 코드는 아래 github에서 확인할 수 있습니다.
챕터에서 사용한 예제 코드는 챕터에 있는 코드들을 직접 타이핑해 본 후, 학습 내용을 조금 더 구체적으로 이해하기 위한 용도로만 활용해 주세요.
- 서비스 계층 유닛에 사용한 예제 코드
복사/붙여넣기 해서 돌아가는 코드는 의미가 없습니다.
꼭 여러분 스스로 타이핑해 본 후, 여러분의 지식으로 만들길 바랍니다.
학습 목표
- DI(Dependency Injection)를 사용해서 API 계층과 서비스 계층을 연동할 수 있다.
- API 계층의 DTO 클래스와 서비스 계층의 엔티티(Entity) 클래스를 매핑할 수 있다.
Chapter - 서비스 계층에서의 DI
이번 챕터에서는 우리가 이전 챕터까지 작성한 API 계층에 서비스 계층을 연동해 보도록 하겠습니다.
서비스 계층은 API 계층에서 전달받은 클라이언트의 요청 데이터를 기반으로 실질적인 비즈니스 요구사항을 처리하는 계층입니다.
이번 시간부터 Spring의 DI(Dependency Injection)를 이용해서 API 계층과 비즈니스 계층을 연동하고, API 계층에서 전달받은 DTO 객체를 비즈니스 계층의 도메인 엔티티(Entity) 객체로 변환해서 전달하는 방법을 살펴보도록 하겠습니다.
이번 챕터부터는 Controller에 많은 개선이 일어나기 때문에 Controller의 요청 URI가 “/v1/xxxx”에서 “/v2/xxxx”와 같이 지속적으로 API 버전이 바뀔 수 있다는 것을 염두에 두고 예제 코드를 살펴보길 바랍니다.
서비스 계층까지 여러분들이 별도의 데이터베이스 연동에 대해서 고민할 필요는 없습니다.
다만, 서비스 계층에서 처리된 데이터를 API 계층을 거쳐 클라이언트에게 전달되는 과정을 여러분들이 직접 눈으로 확인하는 것이 애플리케이션의 큰 그림을 그리는데 도움이 된다고 생각하기에 서비스 계층에서 샘플 응답 데이터를 클라이언트에 전송하는 로직을 추가했습니다.
데이터베이스 연동은 데이터 액세스 계층의 학습까지 조금만 기다려주세요!
학습 목표
- Spring의 DI 기능을 이용해서 API 계층과 서비스 계층을 연동할 수 있다.
- API 계층에서 전달받은 DTO 객체를 서비스 계층의 도메인 엔티티(Entity) 객체로 변환할 수 있다.
[기본] DI를 통한 서비스 계층 ↔ API 계층 연동
API 계층과 서비스 계층을 연동한다는 의미는 API 계층에서 구현한 Controller 클래스가 서비스 계층의 Service 클래스와 메서드 호출을 통해 상호 작용한다는 것을 의미합니다.
Service의 의미
애플리케이션에 있어 Service의 의미는 도메인 업무 영역을 구현하는 비즈니스 로직과 관련이 있습니다.
애플리케이션의 비즈니스 로직을 처리하기 위한 서비스 계층은 대부분 도메인 모델을 포함하고 있습니다.
도메인 모델은 다시 빈약한 도메인 모델(anemic domain model)과 풍부한 도메인 모델(rich domain model)로 구분할 수 있는데, 이러한 도메인 모델은 DDD(도메인 주도 설계, Domain Driven Design)와 관련이 깊습니다.
DDD는 현업에서 클래스 설계 경험이 풍부해야 제대로 사용할 수 있는 영역이기 때문에 백엔드 개발자로 막 입문하는 분들에게 학습을 권장할 만한 영역은 아니라고 생각합니다.
다만, 이후 데이터 액세스 계층 유닛에서 학습하게 될 Spring Data JDBC의 경우, DDD와 밀접한 관련이 있기 때문에 Spring Data JDBC라는 기술을 기본적으로 사용할 수 있는 범위 내에서 DDD를 언급할 예정입니다.
DDD에 대한 더 구체적인 내용은 언젠가 여러분들이 미래에 경험 많은 개발자로 성장하게 되면 그때 제대로 학습해 보길 권장드립니다.
어쨌든 우리 코스에서 Service라는 용어를 접하게 된다면 지금은 단순히 비즈니스 로직을 처리하는 Service 클래스라고 생각해도 무방합니다.
비즈니스 로직을 처리하는 Service 클래스 작성
API 계층에서 클라이언트의 요구 사항을 잘 만족하는 Controller 클래스를 구현해 두었다면 이 Controller를 기반으로 해당 Controller와 연동하는 Service 클래스의 큰 틀을 만들 수 있습니다.
템플릿 프로젝트에 미리 작성되어 있는 MemberController의 코드를 보면서 MemberService 클래스의 큰 틀을 어떻게 만들지 생각해 봅시다.
@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) {
memberPatchDto.setMemberId(memberId);
// No need Business logic
return new ResponseEntity<>(memberPatchDto, HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(
@PathVariable("member-id") @Positive long memberId) {
System.out.println("# memberId: " + memberId);
// not implementation
return new ResponseEntity<>(HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers() {
System.out.println("# get Members");
// not implementation
return new ResponseEntity<>(HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(
@PathVariable("member-id") @Positive long memberId) {
System.out.println("# memberId: " + memberId);
// No need business logic
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
코드 3-40은 템플릿 프로젝트에 미리 작성되어 있는 MemberController 클래스입니다.
MemberController에는 총 다섯 개의 핸들러 메서드가 있는데 이 핸들러 메서드의 역할을 요약하면 다음과 같습니다.
- postMember() : 1명의 회원 등록을 위한 요청을 전달받는다.
- patchMember() : 1명의 회원 수정을 위한 요청을 전달받는다.
- getMember() : 1명의 회원 정보 조회를 위한 요청을 전달받는다.
- getMembers() : N명의 회원 정보 조회를 위한 요청을 전달받는다.
- deleteMember() : 1명의 회원 정보 삭제를 위한 요청을 전달받는다.
이 다섯 개의 핸들러 메서드가 전달받은 요청을 처리하는 메서드를 MemberService 클래스에 작성하면 됩니다.
MemberService 클래스와 Member 클래스의 기본 구조 작성
MemberController의 핸들러 메서드 정보를 기반으로 MemberService 클래스의 큰 틀만 먼저 작성을 해 봅시다.
✔ MemberService 클래스 기본 구조 작성
package com.spring.member;
import java.util.List;
public class MemberService {
public Member createMember(Member member) {
return null;
}
public Member updateMember(Member member) {
return null;
}
public Member findMember(long memberId) {
return null;
}
public List<Member> findMembers() {
return null;
}
public void deleteMember(long memberId) {
}
}
코드 3-41에서는 MemberController가 전달받은 요청을 실제로 처리하는 MemberService 클래스의 기본 구조만 작성했습니다.
코드를 보면 MemberController 클래스의 핸들러 메서드와 1대1로 매치가 된다는 것을 알 수 있습니다.
그런데 createMember() 메서드와 updateMember() 메서드의 파라미터와 리턴값에 Member라는 타입을 사용했습니다.
✔ Member 클래스의 역할
우리가 MemberController의 핸들러 메서드에서 클라이언트의 요청 데이터(Request Body)를 전달받을 때 MemberPostDto나 MemberPatchDto 같은 DTO 클래스를 사용했습니다.
즉, DTO가 API 계층에서 클라이언트의 Request Body를 전달받고 클라이언트에게 되돌려 줄 응답 데이터를 담는 역할을 한다면, Member 클래스는 API 계층에서 전달받은 요청 데이터를 기반으로 서비스 계층에서 비즈니스 로직을 처리하기 위해 필요한 데이터를 전달받고, 비즈니스 로직을 처리한 후에는 결과 값을 다시 API 계층으로 리턴해주는 역할을 합니다.
✔ Member 클래스 기본 구조 작성
public class Member {
}
Member 클래스 역시 기본 구조만 작성했으므로 현재까지는 비어 있는 상태입니다.
Member 클래스처럼 서비스 계층에서 데이터 액세스 계층과 연동하면서 비즈니스 로직을 처리하기 위해 필요한 데이터를 담는 역할을 하는 클래스를 도메인 엔티티(Entity) 클래스라고 부릅니다.
물론 우리는 아직 데이터 액세스 계층과의 연동은 하지 않고 있습니다.
Member 클래스와 MemberService 클래스 구현
이제 기본 구조는 완성이 되었으니까 구체적인 로직을 채워 넣을 시간입니다.
먼저 Member 클래스부터 작성해 봅시다.
✔ Member 클래스 구현
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
public class Member {
private long memberId;
private String email;
private String name;
private String phone;
}
코드 3-43은 Member 클래스를 구현한 모습입니다.
Member 클래스에는 우리가 API 계층에서 사용했던 MemberPostDto 클래스와 MemberPatchDto 클래스에서 사용한 멤버 변수들이 모두 포함되어 있는 것을 알 수 있습니다.
그리고 @Getter, @Setter , @NoArgsConstructor , @AllArgsConstructor라는 애너테이션이 보입니다.
- @Getter, @Setter 애너테이션은 lombok이라는 라이브러리에서 제공하는 애너테이션으로서 우리가 DTO 클래스를 작성하면서 각 멤버 변수에 해당하는 getter/setter 메서드를 일일이 작성하는 수고를 덜어주는 편리한 유틸리티성 라이브러리입니다.
- @AllArgsConstructor 애너테이션은 현재 Member 클래스에 추가된 모든 멤버 변수를 파라미터로 갖는 Member 생성자를 자동으로 생성해 줍니다.
- @NoArgsConstructor는 파라미터가 없는 기본 생성자를 자동으로 생성해 줍니다.
Member 클래스 코드에는 직접적으로 보이지 않지만 Member 클래스에 내부적으로 getter/setter 메서드가 작성되어 있다고 생각하면 되겠습니다.
이후 예제 코드에서는 lombok을 적극적으로 활용할 예정이며, 필요한 애너테이션들은 학습을 진행하면서 그때그때 설명을 하도록 하겠습니다.
lombok에 대해서 더 알아보고 싶다면 아래 [심화 학습]을 참고하세요.
이제 MemberService 클래스를 구현해 봅시다.
✔ MemberService 클래스 구현
public class MemberService {
public Member createMember(Member member) {
// TODO should business logic
// TODO member 객체는 나중에 DB에 저장 후, 되돌려 받는 것으로 변경 필요.
Member createdMember = member;
return createdMember;
}
public Member updateMember(Member member) {
// TODO should business logic
// member 객체는 나중에 DB에 업데이트 후, 되돌려 받는 것으로 변경 필요.
Member updatedMember = member;
return updatedMember;
}
public Member findMember(long memberId) {
// TODO should business logic
// TODO member 객체는 나중에 DB에서 조회 하는 것으로 변경 필요.
Member member =
new Member(memberId, "lucky@gmail.com", "홍길동", "010-1234-5678");
return member;
}
public List<Member> findMembers() {
// TODO should business logic
// TODO member 객체는 나중에 DB에서 조회하는 것으로 변경 필요.
List<Member> members = List.of(
new Member(1, "lucky@gmail.com", "김러키", "010-1234-5678"),
new Member(2, "vanilla@gmail.com", "바닐라", "010-1111-2222")
);
return members;
}
public void deleteMember(long memberId) {
// TODO should business logic
}
}
코드 3-41에서 MemberSerivce 클래스에 비어 있던 메서들을 [코드 3-44]처럼 채워 넣었습니다.
코드를 보면 어려울 게 별로 없습니다.
다만, 우리가 아직 요청 데이터를 DB에 저장하거나 DB에서 데이터를 조회하는 데이터 액세스 계층을 배우지 않았기 때문에 crateMember()와 updateMember() 메서드의 경우, 단순히 파라미터로 전달받은 Member 객체를 그대로 리턴하고 있고, findMember()와 findMembers() 메서드의 경우 Stub 데이터를 넘겨주도록 했다는 사실을 기억하기 바랍니다.
이번 챕터에서 중요한 건 서비스 계층의 서비스 클래스와 API 계층의 Controller 클래스를 어떻게 연결하느냐가 중요한 학습 포인트이기 때문에 다른 건 신경 쓰지 말고, 이 부분에만 집중을 해주기 바랍니다.
여러 개의 계층을 한꺼번에 학습하게 되면 배워야 할 많은 지식 때문에 여러분들의 머릿속이 혼란스러워질 가능성이 더 크기 때문에 하나의 계층에 대한 학습에만 집중해 주길 바라겠습니다.
지금 작성한 코드들은 데이터 액세스 계층을 학습하면서 모두 개선해 나갈 예정이니 조금만 기다려주세요!
자, 이제 기다리고 기다리던 API 계층의 MemberController 클래스와 서비스 계층의 MemberService 클래스를 연동해 볼 시간입니다.
DI(Dependency Injection)없이 비즈니스 계층과 API 계층 연동
어떤 클래스가 다른 클래스의 기능을 사용하는 방법은 무엇일까요?
여러분들이 모두 알고 있는 답일 거라고 생각합니다.
맞습니다. 바로 객체를 생성해서 해당 객체로 클래스의 메서드를 호출해서 사용하면 됩니다.
객체의 생성은?
여러분들이 잘 알고 있는 new 키워드로 해당 클래스의 객체를 생성하면 됩니다.
그럼 MemberController에서 MemberService의 기능을 사용하도록 MemberController를 수정해 보도록 하겠습니다.
@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);
}
}
MemberService 클래스의 기능을 사용하도록 변경이 되었기 때문에 \[코드 3-40]의 MemberController(V1) 보다 조금 복잡해 보이기는 하지만 변경된 부분에 대한 설명을 듣고 난 후에는 복잡한 로직이 아니라는 생각을 할 거라 생각합니다. ^^
- (1)은 MemberService 클래스를 사용하기 위해 MemberService 클래스의 객체를 생성하고 있습니다. 여러분들이 잘 알고 있다시피 new 키워드를 사용해서 객체를 생성하면 됩니다.
- (2)는 클라이언트에서 전달받은 DTO 클래스의 정보를 MemberService의 createMember() 메서드의 파라미터로 전달하기 위해 MemberPostDto 클래스의 정보를 Member 클래스에 채워 넣고 있습니다. ❓ 그냥 MemberPostDto 객체를 서비스 계층에 전달하면 깔끔하지 않을까라는 의문이 들 수 있습니다. 이 부분은 아래의 [매퍼(Mapper)를 이용한 DTO 객체 ↔ 엔티티(Entity) 객체 매핑] 챕터에서 자세히 설명하겠습니다.
- (3)은 회원 정보 등록을 위해 MemberService 클래스의 createMember() 메서드를 호출합니다. ⭐ 서비스 계층과의 연결 지점입니다.
- (4)는 클라이언트에서 전달받은 DTO 클래스의 정보를 MemberService의 updateMember() 메서드의 파라미터로 전달하기 위해 MemberPatchDto 클래스의 정보를 Member 클래스에 채워 넣고 있습니다.
- (5)는 회원 정보 수정을 위해 MemberService 클래스의 updateMember() 메서드를 호출합니다. ⭐ 서비스 계층과의 연결 지점입니다.
- (6)은 한 명의 회원 정보 조회를 위해 MemberService 클래스의 findMember() 메서드를 호출합니다. 특정 회원의 정보를 조회하는 기준인 memberId를 파라미터로 넘겨줍니다. ⭐ 서비스 계층과의 연결 지점입니다.
- (7)은 모든 회원의 정보를 조회하기 위해 MemberService 클래스의 findMembers() 메서드를 호출합니다.
- (8)은 한 명의 회원 정보를 삭제하기 위해 MemberService 클래스의 deleteMember() 메서드를 호출합니다. 특정 회원의 정보를 삭제하는 기준인 memberId를 파라미터로 넘겨줍니다.
DI를 적용한 비즈니스 계층과 API 계층 연동
그런데 코드 3-45는 Spring에서 지원하는 DI 기능을 사용하지 않았기 때문에 MemberController와 MemberService가 강하게 결합(Tight Coupling)되어 있는 상태입니다.
Spring의 DI를 사용하면 클래스 간의 결합을 느슨한 결합(Loose Coupling)으로 손쉽게 만들 수 있습니다.
그럼 Spring의 DI를 사용하도록 MemberController를 바꿔볼까요?
클래스 간에 DI가 필요한 이유는IoC(Inversioin of Control)/DI(Dependency Injection) 학습 내용을 참고하세요
@RestController
@RequestMapping("/v3/members")
@Validated
public class MemberController {
private final MemberService memberService;
// (1) MemberController의 변경 포인트
public MemberController(MemberService memberService) {
this.memberService = memberService;
}
...
...
}
코드 3-46은 MemberController에 Spring의 DI 기능을 적용했습니다.
코드 3-45의 MemberController(V2)에서는 MemberController의 생성자 내부에서 new 키워드를 사용하여 MemberService의 객체를 생성했지만 코드 3-46에서는 Spring의 DI 기능을 이용해서 MemberController 생성자 파라미터로 MemberService의 객체를 주입(Injection) 받았습니다.
누가 이 MemberService 객체를 주입해 주었을까요?
네, 바로 Spring이 애플리케이션 로드 시, ApplicationContext에 있는 MemberService 객체를 주입해 줍니다.
그런데 Spring에서 DI를 통해서 어떤 객체를 주입받기 위해서는 주입을 받는 클래스와 주입 대상 클래스 모두 Spring Bean이어야 합니다.
MemberController의 경우, @RestController 애너테이션이 추가되어 있으므로 Spring Bean입니다.
그렇다면 MemberService는 어떨까요?
@Service
public class MemberService {
// 내부 코드는 [코드 3-45]와 동일.
...
...
}
MemberService 클래스에 코드 3-47과 같이 @Service 애너테이션을 추가함으로써 MemberService 클래스는 Spring Bean이 됩니다.
일반적으로 생성자가 하나일 경우에는 @Autowired 애너테이션을 붙이지 않아도 Spring이 알아서 DI를 적용합니다.
하지만, 생성자가 하나 이상일 경우, DI를 적용하기 위한 생성자에 반드시 @Autowired 애너테이션을 붙여야 한다는 사실을 기억하기 바랍니다.
MemberController(V3)의 문제점
서비스 클래스를 사용하도록 변경된 MemberController는 잘 동작합니다.
그런데 MemberController는 두 가지 정도 개선이 필요한 부분이 있습니다.
- 첫 번째는 Controller 핸들러 메서드의 책임과 역할에 관한 문제입니다. 핸들러 메서드의 역할은 클라이언트로부터 전달받은 요청 데이터를 Service 클래스로 전달하고, 응답 데이터를 클라이언트로 다시 전송해 주는 단순한 역할만을 하는 것이 좋습니다.
그런데, 현재의 MemberController에서는 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 객체로 변환하는 작업까지 도맡아서 하고 있습니다.
- 두 번째는 Service 계층에서 사용되는 엔티티(Entity) 객체를 클라이언트의 응답으로 전송하고 있는 것입니다. DTO 클래스는 API 계층에서만 데이터를 처리하는 역할을 하고, 엔티티(Entity) 클래스는 서비스 계층에서만 데이터를 처리하는 역할을 해야 하는데 엔티티(Entity) 클래스의 객체를 클라이언트의 응답으로 전송함으로써 계층 간의 역할 분리가 이루어지지 않았습니다.
다음 챕터에서 위 두 가지 문제점을 개선해 보도록 하겠습니다.
핵심 포인트
- 애플리케이션에 있어 Service는 도메인 업무 영역을 구현하는 비즈니스 로직을 처리하는 것을 의미한다.
- Controller 클래스에 @RestController 애너테이션을 추가하면 Spring Bean으로 등록된다.
- Service 클래스에 @Service 애너테이션을 추가하면 Spring Bean으로 등록된다.
- 생성자 방식의 DI는 생성자가 하나일 경우에는 @Autowired 애너테이션을 추가하지 않아도 DI가 적용된다.
심화 학습
- DDD(Domain Driven Design)에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- lombok에 대해서 더 알아보고 싶다면 아래 링크를 참고하세요.
- lombok의 경우 현업에서도 굉장히 많이 사용되는 라이브러리이기 때문에 자주 사용하는 @Getter, @Setter, @AllArgsConstructor, @NoArgsConstructor, @Data, @ToString 같은 애너테이션의 사용법은 간단하게라도 알아두면 편리하다는 사실 기억하세요!
- lombok 관련 자료
[기본] 매퍼(Mapper)를 이용한 DTO 클래스 ↔ 엔티티(Entity) 클래스 매핑
이 전 챕터에서 아직 해결되지 않는 MemberController(V3)의 문제점은 다음과 같습니다.
- MemberController의 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 클래스로 변환하는 작업까지 도맡아서 하고 있다.
- 엔티티(Entity) 클래스의 객체를 클라이언트의 응답으로 전송함으로써 계층 간의 역할 분리가 이루어지지 않았다.
위 두 가지 문제를 해결하려면 어떻게 해야 할까요?
먼저 첫 번째 문제는 DTO 클래스를 엔티티 클래스로 변환하는 작업을 핸들러 메서드가 하지 않고, 다른 클래스에게 변환해 달라고 요청하면 됩니다.
그리고 두 번째 문제는 클라이언트의 응답으로 엔티티 클래스를 전송하지 말고, 이 엔티티 클래스의 객체를 DTO 클래스의 객체로 다시 바꿔주면 됩니다.
결국 DTO 클래스와 엔티티(Entity) 클래스를 서로 변환해 주는 누군가 즉, **매퍼(Mapper)**가 필요한 상황입니다.
Mapper 클래스 구현
그럼 먼저 MemberPostDto 클래스와 Member 클래스를 서로 변환해 주는 매퍼(Mapper) 클래스를 구현해 봅시다.
@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());
}
}
코드 3-48은 MemberController에서 사용하는 DTO 클래스와 Member 간에 서로 타입을 변환해 주는 매퍼(Mapper) 클래스입니다.
✔ MemberMapper 코드 설명
- (1)은 MemberMapper를 Spring의 Bean으로 등록하기 위해서 @Component 애너테이션을 추가했습니다. 등록된 Bean은 MemberController에서 사용됩니다.
- (2)는 MemberPostDto 클래스를 Member 클래스로 변환해 주는 메서드입니다.
- (3)은 MemberPatchDto 클래스를 Member 클래스로 변환해 주는 메서드입니다.
- (4)는 Member 클래스를 MemberResponseDto 클래스로 변환해 주는 메서드입니다.
✔ MemberResponseDto 클래스 생성
MemberResponseDto 클래스는 응답 데이터의 역할을 해주는 DTO 클래스입니다.
MemberResponseDto 클래스의 코드는 [코드 3-49]와 같습니다.
@Getter
@AllArgsConstructor
public class MemberResponseDto {
private long memberId;
private String email;
private String name;
private String phone;
}
MemberController의 핸들러 메서드에 매퍼(Mapper) 클래스 적용
매퍼(Mapper) 클래스가 완성되었으니 이제 이 매퍼(Mapper) 클래스를 MemberController에 적용해 보도록 하겠습니다.
@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);
}
}
코드 3-50에서는 MemberController(V4) 클래스에 매퍼(Mapper) 클래스를 적용했습니다.
✔ MemberController(V4) 코드 설명
- (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<Member> 이므로 List 안의 Member 객체들을 하나씩 꺼내어서 MemberResponseDto 객체로 변환해주어야 하는데, 이 작업은 Java의 Stream이 해주고 있습니다.
Mapper 클래스를 사용함으로써 앞에서 살펴본 MemberController(V3)의 문제점이 아래와 같이 해결되었습니다.
- MemberController의 핸들러 메서드가 DTO 클래스를 엔티티(Entity) 클래스로 변환하는 작업까지 도맡아서 하고 있는 문제
- MemberMapper에게 DTO 클래스 → 엔티티(Entity) 클래스로 변환하는 작업을 위임함으로써 MemberController는 더 이상 두 클래스의 변환 작업을 신경 쓰지 않아도 됩니다.
- 역할 분리로 인해 코드 자체가 깔끔해졌습니다.
- 엔티티(Entity) 클래스의 객체를 클라이언트의 응답으로 전송하는 문제
- MemberMapper가 엔티티(Entity) 클래스를 DTO 클래스로 변환해 주기 때문에 서비스 계층에 있는 엔티티(Entity) 클래스를 API 계층에서 직접적으로 사용하는 문제가 해결되었습니다.
MapStruct를 이용한 Mapper 자동 생성
이처럼 매퍼(Mapper) 클래스를 사용하면 DTO 클래스와 엔티티(Entity) 클래스의 변환 작업을 깔끔하게 처리할 수 있습니다.
그런데 지금은 회원 정보를 처리하는 DTO 클래스에 해당하는 매퍼(Mapper) 클래스를 하나만 만들기 때문에 직접 매퍼(Mapper) 클래스를 만드는 작업이 힘들지 않습니다.
하지만 우리는 Coffee 정보를 처리하는 DTO 클래스에 해당하는 매퍼(Mapper) 클래스와 커피 주문 정보를 처리하는 DTO 클래스에 해당하는 매퍼 클래스를 추가적으로 더 구현을 해야 합니다.
이처럼 어떤 도메인 업무 기능이 늘어날 때마다 개발자가 일일이 수작업으로 매퍼(Mapper) 클래스를 만드는 것은 비효율적입니다.
이 매퍼(Mapper) 클래스를 누군가가 자동으로 만들어주면 굉장히 효율적일 것 같습니다.
MapStruct가 매퍼 클래스를 자동으로 구현해 줌으로써 개발자의 생산성을 향상해줄 수 있습니다.
MapStruct는 DTO 클래스처럼 Java Bean 규약을 지키는 객체들 간의 변환 기능을 제공하는 매퍼(Mapper) 구현 클래스를 자동으로 생성해 주는 코드 자동 생성기입니다.
MapStruct에 대해서 더 알아보고 싶다면 아래의 [심화 학습]을 참고해 주세요.
그럼 이제 MapStruct를 통해 매퍼 클래스를 자동 생성하여 앞에서 수작업으로 구현한 MemberMapper 클래스를 대체해 보도록 하겠습니다.
✔ MapStruct 의존 라이브러리 설정
MapStruct 기반의 매퍼(Mapper)를 자동 생성하기 위해서는 코드 3-51과 같이 MapStruct 관련 의존 라이브러리를 Gradle의 build.gradle 파일에 아래와 같이 추가해야 합니다.
dependencies {
...
...
implementation 'org.mapstruct:mapstruct:1.4.2.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.4.2.Final'
}
build.gradle 파일에 의존 라이브러리를 추가한 후에는 아래와 같이 Gradle 프로젝트를 reload해야 합니다.
- IntelliJ IDE 우측의 [Gradle] 탭을 클릭한다.
- IntelliJ 프로젝트명 위에서 마우스 우클릭
- 컨텍스트 메뉴에서 [Reload Gradle Project] 또는 [Refresh Gradle Dependencies]를 클릭해서 프로젝트 또는 의존 라이브러리를 갱신합니다.
✔ MapStruct 기반의 매퍼(Mapper) 인터페이스 정의
이제 MapStruct를 사용할 준비가 되었습니다.
DTO 클래스를 변환하는 매퍼(Mapper)를 자동으로 생성하기 위해서는 매퍼(Mapper) 인터페이스를 먼저 정의해야 합니다.
MemberController에서 사용하는 DTO 클래스(MemberPostDto, MemberPatchDto, MemberResponseDto)와 Member 클래스 간의 변환 기능을 제공하는 매퍼(Mapper) 인터페이스를 정의해 봅시다.
package com.spring.member.mapstruct.mapper;
import com.spring.member.dto.MemberPatchDto;
import com.spring.member.dto.MemberPostDto;
import com.spring.member.dto.MemberResponseDto;
import com.spring.member.entity.Member;
import org.mapstruct.Mapper;
@Mapper(componentModel = "spring") // (1)
public interface MemberMapper {
Member memberPostDtoToMember(MemberPostDto memberPostDto);
Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
MemberResponseDto memberToMemberResponseDto(Member member);
}
코드 3-52는 MapStruct 기반으로 정의된 MemberMapper 인터페이스입니다.
MemberMapper 인터페이스에 정의한 세 개의 메서드가 \[코드 3-48]에서 우리가 직접 수작업으로 작성한 MemberMapper 클래스를 대체해 줍니다.
코드 3-48을 보면 우리가 직접 수작업으로 작성한 MemberMapper 클래스의 메서드들이 \[코드 3-52] 안에 그대로 정의된 것을 볼 수 있습니다.
코드 3-52에서 설명이 필요한 부분은 바로 (1)의 @Mapper 애너테이션입니다.
@Mapper 애너테이션을 추가함으로써 해당 인터페이스는 MapStruct의 매퍼 인터페이스로 정의가 되는 것입니다.
@Mapper 애너테이션의 애트리뷰트로 componentModel = "spring"을 지정해 주면 Spring의 Bean으로 등록이 된다는 사실을 꼭 기억하길 바랍니다.
그런데 ‘인터페이스는 정의되었긴 한데 이 인터페이스의 구현 클래스는 어디 있지?’라고 의아해하는 분들이 분명히 있을 거라고 생각합니다.
맞습니다. MapStruct가 코드 3-52의 MemberMapper 인터페이스를 기반으로 매퍼(Mapper) 구현 클래스를 자동으로 생성해 줍니다.
✔ 자동 생성된 Mapper 인터페이스 구현 클래스 확인
MapStruct가 자동으로 생성해 준 MemberMapper 인터페이스의 구현 클래스는 Gradle의 build task를 실행하면 자동으로 생성됩니다.
코드 3-53은 MapStruct가 자동으로 생성한 MemberMapper 인터페이스의 구현 클래스 코드입니다.
@Component
public class MemberMapperImpl implements MemberMapper {
public MemberMapperImpl() {
}
public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
if (memberPostDto == null) {
return null;
} else {
Member member = new Member();
member.setEmail(memberPostDto.getEmail());
member.setName(memberPostDto.getName());
member.setPhone(memberPostDto.getPhone());
return member;
}
}
public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
if (memberPatchDto == null) {
return null;
} else {
Member member = new Member();
member.setMemberId(memberPatchDto.getMemberId());
member.setName(memberPatchDto.getName());
member.setPhone(memberPatchDto.getPhone());
return member;
}
}
public MemberResponseDto memberToMemberResponseDto(Member member) {
if (member == null) {
return null;
} else {
long memberId = 0L;
String email = null;
String name = null;
String phone = null;
memberId = member.getMemberId();
email = member.getEmail();
name = member.getName();
phone = member.getPhone();
MemberResponseDto memberResponseDto = new MemberResponseDto(memberId, email, name, phone);
return memberResponseDto;
}
}
}
코드 3-53은 MapStruct가 자동으로 생성해 준 MemberMapper 인터페이스의 구현 클래스입니다.
코드 3-48에서 수작업으로 구현한 MemberMapper 클래스와 코드 상으로 크게 다른 부분은 없지만 개발자가 일일이 매퍼(Mapper) 클래스를 구현하지 않아도 된다는 사실은 생산성 면에서, 그리고 에러 발생 측면에서 굉장한 이점을 가져다줍니다.
다시 한번 강조하지만 [코드 3-53]의 MemberMapperImpl 클래스는 우리가 직접 작성한 코드가 아니라 MapStruct가 자동으로 생성해 준다는 사실을 잊지 마세요!
MemberMapperImpl 클래스는 언제, 어떻게 생성될까요? IntelliJ IDE의 오른쪽 상단의 [Gradle] 탭을 클릭한 후, [프로젝트 명 > Tasks 디렉토리 > build 디렉토리 > build task]를 더블 클릭하면 MapStruct로 정의된 인터페이스의 구현 클래스가 생성됩니다.
MemberMapperImpl 클래스는 어디에 생성될까요? IntelliJ IDE의 좌측에서 [Project 탭 > 프로젝트명 > build] 디렉토리 내의 MemberMapper 인터페이스가 위치한 패키지 안에 생성됩니다.
MemberController의 핸들러 메서드에서 MapStruct 적용
그럼 이제 앞에서 정의한 MapStruct 기반의 MemberMapper 인터페이스를 MemberController에 적용해 보도록 합시다.
//import com.spring.member.mapper.MemberMapper;
import com.spring.member.mapstruct.mapper.MemberMapper; // (1) 패키지 변경
/**
* - DI 적용
* - Mapstruct Mapper 적용
*/
@RestController
@RequestMapping("/v5/members") // (2) URI 버전 변경
@Validated
public class MemberController {
...
...
...
}
코드 3-54는 코드 3-50에서 개발자가 직접 구현한 매퍼(Mapper)를 MapStruct 기반의 매퍼(Mapper)로 변경한 모습입니다.
‘이게 다야?’ 할지 모르지만 MapStruct 인터페이스가 위치한 인터페이스의 위치만 import문으로 알려주고, Controller URI의 버전 번호만 v4에서 v5로 바꿔주면 끝입니다.
MemberController 클래스의 내부는 손댈 필요가 없습니다.
우리는 그저 사용하고자 하는 매퍼(Mapper)만 바꿔주면 됩니다.
MapStruct와 ModelMapper 중에 어떤 걸 사용하면 좋을까요?
Java에서 Object를 Mapping하는 라이브러리는 생각보다 많이 존재합니다. 그중에서 가장 많이 사용되는 Mapping 라이브러리는 MapStruct와 ModelMapper입니다.
결론을 이야기하자면 ModelMapper가 여전히 많이 사용되고 있지만 ModelMapper는 Runtime시 Java의 리플렉션 API를 이용해서 매핑을 진행하기 때문에 컴파일 타임에 이미 Mapper가 모두 생성되는 MapStruct보다 성능면에서 월등히 떨어집니다.
따라서 ModelMapper의 대안으로 MapStruct가 많이 사용되고 있는 추세인 것은 맞습니다.
Java의 Object Mapping 라이브러리 성능과 관련된 참고 자료는 [심화 학습]을 참고하세요.
DTO 클래스와 엔티티 클래스의 역할 분리가 필요한 이유
이처럼 DTO 클래스와 엔티티(Entity) 클래스를 매핑해서 변환하는 이유는 무엇인지 한번 생각해 봅시다.
DTO와 엔티티 클래스를 매핑해서 사용하는 여러 가지 이유가 있지만 그중에서 대표적인 이유는 아래와 같습니다.
✔ 계층별 관심사의 분리
우선 서로 사용되는 계층이 다릅니다. 따라서 기능에 대한 관심사가 다릅니다.
DTO 클래스는 API 계층에서 요청 데이터를 전달받고, 응답 데이터를 전송하는 것이 주 목적인 반면에 Entity 클래스는 서비스 계층에서 데이터 액세스 계층과 연동하여 비즈니스 로직의 결과로 생성된 데이터를 다루는 것이 주목적입니다.
굳이 Java의 Object Mapping 관점으로 생각하지 않아도 하나의 클래스나 메서드 내에서 여러 개의 기능들을 구현하고 있는 것은 객체 지향 코드 관점에서도 리팩토링 대상이 된다는 사실을 기억하면 좋을 것 같습니다.
✔ 코드 구성의 단순화
우리가 아직 JPA 같은 데이터 액세스 기술을 배우지 않았기 때문에 현실감 있게 와닿지 않을 수 있지만 DTO 클래스에서 사용하는 유효성 검사 애너테이션이 Entity 클래스에서 사용이 된다면 JPA에서 사용하는 애너테이션과 뒤섞인 상태가 되어 유지보수하기 상당히 어려운 코드가 됩니다.
이 부분은 JPA 같은 데이터 액세스 기술을 배우게 되면 조금 더 현실감 있게 느낄거라 생각합니다.
✔ REST API 스펙의 독립성 확보
데이터 액세스 계층에서 전달받은 데이터로 채워진 Entity 클래스를 클라이언트의 응답으로 그대로 전달하게 되면 원치 않는 데이터까지 클라이언트에게 전송될 수 있습니다.
대표적인 예가 바로 회원의 로그인 패스워드입니다.
DTO 클래스를 사용하면 회원의 로그인 패스워드 같은 정보를 클라이언트에게 노출하지 않고, 원하는 정보만 제공할 수 있습니다.
핵심 포인트
- Mapper를 사용해서 DTO 클래스와 Entity 클래스 간의 관심사를 분리할 수 있다.
- Mapper를 개발자가 직접 구현하기보다는 MapStruct 같은 매핑 라이브러리를 사용하는 것이 생산성 측면에서 더 나은 선택이다.
심화 학습
- MapStruct에 대해서 더 알아보고 싶다면 아래 링크를 참조하세요.따라서 지금 당장은 아니더라도 MapStruct의 사용법을 틈틈이 익혀두면 여러분들이 프로젝트를 진행할 때 분명 도움이 될 거라 생각합니다.
- MapStruct는 ModelMapper와 함께 실무에서 가장 많이 사용되는 Mapper입니다.
- Java의 Object Mapping 라이브러리 성능 비교 자료를 확인하고 싶다면 아래 링크를 참조하세요.
MapperController
package com.springboot.member;
import com.springboot.member.mapstruct.MemberMapper;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.Min;
import java.util.List;
@RestController
@RequestMapping("/v1/members")
@Validated
public class MemberController {
private final MemberService memberService;
private final MemberMapper memberMapper;
public MemberController(MemberService memberService, MemberMapper memberMapper) {
this.memberService = memberService;
this.memberMapper = memberMapper;
}
@PostMapping
public ResponseEntity postMember(@Valid @RequestBody MemberPostDto memberPostDto) {
// DTO -> Entity(아래 한 줄로 변경 가능)
// Member member = new Member();
// member.setEmail(memberDto.getEmail());
// member.setName(memberDto.getName());
// member.setPhone(memberDto.getPhone());
Member member = memberMapper.memberPostDtoToMember(memberPostDto);
Member responseEntity = memberService.createMember(member);
MemberResponseDto responseDto = memberMapper.memberToMemberResponseDto(responseEntity);
return new ResponseEntity<>(responseDto, HttpStatus.CREATED);
}
@PatchMapping("/{member-id}")
public ResponseEntity patchMember(@PathVariable("member-id") @Min(1) long memberId,
@Valid @RequestBody MemberPatchDto memberPatchDto) {
memberPatchDto.setMemberId(memberId);
// Member member = new Member();
// member.setMemberId(memberPatchDto.getMemberId());
// member.setName(memberPatchDto.getName());
// member.setPhone(memberPatchDto.getPhone());
// Dto -> Entity
Member member = memberMapper
.memberPatchDtoToMember(memberPatchDto);
// Entity 객체를 Service에 전달하여 비즈니스 로직 실행
// 응답되어 오는 객체는 Entity
Member response = memberService
.updateMember(member);
// 클라이언트로 응답하기 위해 Entity -> Dto
MemberResponseDto memberResponseDto = memberMapper
.memberToMemberResponseDto(response);
return new ResponseEntity<>(response, HttpStatus.OK);
}
@GetMapping("/{member-id}")
public ResponseEntity getMember(@PathVariable("member-id") @Min(1) long memberId) {
// 식별자를 Service로 보내서 조회 처리
// 조회되서 돌아오는 회원 정보의 타입은?
// Member -> Entity
Member response = memberService.findMember(memberId);
// Response에는 항상 Dto가 반환되어야 합니다.
// 즉, GET도 Entity -> Dto 변환해야 합니다.
MemberResponseDto responseDto = memberMapper.memberToMemberResponseDto(response);
return new ResponseEntity<>(responseDto, HttpStatus.OK);
}
@GetMapping
public ResponseEntity getMembers() {
//List 안의 요소는 실제로 Entity
List<Member> responseEntity = memberService.findMembers();
List<MemberResponseDto> responses = memberMapper.membersToMemberResponseDtos(responseEntity);
return new ResponseEntity<>(responses, HttpStatus.OK);
}
@DeleteMapping("/{member-id}")
public ResponseEntity deleteMember(@PathVariable("member-id") @Min(1) long memberId) {
memberService.deleteMember(memberId);
return new ResponseEntity(HttpStatus.NO_CONTENT);
}
}
MemberMapper
package com.springboot.member;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@Component
public class MemberMapper {
public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
return new Member(0L,
memberPostDto.getName(),
memberPostDto.getEmail(),
memberPostDto.getPhone());
}
public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
return new Member(
memberPatchDto.getMemberId(),
memberPatchDto.getName(),
null,
memberPatchDto.getPhone()
);
}
public MemberResponseDto memberToMemberResponseDto(Member member) {
return new MemberResponseDto(
member.getMemberId(),
member.getName(),
member.getEmail(),
member.getPhone()
);
}
public List<MemberResponseDto> membersToMembersResponseDtos(List<Member> responseEntity) {
/* for-each문 */
//List 안의 요소를 Dto로 변경해야 합니다.
// List<MemberResponseDto> responseDtos = new ArrayList<>();
//
// for (Member member : responseEntity) {
// responseDtos.add(memberToMemberResponseDto(member));
// }
/* stream */
List<MemberResponseDto> responseDtos = responseEntity.stream()
.map(this::memberToMemberResponseDto)
.collect(Collectors.toList());
return responseDtos;
}
}
MapStruct 라이브러리를 사용하면
MemberMapper 인터페이스만 만들어주면 구현체를 자동으로 만들어줍니다.
package com.springboot.member.mapstruct;
import com.springboot.member.Member;
import com.springboot.member.MemberPatchDto;
import com.springboot.member.MemberPostDto;
import com.springboot.member.MemberResponseDto;
import org.mapstruct.Mapper;
import java.util.List;
@Mapper(componentModel = "spring")
public interface MemberMapper {
Member memberPostDtoToMember(MemberPostDto memberPostDto);
Member memberPatchDtoToMember(MemberPatchDto memberPatchDto);
MemberResponseDto memberToMemberResponseDto(Member member);
List<MemberResponseDto> membersToMemberResponseDtos(List<Member> members);
}
MemberMapper 인터페이스를 만든 이후, gradle을 build 해주면
package com.springboot.member.mapstruct;
import com.springboot.member.Member;
import com.springboot.member.MemberPatchDto;
import com.springboot.member.MemberPostDto;
import com.springboot.member.MemberResponseDto;
import java.util.ArrayList;
import java.util.List;
import javax.annotation.processing.Generated;
import org.springframework.stereotype.Component;
@Generated(
value = "org.mapstruct.ap.MappingProcessor",
date = "2024-06-12T17:54:48+0900",
comments = "version: 1.4.2.Final, compiler: IncrementalProcessingEnvironment from gradle-language-java-7.4.1.jar, environment: Java 11.0.22 (Azul Systems, Inc.)"
)
@Component
public class MemberMapperImpl implements MemberMapper {
@Override
public Member memberPostDtoToMember(MemberPostDto memberPostDto) {
if ( memberPostDto == null ) {
return null;
}
Member member = new Member();
return member;
}
@Override
public Member memberPatchDtoToMember(MemberPatchDto memberPatchDto) {
if ( memberPatchDto == null ) {
return null;
}
Member member = new Member();
return member;
}
@Override
public MemberResponseDto memberToMemberResponseDto(Member member) {
if ( member == null ) {
return null;
}
MemberResponseDto memberResponseDto = new MemberResponseDto();
return memberResponseDto;
}
@Override
public List<MemberResponseDto> membersToMemberResponseDtos(List<Member> members) {
if ( members == null ) {
return null;
}
List<MemberResponseDto> list = new ArrayList<MemberResponseDto>( members.size() );
for ( Member member : members ) {
list.add( memberToMemberResponseDto( member ) );
}
return list;
}
}
구현체를 자동으로 만들어줍니다.