김영한님의 실전! 스프링 부트와 JPA활용 2를 3번째 수강하려고 한다!!
회원 등록 API
V1 엔티티를 Request Body 직접 매핑
@PostMapping("/api/v1/members")
public CreateMemberResponse saveMemberV1(@RequestBody @Valid Member member) {
Long id = memberService.join(member);
return new CreateMemberResponse(id);
}
등록 V1 : 요청 값으로 Member 엔티티를 직접 받는다.
문제점
- 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
- 엔티티에 API 검증을 위한 로직이 들어간다. (@NotEmpty 등등)
- 실무에서는 회원 엔티티를 위한 API가 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 모든 요청 요구사항을 담기는 어렵다.
- 엔티티가 변경되면 API 스펙이 변한다.
결론
- API 요청 스펙에 맞추어 별도의 DTO를 파라미터로 받는다.
V2 엔티티 대신에 DTO를 RequestBody에 매핑
@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);
}
@Data
static class CreateMemberRequest {
private String name;
}
- CreateMemberRequest를 Member 엔티티 대신에 RequestBody와 매핑한다.
- 엔티티와 프레젠테이션 계층을 위한 로직을 분리할 수 있다.
- 엔티티와 API 스펙을 명화하게 분리할 수 있다.
- 엔티티가 변해도 API 스펙이 변하지 않는다.
실무에서는 엔티티를 API 스펙에 노출하면 안된다!
회원 조회 API
V1 응답 값으로 엔티티를 직접 외부에 노출
package jpabook.jpashop.api;
@RestController
@RequiredArgsConstructor
public class MemberApiController{
private final MemberService memberService;
@GetMapping("/api/v1/members")
public List<Member> membersV1(){
return memberService.findMembers();
}
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
V2 응답 값으로 엔티티가 아닌 별도의 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);
}
조회 V1: 응답 값으로 엔티티를 직접 외부에 노출한다.
문제점
- 엔티티에 프레젠테이션 계층을 위한 로직이 추가된다.
- 기본적으로 엔티티의 모든 값이 노출된다.
- 응답 스펙을 맞추기 위해 로직이 추가된다. (@JsonIgnore, 별도의 뷰 로직 등등)
- 실무에서는 같은 엔티티에 대해 API 용도에 따라 다양하게 만들어지는데, 한 엔티티에 각각의 API를 위한 프레젠테이션 응답 로직을 담기 어렵다.
- 엔티티가 변경되면 API 스펙이 변한다.
- 추가로 컬렉션을 직접 반환하면 향후 API 스펙을 변경하기 어렵다.( 별도의 Result 클래스 생성으로 해결)
엔티티를 외부에 노출하지 말라!
실무에서 member 엔티티의 데이터가 필요한 API가 계속 증가하게 된다. 어떤 API는 name 필드가 필요하지만, 어떤 API는 name 필드가 필요없을 수 있다.결론적으로 엔티티 대신에 API 스펙에 맞는 별도의 DTO를 노출해야 한다.
결론
- API 응답 스펙에 맞추어 별도의 DTO를 반환한다.
- 엔티티를 DTO로 변환해서 반환한다.
- 엔티티가 변해도 API 스펙이 변경되지 않는다.
- 추가로 Result 클래스로 컬렉션을 감싸서 향후 필요한 필드를 추가할 수 있다.
API 개발 고급 - 지연 로딩과 조회 성능 최적화
(1) /간단한 주문 조회 V1: 엔티티를 직접 노출
(2) /간단한 주문 조회 V2: 엔티티를 DTO로 변환
(3) /간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
(4) /간단한 주문 조회 V4: JPA에서 DTO로 바로 조회
주문 + 배송정보 + 회원을 조회하는 API
지연 로딩 때문에 발생하는 성능 문제를 단계적으로 해결
지금부터 설명하는 내용은 정말 중요하다. 실무에서 JPA를 사용하려면 100% 이해해야 한다.
간단한 주문 조회 V1: 엔티티를 직접 노출
/**
* V1. 엔티티 직접 노출
* - Hibernate5Module 모듈 등록, LAZY=null 처리
* - 양방향 관계 문제 발생 -> @JsonIgnore
*/
@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;
}
- 엔티티를 직접 노출하는 것은 좋지 않다.
- order -> member와 order -> delivery는 지연 로딩이다. 따라서 실제 엔티티 대신에 프록시 존재
- jackson 라이브러리는 기본적으로 이 프록시 객체를 json으로 어떻게 생성하는지 모른다. -> 예외 발생
해결방법 : Hibernate5Module을 스프링 빈으로 등록하면 해결 (엔티티만 외부에 노출하지 않으면, 할 필요가 없다.)
하이버네이트 모듈 등록
다시 말하지만 Entity를 직접 노출하지 않으면, 하이버네이트 모듈 등록을 할 필요가 없어진다.
SpringBoot 3.0이상
build.gradle 라이브러리 추가
`implementation 'com.fasterxml.jackson.datatype:jackson-datatype-hibernate5-
jakarta'`
@Bean
Hibernate5JakartaModule hibernate5Module() {
return new Hibernate5JakartaModule();
}
기본적으로 최기화 된 프록시 객체만 노출, 초기화 되지 않은 프록시 객체는 노출 하지 않는다.
강제 지연 로딩 가능하게 하는 방법
@Bean
Hibernate5Module hibernate5Module() {
Hibernate5Module hibernate5Module = new Hibernate5Module();
//강제 지연 로딩 설정 hibernate5Module.configure(Hibernate5Module.Feature.FORCE_LAZY_LOADING,
true);
return hibernate5Module;
}
- 이 옵션을 키게되는 경우 order -> member, member -> orders 양방향 연관관계를 계속 로딩하게 된다.
따라서 @JsonIgnore 옵션을 한곳에 주어야 한다.
엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭 한곳을 @JsonIgnore 처리 해야한다. 안그러면 양쪽을 서로 호출하면서 무한루프가 걸린다.
엔티티를 API 응답으로 외부로 노출하는 것은 좋지 않다. Hibernate5Module을 사용하기 보다는 DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGER)로 설정하면 안된다.
즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 할 경우 성능 튜닝이 매우 어려워 진다.
항상 지연 로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해야한다!
간단한 주문 조회 V2: 엔티티를 DTO로 변환
/**
* V2. 엔티티를 조회해서 DTO로 변환(fetch join 사용X)
* - 단점: 지연로딩으로 쿼리 N번 호출
*/
@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();
}
}
- 엔티티를 DTO로 변환하는 일반적인 방법
- 쿼리가 총 1 + N + N번 실행된다.
- order 조회 1번(order 조회 결과 수가 N이 된다.)
- order -> member 지연 로딩 조회 N번
- order -> delivery 지연 로딩 조회 N번
- ex) order의 결과가 4개면 최악의 경우 1 + 4 + 4번 실행된다.
- 지연로딩은 영속성 컨텍스트에서 조회하므로, 이미 조회된 경우 쿼리를 생략한다.
간단한 주문 조회 V3: 엔티티를 DTO로 변환 - 페치 조인 최적화
**
* V3. 엔티티를 조회해서 DTO로 변환(fetch join 사용O)
* - fetch join으로 쿼리 1번 호출
* 참고: fetch join에 대한 자세한 내용은 JPA 기본편 참고(정말 중요함)
*/
@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();
}
- 엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
- 페치 조인으로 order -> member, order -> delivery는 이미 조회된 상태 이므로 지연로딩 x
fetch join 이란?
SQL에서 사용하는 조인의 종류가 아닌 JPQL에서 성능 최적화를 위해 제공하는 기능이다.
연관된 엔티티나 컬렉션을 한 번에 같이 조회하는 기능으로 join fetch 명령어로 사용할 수 있다.
'Backend > JPA' 카테고리의 다른 글
[김영한 자바 ORM 표준 JPA ] -연관관계 매핑 기초 (0) | 2024.05.17 |
---|---|
[김영한 JPA활용 2편] 컬렉션 조회 최적화 (1) | 2024.04.26 |
[김영한의 JPA] 연관관계 매핑 (0) | 2024.03.27 |
API 개발 고급 정리 (0) | 2024.01.04 |
엔티티 설계시 주의할점 (1) | 2023.12.06 |