API 계층과 서비스 계층을 연동하는 의미는 API 계층에서 구현한 Controller 클래스가 서비스 계층의 Service 클래스와 메서드 호출을 통해 상호 작용한다는 것을 의미한다.
API 계층에서 구현한 Controller 내의 핸들러 메서드가 다음과 같은 종류가 있다고 하자.
서비스 계층의 Service 클래스내에 각각에 매핑해서 서비스 로직을 수행하는 메서드를 작성하면 된다.
@Service
public class UserService {
public User createUser(User user) {
return null;
}
public User updateUser(User user) {
return null;
}
public User findUser(long userId) {
return null;
}
public List<User> findUsers() {
return null;
}
public void deleteUser(long userId) {
}
}
그리고 Controller에서 UserService를 사용해야하므로 DI를 이용하여 생성자 주입을 해주어야 한다.
@RestController
@RequestMapping("/v1/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
// public UserController(UserService userService) {
// this.userService = userService;
// }
...
}
DTO는 API 계층에서만 데이터를 처리하는 역할을 하고, Entity는 서비스 계층에서만 데이터를 처리하는 역할을 수행해야 한다. 이에 따라 계층 간의 역할 분리가 이루어진다.
Mapper는 DTO와 Entity를 서로 변환해주는 역할을 수행한다.
@Getter
@AllArgsConstructor
public class UserResponseDto {
private long userId;
private String email;
private String name;
private String phone;
}
@Component
public class UserMapper {
// UserPostDto를 User로 변환
public User userPostDtoToUser(UserPostDto userPostDto) {
return new User(0L,
userPostDto.getEmail(),
userPostDto.getName(),
userPostDto.getPhone());
}
// UserPatchDto를 User로 변환
public User userPatchDtoToUser(UserPatchDto userPatchDto) {
return new User(userPatchDto.getUserId(),
null,
userPatchDto.getName(),
userPatchDto.getPhone());
}
// User를 UserResponseDto로 변환
public UserResponseDto userToUserResponseDto(User user) {
return new UserResponseDto(user.getUserId(),
user.getEmail(),
user.getName(),
user.getPhone());
}
}
직접 Mapper 클래스를 작성해도 되지만 자동으로 생성하는 라이브러리가 있다. MapStruct나 ModelMapper가 있는데 성능상 MapStruct가 더 뛰어나다고 한다. 그 이유는 다음과 같다.
- ModelMapper
modelMapper.map(user, UserDTO.class) 매핑이 일어날 때 리플렉션이 발생한다- MapStruct
컴파일 시점에서 어노테이션을 읽어 구현체를 만들어내기 때문에 리플렉션이 발생하지 않는다
dependencies {
...
...
implementation 'org.mapstruct:mapstruct:1.5.1.Final'
annotationProcessor 'org.mapstruct:mapstruct-processor:1.5.1.Final'
}
build.gradle에 dependency를 설정해준다.
그리고 Mappser interface를 작성한다.
@Mapper(componentModel = "spring") // (1)
public interface UserMapper {
User userPostDtoToUser(UserPostDto userPostDto);
User userPatchDtoToUser(UserPatchDto userPatchDto);
UserResponseDto userToUserResponseDto(User user);
}
여기서 (1)의 의미는 @Mapper 애너테이션의 애트리뷰트로 componentModel = "spring"을 지정해주면 Spring의 Bean으로 등록한다는 뜻이다.
gradle build를 하면 build 폴더 내에 UserMapper interface가 위치한 곳에 UserMapperImpl이 자동으로 생성되고 이는 UserMapper interface의 구현체이다.
여기서 들여다봐야할 것은 UserMapper에서 리턴 타입은 변환 결과(to)를 나타내고 파라미터는 어떤 타입을 변환시킬 것(from)이냐를 나타낸다.
UserResponseDto를 좀 더 살펴보자.
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder // (1)
@Setter // (2)
public class UserResponseDto {
private long userId;
private String email;
private String name;
private String phone;
}
코드에서 (1), (2)에 따라 생성되는 UserMapperImpl이 조금씩 다르다.
@Builder
)@Component
public class UserMapperImpl implements UserMapper {
public UserMapperImpl() {
}
...
public UserResponseDto userToUserResponseDto(User user) {
if (user == null) {
return null;
} else {
UserResponseDto.UserResponseDtoBuilder userResponseDto = UserResponseDto.builder();
userResponseDto.userId(user.getUserId());
userResponseDto.email(user.getEmail());
userResponseDto.name(user.getName());
userResponseDto.phone(user.getPhone());
return userResponseDto.build();
}
}
}
@Setter
)@Component
public class UserMapperImpl implements UserMapper {
public UserMapperImpl() {
}
...
public UserResponseDto userToUserResponseDto(User user) {
if (user == null) {
return null;
} else {
UserResponseDto userResponseDto = new UserResponseDto();
userResponseDto.setUserId(user.getUserId());
userResponseDto.setEmail(user.getEmail());
userResponseDto.setName(user.getName());
userResponseDto.setPhone(user.getPhone());
return userResponseDto;
}
}
}
@Component
public class UserMapperImpl implements UserMapper {
public UserMapperImpl() {
}
...
public UserResponseDto userToUserResponseDto(User user) {
if (user == null) {
return null;
} else {
UserResponseDto.UserResponseDtoBuilder userResponseDto = UserResponseDto.builder();
userResponseDto.userId(user.getUserId());
userResponseDto.email(user.getEmail());
userResponseDto.name(user.getName());
userResponseDto.phone(user.getPhone());
return userResponseDto.build();
}
}
}
우선순위는 @Setter
< @Builder
임을 확인할 수 있다.
계층별 관심사의 분리
DTO는 API 계층에서 요청 데이터를 전달 받고, 응답 데이터를 전송하는것이 주목적
Entity는 서비스 계층에서 데이터 액세스 계층과 연동하여 비즈니스 로직의 결과로 생성된 데이터를 다루는 것이 주목적
코드 구성의 단순화
DTO에서 사용하는 유효성 검사 애너테이션이 Entity에서 사용이 된다면 JPA에서 사용하는 애너테이션과 뒤섞인 상태가 되어 유지보수하기 상당히 어려운 코드가 된다.
REST API 스펙의 독립성 확보
데이터 액세스 계층에서 전달 받은 데이터로 채워진 Entity를 클라이언트의 응답으로 그대로 전달하게되면 원치 않는 데이터까지 클라이언트에게 전송될 수 있다.