DTO 사용 왜 할까?
@GetMapping("/api/v1/members")
public List<Member> membersV1() {
return memberService.findMembers();
}
[ // > array
-> 이곳에 새로운 필드를 입력하지 못함
ex) "count": 1 ==> 불가
{
"id": 1,
"name": "hello-name",
"address": null,
"orders":[]
},
{
"id": 2,
"name": "member1",
"address":{
"city": "서울",
"street": "test",
"zipcode": "111-111"
},
"orders":[]
},
{
"id": 3,
"name": "member2",
"address":{
"city": "부산",
"street": "거리",
"zipcode": "20302-2323"
},
"orders":[]
}
]
List를 반환함으로써 API response 스펙을 확장할 수 없음.
@GetMapping("/api/v2/members")
public Result<List<MemberDto>> memberV2() {
List<Member> findMembers = memberService.findMembers();
List<MemberDto> collect = findMembers.stream()
.map(member -> new MemberDto(member.getName()))
.collect(toList());
return new Result<>(collect);
}
@Data
@AllArgsConstructor
static class Result<T> {
private T data;
}
@Data
@AllArgsConstructor
static class MemberDto {
private String name;
}
{ // > json object
"count": 1 ==> 필드 추가 가능
"data":[
{
"name": "hello-name"
},
{
"name": "member1"
},
{
"name": "member2"
},
{
"name": ""
}
]
}
List를 반환하는 것이 아닌 Rsult 클래스를 정의해서 API 응답 스펙을 유연하게 만들었다. (추가하고싶은 데이터를 Result의 필드에 추가하여 사용한다.)
✨ 지연로딩 때문에 발생하는 성능문제를 단계적으로 해결하는것이 핵심
항상 지연로딩을 기본으로 하고 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라(V3)
엔티티를 DTO로 변환하거나, DTO로 바로 조회하는 두가지 방법은 각각 장단점이 있다. 둘중 상황에 따라서 더 나은 방법을 선택하면 된다. 엔티티로 조회하면 리포지토리 재사용성도 좋고, 개발도 단순해 진다. 따라서 권장하는 방법은 다음과 같다.
쿼리 방식 선택 권장 순서 (가장 중요)
우선 엔티티를 DTO로 변환하는 방법을 선택한다. : V2
필요하면 페치 조인으로 성능을 최적화 한다. → 대부분의 성능 이슈가 해결 : V3(엔티티 조회)
그래도 안되면 DTO로 직접 조회하는 방법을 사용한다. : V4(Dto 조회)
최후의 방법은 JP가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
Fetch Join
조회의 주체가 되는 Entity 이외에 Fetch Join이 걸린 연관 Entity도 함께 SELECT 하여 모두 영속화
Fetch Join이 걸린 Entity 모두 영속화하기 때문에 FetchType이 Lazy인 Entity를 참조하더라도 이미 영속성 컨텍스트에 들어있기 때문에 따로 쿼리가 실행되지 않은 채로 N+1문제가 해결됨
엔티티를 페치 조인(fetch join)을 사용해서 쿼리 1번에 조회
페치 조인으로 order -> member , order -> delivery 는 이미 조회 된 상태 이므로 지연로딩X
DTO 내에서 Entity 의존 x
의존하게 된다면 이 Entity를 변경하면 마찬가지로 API 스펙이 달라지는 거다.
distinct
distinct 를 사용한 이유는 1대다 조인이 있으므로 데이터베이스 row가 증가한다. 그 결과 같은 order 엔티티의 조회 수도 증가하게 된다. JPA의 distinct는 SQL에 distinct를 추가하고, 더해서 같은 엔티티가 조회되면, 애 플리케이션에서 중복을 걸러준다. 이 예에서 order가 컬렉션 페치 조인 때문에 중복 조회 되는 것을 막아준다.
하지만 db에선 distinct가 제대로 적용되는 게 아니다. 날려주긴 함 근데 진자 아예 row가 같아야 작동되는 것
즉 일단 db에서 다 가져와서,객체의 Id가 같다면 그때 중복을 제거하는 것이다.
메모리에서 페이징처리를 해버린다고 warning 뜬다 -> outofmemory 당하는거임
컬렉션 fetch join은 1개만 사용할 수 있다.
페이징과 한계 돌파
컬렉션을 페치 조인하면 페이징이 불가능하다.
컬렉션을 페치 조인하면 일대다 조인이 발생하므로 데이터가 예측할 수 없이 증가한다.
일다대에서 일(1)을 기준으로 페이징을 하는 것이 목적이다. 그런데 데이터는 다(N)를 기준으로 row가 생 성된다.
Order를 기준으로 페이징 하고 싶은데, 다(N)인 OrderItem을 조인하면 OrderItem이 기준이 되어버린다.
이 경우 하이버네이트는 경고 로그를 남기고 모든 DB 데이터를 읽어서 메모리에서 페이징을 시도한다. 최악의 경우 장애로 이어질 수 있다.
먼저 ToOne(OneToOne, ManyToOne) 관계를 모두 페치조인 한다. ToOne 관계는 row수를 증가시키지 않으므로 페이징 쿼리에 영향을 주지 않는다.
컬렉션은 지연 로딩으로 조회한다.
지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
hibernate.default_batch_fetch_size: 글로벌 설정
@BatchSize: 개별 최적화
이 옵션을 사용하면 컬렉션이나, 프록시 객체를 한꺼번에 설정한 size 만큼 IN 쿼리로 조회한다.
default_batch_fetch_size
복잡한 조회쿼리 작성시,
지연로딩으로 발생해야 하는 쿼리를 IN절로 한번에 모아보내는 기능이다.
조회 성능 개선을 위해 반드시 활용해야 하는 설정이다!
ToOne(N:1, 1:1) 관계들을 먼저 조회하고, ToMany(1:N) 관계는 각각 별도로 처리한다.
이런 방식을 선택한 이유는 다음과 같다.
ToOne 관계는 조인해도 데이터 row 수가 증가하지 않는다. ToMany(1:N) 관계는 조인하면 row 수가 증가한다.
row 수가 증가하지 않는 ToOne 관계는 조인으로 최적화 하기 쉬우므로 한번에 조회하고, ToMany 관계는 최적 화 하기 어려우므로 findOrderItems() 같은 별도의 메서드로 조회한다.
루프를 돌릴때마다 쿼리를 날리는데, 쿼리를 한번 날리고 메모리에 맵으로 가져온 다음에 메모리에서 매칭해서 값을 세팅해줌
이건 쿼리 두번 나간다.
Query: 루트 1번, 컬렉션 1번
ToOne 관계들을 먼저 조회하고, 여기서 얻은 식별자 orderId로 ToMany 관계인 OrderItem 을 한꺼번에 조 회
MAP을 사용해서 매칭 성능 향상(O(1))
데이터베이스 커넥션을 획득 & 반환
트랜잭션을 실행할 때 JPA 영속성 컨텍스트에서 데이터베이스 커넥션을 획득함
spring.jpa.open-in-view : true 기본값 이 커져있다면, 트랜잭션이 끝나도 바로 반환하지 않음, API 경우는 API가 유저에게 반환이 될 때까지, 뷰템플릿 경우엔 화면에 다 렌더링 될 때까지
영속성 컨텍스트랑 데이터베이스 커넥션이 끝까지 살아있는 것
그래서 컨트롤러나 뷰에서 지연로딩이 가능했던 것이다.
OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트 와 데이터베이스 커넥션을 유지한다. 그래서 지금까지 View Template이나 API 컨트롤러에서 지연 로딩이 가능했던 것이다.
그런데 이 전략은 너무 오랜시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리 케이션에서는 커넥션이 모자랄 수 있다. 이것은 결국 장애로 이어진다.
예를 들어서 컨트롤러에서 외부 API를 호출하면 외부 API 대기 시간 만큼 커넥션 리소스를 반환하지 못하고, 유지해야 한다.
spring.jpa.open-in-view: false OSIV 종료
OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스 를 낭비하지 않는다.
OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭 션 안으로 넣어야 하는 단점이 있다. 그리고 view template에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.