[인프런] 김영한님의 실전 스프링부트와 JPA 활용 2편을 수강하면서 공부한 내용을 바탕으로 작성하였습니다.
스프링부트를 통해 API를 개발할 때 요청(Request)하는 값과 반환(Response) 받는 값을 엔티티가 아닌 DTO로 받는 방법에 대해 정리하고자 합니다.
DTO로 데이터 전달하기
Entity 엔티티를 DTO로 반환하지 않고 그대로 데이터를 주고 받을 경우 여러 문제점이 있습니다.
1) 엔티티에 프레젠테이션 계층을 위한 로직이 추가됩니다.
=> 엔티티를 코드를 작성할 때 사용자에게 보여지는 화면에 맞게 개발해야 하는 문제.
2) 각 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요청 요구사항을 담기 어렵습니다.
3) 엔티티의 속성이 변경되면 API 스펙이 변경됩니다.
4) 엔티티에 API 검증을 위한 추가 로직이 들어갑니다. (@NotEmpty, @Nullable..)
위와 같은 문제점을 해결하기 위해 별도의 DTO를 파라미터로 받아 사용합니다.
( DTO 란 데이터 교환을 하기 위해 사용하는 객체를 의미합니다.)
엔티티를 Request Body에 매핑 (회원 등록 API)
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member){
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
데이터를 저장할 때 엔티티의 정보를 API 스펙에 노출하게 되고 위의 말했던 문제점을 가지고 있습니다.
@Data
static class CreateMemberRequest {
private String name;
}
@Data
static class CreateMemberResponse {
private Long id;
public CreateMemberResponse(Long id) {
this.id = id;
}
}
CreateMemberResponse 클래스를 통해 데이터를 저장한 id의 값을 반환하여 확인합니다.
엔티티 대신에 DTO를 Request Body에 매핑 (회원 등록 API)
@PostMapping("/api/v2/members")
public CreateMemberResponse saveMemberV2(@RequestBody @Valid CreateMemberRequest request) {
Member member = new Member();
member.setName(request.getName());
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
CreateMemberRequset 클래스를 이용해 Request Body와 매핑하였습니다.
데이터를 요청할 때 name 값만 전달하는 DTO를 만들었습니다.
엔티티와 프레젠테이션 계층에 있어 로직을 분리하였으며, DTO를 통해 엔티티와 API 스펙을 분리하였습니다.
응답 값으로 엔티티를 직접 외부에 노출 (회원 조회 API)
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
문제점
1) 엔티티의 모든 값이 외부에 노출됩니다.
2) 응답 스펙을 맞추기 위해 로직이 추가될 수 있습니다. (@JsonIgnore..)
3) 같은 엔티티에 대해 API가 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기 어렵습니다.
4) 엔티티가 변경되면 API 스펙이 변경됩니다.
5) 컬렉션(ex, 리스트)을 직접 변환하면 향후 API 스펙을 변경하기 어렵습니다.
(별도의 반환하는 Result 클래스 생성하여 클래스 안에 담아 반환하여 해결합니다.)
응답 값으로 엔티티가 아닌 별도의 DTO 클래스 사용
@GetMapping("/api/v2/members")
public Result membersV2() {
// 모든 회원 조회
List<Member> findMembers = memberService.findMembers();
// 조회한 엔티티 -> DTO 변환
List<MemberDto> collect = findMembers.stream()
.map(m -> new MemberDto(m.getName()))
.collect(Collectors.toList());
return new Result(collect.size(), collect);
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
@Data
@AllArgsConstructor
static class Result<T> {
private int size;
private T data;
}
조회한 엔티티를 stream().map().collect()를 통해 DTO로 변환합니다.
변환한 DTO 객체를 Result 클래스에 담아 향후 필요한 필드를 추가하여 필요에 따라 API 스펙을 개발할 수 있습니다.
(컬렉션의 크기를 API 스펙에 추가하였습니다.)
응답 값으로 DTO를 전달하게 되면 엔티티가 변해도 API 스펙이 변경되지 않으며, 딱 필요한 엔티티의 필드만 응답 값으로 전달하여 노출을 최소화할 수 있습니다.
지연 로딩과 조회 성능 최적화하기
주문 (Order) + 배송 (Delivery) + 회원 (Member)을 조회하는 API를 만들 때 조회 성능을 최적화 해보겠습니다.
주문 조회를 할 때 연관된 배송과 회원 엔티티의 필드를 조회하려고 할 때 XToOne(fetch = FetchType.LAZY) 관계로 연결되어 있어 프록시 객체를 초기화해주어야 조회를 할 수 있습니다.
(1) 지연 로딩을 초기화하는 방법과 (2) fetch join을 통해 Query 1번으로 조회하여 성능을 최적화할 수 있습니다.
주문 조회 - 엔티티를 직접 노출 (비추)
@GetMapping("/api/v1/simple-orders")
public List<Order> ordersV1() {
List<Order> all = orderRepository.findAllByString(new OrderSearch());
for (Order order : all) {
order.getMember().getName(); //Lazy 강제 초기화
order.getDelivery().getAddress(); //Lazy 강제 초기환
}
return all;
}
문제점
1) 엔티티를 직접 노출하므로 좋지 않다.
2) order => member / order => delivery는 XToOne(fetch = FetchType.LAZY) 으로 매핑하여 지연 로딩 관계이다.
실제 엔티티가 아닌 프록시 객체가 저장되어 있으므로 초기화를 해주어야 member와 delivery의 정보를 조회할 수 있습니다.
3) Jackson 라이브러리는 기본적으로 프록시 객체를 json으로 어떻게 생성해야 하는지 모르므로 예외가 발생합니다.
Hibernate5Module 을 스프링 빈으로 등록하면 해결합니다.
3.1 build.gradle에 라이브러리 추가하기
// build.gradle 에 다음 라이브러리를 추가하기
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5'
3.2 **Application 클래스에 Hibernate5Module 추가하기
@Bean
Hibernate5Module hibernate5Module() {
return new Hibernate5Module();
}
주문 조회 - 엔티티를 DTO로 변환
@GetMapping("/api/v2/simple-orders")
public List<SimpleOrderDto> ordersV2() {
List<Order> orders = orderRepository.findAll();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
@Data
static class SimpleOrderDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
public SimpleOrderDto(Order order) {
orderId = order.getId();
name = order.getMember().getName();
orderDate = order.getOrderDate();
orderStatus = order.getStatus();
address = order.getDelivery().getAddress();
}
}
지연 로딩을 초기화하여 조회하게 되면, Query가 총 1 + N + N 번 실행됩니다.
* Order 조회 1번
* Order -> Member 지연 로딩 조회 N번
* Order -> Delivery 지연 로딩 조회 N번
주문 조회 - FETCH JOIN : 엔티티를 DTO로 변환
@GetMapping("/api/v3/simple-orders")
public List<SimpleOrderDto> ordersV3() {
List<Order> orders = orderRepository.findAllWithMemberDelivery();
List<SimpleOrderDto> result = orders.stream()
.map(o -> new SimpleOrderDto(o))
.collect(toList());
return result;
}
public List<Order> findAllWithMemberDelivery() {
return em.createQuery(
"select o from Order o" +
" join fetch o.member m" +
" join fetch o.delivery d", Order.class)
.getResultList();
}
엔티티를 패치 조인을 사용해서 쿼리 1번에 모두 조회합니다.
패치 조인으로 Order => Member / Order => Delivery는 이미 조회된 상태이므로 쿼리가 더 발생하지 않습니다.
select
order0_.order_id as order_id1_6_0_,
member1_.member_id as member_i1_4_1_,
delivery2_.delivery_id as delivery1_2_2_,
order0_.delivery_id as delivery4_6_0_,
order0_.member_id as member_i5_6_0_,
order0_.order_date as order_da2_6_0_,
order0_.status as status3_6_0_,
member1_.city as city2_4_1_,
member1_.street as street3_4_1_,
member1_.zipcode as zipcode4_4_1_,
member1_.name as name5_4_1_,
delivery2_.city as city2_2_2_,
delivery2_.street as street3_2_2_,
delivery2_.zipcode as zipcode4_2_2_,
delivery2_.status as status5_2_2_
from
orders order0_
inner join
member member1_
on order0_.member_id=member1_.member_id
inner join
delivery delivery2_
on order0_.delivery_id=delivery2_.delivery_id
주문 조회 - JPA에서 DTO로 바로 조회
@GetMapping("/api/v4/simple-orders")
public List<OrderSimpleQueryDto> ordersV4() {
return orderSimpleQueryRepository.findOrderDtos();
}
public List<OrderSimpleQueryDto> findOrderDtos() {
return em.createQuery(
"select new jpabook.jpashop.repository.order.simplequery.OrderSimpleQueryDto(o.id, m.name, o.orderDate, o.status, d.address)" +
" from Order o" +
" join o.member m" +
" join o.delivery d", OrderSimpleQueryDto.class)
.getResultList();
}
@Data
public class OrderSimpleQueryDto {
private Long orderId;
private String name;
private LocalDateTime orderDate; //주문시간
private OrderStatus orderStatus;
private Address address;
public OrderSimpleQueryDto(Long orderId, String name, LocalDateTime orderDate, OrderStatus orderStatus, Address address) {
this.orderId = orderId;
this.name = name;
this.orderDate = orderDate;
this.orderStatus = orderStatus;
this.address = address;
}
}
일반적인 SQL을 사용할 때처럼 원하는 값을 선택해서 조회합니다.
new 명령어를 사용해서 JPQL의 결과를 DTO로 즉시 변환하여 사용합니다.
SELECT 절에서 원하는 데이터를 직접 선택하므로 DB => 애플리케이션 네트워크 용량이 최적화됩니다.
API 스펙에 맞춘 코드이므로 재사용성이 떨어지는 단점이 있습니다.
쿼리 방식 선택 권장 순서
1. 우선 엔티티를 DTO로 변환하는 방법을 선택합니다.
2. 필요하면 페치 조인으로 성능을 최적합니다.
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용합니다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용합니다.