Inflearn
JPA 실전 2편 정리
시롱시롱
2023. 4. 5. 19:57
본 게시글은 인프런 김영한님의 JPA실전 2편 을 수강하고 저의 생각을 정리한 글입니다. 저의 글은 제 생각을 정리한 것에 불과하기에 강의에 나오는 내용을 제대로 학습할 수 없다고 생각합니다. 필요하다면 해당 강의를 수강하시는 것을 권장드립니다 !
실전1 내용
- 직렬화(Serialization) : 객체를 직렬화하여 전송 가능한 형태로 만드는 것, 객체들의 데이터를 연속적인 데이터로 변환하여 stream을 통해 데이터를 읽도록 해준다.
- 역직렬화(Deserialization) : 직렬화된 파일 등을 역으로 직렬화 하여 다시 객체 형태로 만드는 것, 저장된 파일을 읽거나 전송된 스트림데이터를 읽어 원래 객체의 형태로 복원한다.
- @JsonIgnore : 엔티티(자바 클래스)를 자동으로 JSON으로 만들 때 무시하겠다는 뜻
- @JsonProperty : 역직렬화할 때 해당 필드를 무시한다.
- 파라미터 …표현
- public Order(Member member, Delivery delivery, OrderItem... orderItems) { }
- OrderItem객체를 여러개 받는 경우 대신, 갯수가 일정하지 않을 때 사용! 뭐 for-each도 사용가능
API개발 기본
- Controller는 템플릿 엔진을 사용하는 매핑인 경우, Api는 말 그대로 API의 기능을 하는 애들 이렇게 나눠놓고 패키지를 다르게 두는 게 좋을 것 같음
- 예외 처리를 한다고 생각했을 때 API는 에러를 담은 메세지를 보내고 뭐 템플릿 엔진은 에러를 보여주는 html을 보내는 느낌의 차이랄까..
- @RequestBody : Body에 넣어서 들어온 json을 뒤의 객체에 매핑해서 넣어줌
- 컨트롤러 단에서 entity를 바로 받으면 안되는 이유
- 컨트롤러까지를 프레젠테이션 계층이라 하는데 만약 엔티티에 valid를 설정한 상태에서 어떤 api는 해당 validation이 필요할 수도 있지만 어떤 api는 필요로 하지 않을 수 있다.
- 또한, entity에서 이름을 수정하는 경우 api spec이 바뀌는 것이 되니 매우 좋지 않다.
- DTO만들어서 써라 .
- Validation도 그냥 해당 DTO에 붙여 쓰면 됨
- restful api 스타일 이렇게 검색하면 restful하게 api짜는 법이 나옴
- DTO에선 그냥 lombok 많이 써도 ㄱㅊ
- update같이 변경을 하는 목적인 경우 커맨드 형태로 쿼리와 같이 섞이는 것이 좋지 않다. 그렇기에 굳이 엔티티를 반환하는게 아닌 void로 리턴하고 필요하다면 따로 Find메소드(쿼리)로 찾아주는게 좋다.
- 만약 Member를 리턴하는 회원 조회 여기서 참조중인 orders를 노출시키고 싶지 않다면 @JsonIgnore를 해주면 된다
- 근데, 만약 다른 회원 조회 api에선 orders가 필요하다면 ?
- 답이 안나오지.. 그러니까 DTO만들어서 써야지!
- 근데, 만약 다른 회원 조회 api에선 orders가 필요하다면 ?
- 이렇게 스펙이 변경되는 경우를 대비하고 또 요구사항이 변경되는 경우에 대비해야 한다
- 예를들어 회원 배열을 리턴하다가 회원 숫자도 같이 달라고 하면 새로 다시 다 짜는 것 보단, DTO를 활용해서 확장하는 것이 훨씬 간편하다.
API개발 고급-지연 로딩과 조회 성능 최적화
- 지연로딩으로 발생하는 성능 문제를 단계적으로 해결한다.
- 실무에서 JPA를 사용하려면 이건 무조건 100%알아야 한다.
- 엔티티를 직접 노출할 때 양방향 연관관계가 설정된 곳은 꼭 한곳을 JsonIgnore처리 해야 함(아니면 무한 루프 발생)
- 뭐 양방향 무한 참조 해결하려고 JsonIgnore를 달아도 xToOne관계들에 묶여 있고 지연로딩을 설정한 경우, 엔티티를 직접 반환하는 경우
- ByteBuddyInterceptor라는 proxy객체가 들어가 있게 됨
- 근데, 그냥 jackson이 참조하려 했는데 bytebuddy형태인 객체를 참조할 수 없기에 오류를 일으킴
- Hibernate5 모듈이란걸 설치해서 이런 프록시 객체는 null로 설정하고 보내게 설정할 수 있긴 함
- module.configure(Hibernate5Module.Feature.*FORCE_LAZY_LOADING*,true);
- 이렇게 설정하면 값을 다 지연로딩으로 데리고 오긴 함
- 근데, 이러면 연관되어 있는 애들 다 데리고 오려고 오만 쿼리를 다 쏴버림
- 즉, 성능상 낭비가 발생한다
- 그냥 DTO를 쓰자 !
- Option+엔터가 참 많은 리팩토링을 제공함. 람다표현, static import ..
- 그냥 DTO만 쓴다 해도 어차피 lazyLoading은 하게 됨(DTO에서 참조하는 테이블의 필드에 접근 한 경우 프록시가 초기화 되기 때문)
- 그렇다고 Eager로 설정한다면, 어차피 쿼리는 나가고 성능은 성능대로 안좋고 예측할 수 없는 쿼리가 나가기도 함(연관관계 때문)
- fetch join을 사용해서 해결 해야 함
- jpql에서 join fetch는 연속해서 여러개 쓸 수 있음
- 이렇게 되면 그냥 쿼리 한방으로 원하는 엔티티들을 다 가져올 수 있음
- 즉, order -member,delivery인 상황에서 member와 delivery를 한번에 다 가져오면 쿼리가 더 나갈 이유가 없는 것
- 여기서 뭐 조인한 애들에서 필요없는 필드까지 데리고 오는 약간의 낭비는 발생하게 됨
- 이걸 해결하기 위해 JPA에서 DTO로 바로 뽑는 것 (약간의 성능 최적화 가능)
- select에서 new DTO경로(생성자에 넣을 값) 이렇게 하고 join으로 필요한 애들 다 데리고 오는 것
- 하지만 이렇게 하는게 무조건 적으로 좋다고는 할 수 없다
- 외부의 껍데기를 조작하지 않고 내부에서 join fetch사용하는 것은 다른 곳에서도 유연하게 사용할 수 있지만
- 이렇게 사용하는 경우 성능적으로 좀 더 최적화는 되지만, fit하게 사용되는 메소드이므로 유연한 확장이 불가능 하다
- 지저분하기도 좀 더 지저분하고..
- 정리하자면, select절에서 원하는 데이터를 직접 선택하니 DB→애플리케이션간 네트워크 용량을 최적화할 수 있지만 생각보다 미비한 효과이고, 리포지토리 재사용성이 떨어지며 API스펙에 맞춘 코드가 리포지토리에 들어간다는 단점이 존재한다
- 논리적으로 보면 api spec이 바뀐다 → Repository 코드가 바뀐다 (계층간 분리가 되지 않았다)
- 이런 tradeOff가있는데, 뭐 .. 대부분은 fetch join과 성능 차이는 나지 않는다.
- 막 데이터가 엄청나게 크고 select에 사용되는 필드가 너무나도 많다면.. 사용을 고려해볼 필요가 있긴 함
- 영한님은 뭐 이런 최적화를 위한 쿼리는 따로 패키지 파서 repository를 따로 배치한다고 하심
API개발 고급- 컬렉션 조회 최적화
- xToMany 관계를 조회 해 보자 !
- DB입장에서 일대다 관계를 조인하게 되면 데이터가 뻥튀기가 된다. →최적화가 어려워진다.
- 마찬가지로 엔티티 리스트 직접 노출하는 경우 뭐 프록시 강제 초기화 시켜주고 리턴하면 되긴 하지만 이러면 역시 안!된!다!
- 어떤 엔티티를 DTO로 변경했는데 이 DTO 내에 또 엔티티가 존재한다면 해당 엔티티 또한 DTO로 변환해야 한다
- Address같은 값 형은 노출해도 됨
- 만약 주문이 2개고 주문 당 orderitem이 2개면 둘을 조인하면 row가 4개가 된다 ㅇㅇ
- 그러면 jpa에서 orderItem을 fetch join으로 해서 order를 가져오면 이것도 4개가 됨
- 주소값까지 같은 order객체가 두배가 되니 의미 없는 order가 발생한다
- select 절에 distinct를 추가하면 이런 중복을 제거할 수 있긴 함
- 사실 DB에서는 distinct가 있어도 다른 attribute의 값이 다르니 구별이 되어 똑같은 값을 리턴함
- 대신, JPA에서 DB에서 받아온 값을 들고 Order객체에 대해서만 중복을 확인하고 제거하는 것
- 경고 로그 띄우고 실제 DB에서 페이징을 하는게 아니라 메모리에서 처리한다
- 데이터가 많은 상태로 이걸 해버리면 Out of Memory 뜨고 죽어버리는거임
- 생각해보면, 컬렉션 조회를 하면 중복된 order가 존재하게 됨. 즉, 예시 상황에서 똑같은 주문이 2개씩 들어있음. 그러면 페이징을 하게 되면 주문을 1번 인덱스부터 1개 뽑아서 페이징하면 상식적으로 1번 인덱스(2번째 주문)이 나와야 되는데, 실제 DB에는 1번째 주문이 1번 인덱스에 위치하기에(중복) 잘못된 페이징이 된다. 이 때문에, JPA가 경고 문구를 띄우고 자기가 메모리 써서 중복제거하고 페이징하는 것 </aside>
- 컬렉션 조회 (ToMany)에서 페이징을 적용하는 방법
- 일대다 관계에서 1을 기준으로 페이징하는 것이 목적인데 데이터는 다를 기준으로 row가 생성됨
- 즉, order를 기준으로 페이징하고 싶은데 orderitem기준으로 페이징이 됨
- 방법
- ToOne관계를 모두 페치조인한다 → 어차피 ToOne관계는 row를 증가시키지 않으니 페이징 쿼리에 영향을 주지 않음
- Order → Delivery가 ManyToOne이니 추가해도 영향이 없다
- 여기서 Delivery가 또 뭐 Post를 ManyToOne관계로 가지면 이것도 같이 조회해도 영향 X
- 컬렉션은 그냥 지연 로딩을 사용해서 조회
- 지연 로딩 성능을 최적화 한다
- hibernate.default_batch_fetch_size 설정 : 전역 설정
- 주로 이렇게 전역 설정을 사용한다.
- @BatchSize 설정 : 개별(엔티티별) 최적화
- order → orderitem → item 이렇게 1nm 인 관계를 배치를 이용하면(물론, 각 n,m이 설정한 배치 크기보다 작다는 가정하에) 1+1+1 이렇게 쿼리의 수가 훨씬 줄어들게 됨
- hibernate.default_batch_fetch_size 설정 : 전역 설정
- ToOne관계를 모두 페치조인한다 → 어차피 ToOne관계는 row를 증가시키지 않으니 페이징 쿼리에 영향을 주지 않음
- 이 방법의 장점
- v3처럼 페치조인으로 데리고온 경우 어쨌든 DB에서 데이터를 데리고 올 때 데이터의 중복은 제거되지 않은 상태로 오게된다
- 따라서, DB에서 데이터를 전송하는 양이 많아진다
- 근데 이렇게 지연 로딩 최적화를 걸면 중복된 데이터를 애초에 만들지 않기에 DB데이터 전송량이 감소하게 된다.
- 그냥 생짜로 짠 v1과 비교하면 1+N → 1+1로 쿼리 호출 수가 줄어든다.
- v3와 달리 페이징이 가능하다.
- v3처럼 페치조인으로 데리고온 경우 어쨌든 DB에서 데이터를 데리고 올 때 데이터의 중복은 제거되지 않은 상태로 오게된다
- tradeoff
- batch size크기를 보통 100~1000으로 설정하는데, 100로 하면 호출하는 쿼리의 수가 증가하게 되고 1000으로 하면 DB에서 1000개를 한번에 불러오니 순간적으로 부하가 발생하게 됨 → 순간 부하를 견딜 수 있을 정도로 설정해야한다.
- 약간 뭐, 연탄 나르는데 한번에 5장 주는거랑 10장주는 느낌이랄까.. 많이주면 들기 힘들지만 후딱 끝나지
- DTO 직접조회 with 컬렉션
- ToOne은 join ToMany는 loop돌면서 join
- ToOne인 엔티티들은 그냥 조인해서 데리고오고
- ToMany인 엔티티들은 루프를 돌면서 컬렉션의 갯수 N번만큼 쿼리를 날려 데리고 온다
- 즉. N+1번 의 쿼리를 날림
- 컬렉션 조회 최적화
- ToOne 모두 한번에 join해서 들고오기
- 해당 Order의 id들을 들고 in절을 이용해서 한번에 orderItem을 다 들고오기
- 받아온 orderItemDto List를 stream의 collect.groupingBy를 이용해서 Map에 넣기
- order에서 setOrderItem이건 이제 map에서 orderId로 꺼내서 넣어주면 됨
- 즉, ToOne에 대한 쿼리1번, In절에 대한 쿼리 1번 총2번의 쿼리가 발생
- 플랫 데이터 최적화
- 필요한 애들 전부 다 join 한다
- 이걸 통째로 받는 DTO를 짜서 이 데이터들을 받는다
- 이걸 이용해서 필요한거 슉슉 넣어서 원하는DTO를 만들어 낸다(노가다 작업)
- 쿼리는 총 1번밖에 나가지 않음
- 근데, ToMany를 조인했으니 데이터의 중복이 발생하고 데이터가 뻥튀기 됨
- 그래서 상황에 따라 오히려 컬렉션 조회 최적화 보다 더 느릴 수 있음
- 그리고, 원하는 스펙의 dto로 중복제거하고,., 매핑하고 이런 노가다를 해야 함
- 페이징도 불가능하다.(데이터 중복 때문에 원하는 페이징 결과가 만들어지지 않게 됨)
- ToOne은 join ToMany는 loop돌면서 join
- 정리
- 컬렉션 조회 방법
- 엔티티로 조회한다
- 그대로 반환 : XXXXX,V1
- 조회 후 DTO로 변환 : V2
- 페치 조인으로 최적화 : V3
- 컬렉션 페이징과 한계 돌파 : V3.1
- 컬렉션을 페치 조인하면 페이징이 안되니
- ToOne관계는 페치조인으로 최적화 하고
- 컬렉션은 지연 로딩을 유지하는 대신 배치 사이즈를 줘서 한번에 좀 크게 데리고 오는 것
- DTO로 직접 조회한다
- JPA에서 DTO 직접 조회 : V4
- 단건 조회의 경우 코드가 간단하기에 이 방식이 좋다
- 컬렉션 조회 최적화 : V5
- IN절을 활용해 메모리에 미리 조회해서 최적화
- 코드가 복잡하다..그래도 쿼리의 수가 압도적으로 줄어든다.
- 플랫 데이터 최적화 : V6
- 전부 몽땅 다 조인한 결과를 원하는 스펙에 맞게 가공
- V5와 성능 차이도 미미하고 노가다는 많이 필요해지며 코드도 복잡하다
- JPA에서 DTO 직접 조회 : V4
- 엔티티로 조회한다
- 권장 순서
- 엔티티 조회 방식으로 접근
- 페치조인
- 페이징 필요 시 배치 사이즈 주고 컬렉션은 지연로딩
- 해결이 안되면 DTO직접 조회
- DTO직접 조회도 안되면 NativeSQL이나 JdbcTemplate사용
- 엔티티 조회 방식으로 접근
- 사실 엔티티 조회 방식으로 접근해서 페치조인 정도만 사용해줘도 거의 다 해결이됨
- 정말 트래픽이 많은 경우 다른 방식으로 최적화하는 것 보단 캐시를 사용하는게 좋음
- 참고로, 엔티티를 직접 캐시에 올리는 것은 바람직 하지 않음
- 컬렉션 조회 방법
API개발 고급-실무 필수 최적화
- Open Session In View : OSIV -하이버네이트 에서 만든 것
- JPA에선 Open EntityManager In VIew이긴 한데, 관례상 OSIV라 함
- spring-jpa.open-in-view : (true가 기본 값)
- spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
- OSIV를 켜면 이런 경고가 뜸
- 경고를 남기는 것은 당연히 주의가 필요하다는 뜻
- JPA가 데이터베이스 커넥션을 가지고 오는 시점은 데이터 베이스 트랙잭션을 시작할 때이다.
- 뭐 서비스나 레포지토리 등등에서 뭐 transactional 어노테이션을 붙여 트랜잭션을 시작 함
- 또, 끝나는 시점은 OSIV설정에 따라 다름
- OSIV가 켜져있다면
- Transaction이 끝난 후에도 반환을 하지 않음
- 서비스 계층에서 트랜잭션 끝나고 컨트롤러에서도 또 lazy loading이 일어나거나 할 수 있음
- 즉, 영속성 컨텍스트가 트랜잭션이 끝났다고 사라지면 안됨
- 그래서, OSIV가 켜져있다면 고객의 요청이 들어왔을 때 api 컨트롤러면 완전히 반환할 때 까지 살아있고
- 뷰템플릿에 쓰이는 경우 렌더링이 다 완성되고 response가 유저에게 다 나간뒤에 사라짐
- OSIV가 켜져있었기에 커넥션이 유지되어 view template이나 api컨트롤러에서 지연 로딩이 가능했던 것
- 이 전략은 너무 오랫동안 커넥션을 물고있어 커넥션 리소스를 사용하기에 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있고 이는 곧 장애로 이어진다
- OSIV OFF
- 트랜잭션이 끝나면 커넥션도 끝내버린다(영속성 컨텍스트를 죽인다)
- 커넥션 리소스를 낭비하지 않음
- 트랜잭션 범위 외에선 이제 지연로딩을 사용할 수 없게 됨(영속성 컨텍스트가 죽었고 db커넥션도 잃었기 때문)
- 즉, 트랜잭션 내에서 지연로딩 처리를 해야 한다.
- 마찬가지로, view template에서 지연로딩이 동작하지 않는다.
- 실무에서 OSIV를 끄고 복잡성을 관리하는 좋은 방법이 Command와 Query를 분리하는 것
- 애플리케이션이 좀 사이즈가 큰 경우 어떤 쿼리형 서비스를 따로 분리해서 만드는 것도 좋다
- 즉, 핵심 비즈니스 로직을 구현한 서비스와
- 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션을 사용)
- 로 서비스를 분리하는 것
- 보통 핵심비즈니스 로직은 특정 엔티티 몇개를 등록,수정하는 것이므로 성능에 큰 영향을 주지 않음
- 잘 변경되지 않음, 라이프 사이클이 길다
- 하지만, 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요해짐
- 잘 변경됨, 라이프 사이클이 짧음
- ADMIN처럼 커넥션을 거의 안쓰는 곳에서는 OSIV를 켜고 고객 서비스의 실시간 API는 OSIV를 끈다.
- 참고
- 화면에 호출하기 위한 조회 로직과, 나머지 생성, 수정, 삭제, 일반적인 조회 로직들을 분리 하는 패턴을 CQRS 패턴이라 한다
- https://learn.microsoft.com/ko-kr/azure/architecture/patterns/cqrs
- 트랜잭션이 끝나면 커넥션도 끝내버린다(영속성 컨텍스트를 죽인다)
- OSIV가 켜져있다면
Spring data jpa
- https://spring.io/projects/spring-data-jpa
- 정해진 규칙 맞춰서 넣으면 이 인터페이스를 spring data jpa가 알아서 구현해서 DI해주는 것
- 실무에서 사용해도 되는 부분과 data jpa의 한계점을 확실하게 알고 써야 한다.
- 뭐 페이징할때 total count 날라갈 때 발생할 수 있는 문제 같은게 있다고 함..
- (지갑이..)여유되면 spring data jpa강의도 구매해야 할 지도..?
- 그래도, 매뉴얼만 어느정도 잘 숙지하고 사용하면 사용할 수 있다고 하심 !
QueryDSL
- 동적쿼리를 작성할 일이 실무에선 꽤 많음
- 사용하려면 Q~이런걸 생성해야 함
- 버전마다 설정 방법이 조금씩 달라지니 구글링해서 적용..
- 다 자바 코드이니 컴파일 시점에 오류가 잡히고 코드 어시스턴스도 된다
- select에 DTO 박아넣던것도 훨씬 깔끔하고 편하게 작성할 수 있게 된다
- JPQL의 new명령어와 비교도 안될 정도로 깔끔한 DTO조회를 지원한다.
- 코드 재사용성이 높아짐
- 뭐 where절에 넣을 비교 문을 여러 메소드에서 사용할 수 있다