실전! 스프링 부트와 JPA 활용 1, 2편을 듣고
1. 들어가며
김영한님의 강의에서 가장 실전적인 파트를 맡고 있는 강의인 "실전! 스프링 부트와 JPA 활용 1, 2편을 듣고"를 요약하고자 합니다. 1편의 경우 Spring 프로젝트 경험이 있다면, 그리고 성공적으로 수행해냈다면 생략해도 될 것 같습니다. 저도 요약에 있어서 굳이 둘을 나눌 필요는 없을 것 같아 하나로 합쳐서 정리하고자 합니다 :)
2. 엔티티 클래스 개발
Entity는 Database의 Model에 해당되는 부분입니다. 하지만 저희는 JPA를 활용하여 '객체지향적'으로 데이터베이스를 활용하고자 합니다. 따라서 data structure처럼 Table을 Entity로 정의하고 Getter와 Setter를 모두 정의하여 사용하기보단 별도의 메서드를 정의하여 객체처럼 다루는 것이 이상적입니다.
하지만 사실 Getter의 경우 쓰일 일이 너무나도 많습니다. 그래서 Getter는 되도록 열어두고 Setter만 비즈니스 메서드로 별도로 제공하는 편을 김영한님은 권장하고 있습니다. 이럴 경우 Domain의 응집도도 높아지면서 예상치 못한 변경에도 어느정도 면역이 생기는 구조가 될 것입니다.
또한 모든 연관관계는 지연로딩으로 설정하고 연관된 엔티티를 함께 조회해야할 경우 fetch join 혹은 Entity Graph를 사용할 것을 권장하고 있습니다. 특히 @XtoOne의 경우 기본이 EAGER이므로 직접 지연로딩으로 지정해줘야 합니다.
또한 컬렉션의 경우 필드에서 바로 초기화하는 것을 권장합니다. 우선 null 문제에서 안전하고, 하이버네이트의 엔티티 영속화 문제에서도 안전합니다.
3. 어플리케이션 전체 개발
전체적인 아키텍쳐 개발의 경우 MVC1에서 정리하기도 했으므로 새로운 것만 정리하도록 하겠습니다.
- 테스트 격리 : 테스트는 격리된 환경에서 실행하고 끝나면 데이터를 초기화하는 것이 좋습니다. 그런 면에서 메모리 DB를 활용해봐도 좋습니다. 또한 테스트 케이스를 위한 스프링 환경을 독립적으로 설정하는 것도 좋습니다. 이를 위해서는 테스트용 설정 파일을 따로 추가해주면 됩니다.
- 변경 감지와 병합 : 준영속 엔티티란 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말합니다. 이러한 준영속 엔티티를 수정하는 방법엔 크게 2가지가 있습니다.
- 변경 감지 기능 사용 : 영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item findItem = em.find(Item.class, itemParam.getId()); //같은 엔티티를 조회한다.
findItem.setPrice(itemParam.getPrice()); //데이터를 수정한다.
}
- 병합 사용 : 준영속 상태의 엔티티를 영속 상태로 변경
@Transactional
void update(Item itemParam) { //itemParam: 파리미터로 넘어온 준영속 상태의 엔티티
Item mergeItem = em.merge(itemParam);
}
준영속 엔티티의 식별자 값으로 영속 엔티티를 조회한 후 영속 엔티티 값을 준영속 엔티티 값으로 모두 교체하는(병합) 방식입니다. 병합의 경우 모든 속성이 변경되므로 주의해야 합니다. save()의 경우 저장과 병합을 모두 수행하고 있습니다. 즉 신규 데이터를 저장하는 것뿐만 아니라 변경된 데이터의 저장이라는 의미도 포함합니다. 근데 실무에서 update가 모든 필드를 바꾸는 일은 흔치 않습니다. 오히려 번거로울 때가 많습니다.
따라서 엔티티를 변경할 때는 변경 감지를 사용하는 것이 좋습니다. 컨트롤러에서 어설프게 엔티티를 생성하지 않고 트랜잭션이 있는 서비스 계층에 식별자와 변경할 데이터를 명확히 전달한 뒤 그 계층에서 직접 변경하여 변경감지가 동작하도록 합시다.
4. API 개발 및 최적화
- DTO의 필요성
API를 만들 땐 Entity를 외부에 노출해서는 안됩니다. 해당 엔티티를 특정 API에서만 사용할 일은 거의 없습니다. 그런데 Entity를 API에 그대로 사용하게 되면 Entity가 해당 API의 스펙을 위해 영향을 받게 되고, 보안에도 좋지 않습니다. 예를 들어 엔티티를 직접 노출하게 되면 양방향 연관관계가 걸려 있을 때 한 곳은 @JsonIgnore가 걸려 있어야 합니다. 안 그럼 json parsing 하면서 서로를 무한히 호출하게 됩니다. 이렇게 모든 API 스펙에 대해 확장성 있게 코드를 짤 수 있을까요? 따라서 API 스펙에 맞는 별도의 DTO를 만드는 것이 좋습니다. - N+1 문제 다루기 - 일대다 :
JPA를 활용할 때 일대다 연관관계는 어떻게 다뤄야 할까요? 예를들어 Entity를 불러올 때 해당 Entity에 지연로딩으로 프록시 객체가 대신 들어가 있는 연관관계 Entity들을 함께 사용해야할 일이 있으면 어떻게 될까요?
가장 간단하게 접근하면 getter를 통해 프록시 객체 대신 실제 객체를 가져오도록 쿼리를 날리는 것입니다.
문제는 이렇게 될 경우 N+1 문제가 발생하게 됩니다. Entity를 N개 가져오게 되면 N개 만큼 쿼리가 더 발생하여 N+1 문제라고 합니다. 매번 getter를 사용할 때마다 프록시 객체 대신 실제 Entity를 가져오기 위해 쿼리를 날리므로 그만큼 많은 쿼리가 전송되고 DB의 과부하로 이어집니다.
이를 해결하기 위해서 김영한님은 두 가지 해결 방안을 제시합니다.
- fetch Join 최적화
JPQL에서 제공하는 fetch Join을 이용하여 쿼리 1번으로 연관된 객체들을 한번에 가져오는 방법입니다. JPQL에서 일반 join은 연관 Entity까지는 초기화하지 않습니다. 따라서 fetch join을 사용해야 합니다.
- JPA에서 DTO 바로 조회
혹은 일반 SQL을 사용할 때처럼 원하는 값을 선택해서 조회하는 방법입니다. JPQL은 Entity뿐만 아니라 DTO와 같은 객체도 조회할 수 있습니다. new로 해당 객체를 JPQL 내부에 넣고 원하는 값을 조회해옵니다. 다만 이 방법은 코드 가독성이 좀 떨어진다는 단점이 있습니다.
따라서 김영한님은 다음과 같은 순서를 권장합니다.
1. 우선 엔티티를 DTO로 변환하는 방법을 선택한다.
2. 필요하면 페치 조인으로 성능을 최적화 한다. 대부분의 성능 이슈가 해결된다.
3. 그래도 안되면 DTO로 직접 조회하는 방법을 사용한다.
4. 최후의 방법은 JPA가 제공하는 네이티브 SQL이나 스프링 JDBC Template을 사용해서 SQL을 직접 사용한다.
3. N+1 문제 다루기 - 다대일
다대일의 경우 문제가 조금 더 복잡해집니다. 우선 위처럼 fetchJoin을 사용할 수는 있습니다. 문제는 이럴 경우 페이징에 문제가 생깁니다.
- hibernate는 컬렉션 페치 조인을 할 경우 모든 데이터를 DB에서 가져와 메모리에서 페이징합니다. 또한 컬렉션 페치 조인은 1개만 사용할 수 있습니다.
- 컬렉션을 fetch join하면 일대다 join이 발생하므로 데이터가 예측할 수 없이 증가합니다.
- 일대다에서는 일(1)을 기준으로 페이징하는 것이 보통 목적이지만 데이터는 다(N)을 기준으로 row가 생성됩니다.
이를 위해서 강의에서는 다음과 같은 방법을 제시합니다.
1) 먼저 ToOne 관계를 모두 fetch join 해온다.
2) collection은 지연 로딩으로 조회해온다.
3) 지연 로딩 성능 최적화를 위해 hibernate.default_batch_fetch_size , @BatchSize 를 적용한다.
-> 이 option을 사용할 경우 collection이나 프록시 객체를 한꺼번에 설정한 size만큼 IN (hibernate 6.2 부터는 array_contains) 쿼리로 조회해온다.
아니면 위처럼 JPA에서 DTO로 직접 조회해오는 것도 가능합니다. 이럴 경우 저자님이 제시한 방법처럼 여러 쿼리가 나가지 않고 하나의 쿼리로도 할 수 있습니다. 다만 join으로 성능이 저하되고 코드 가독성이 떨어진다는 단점이 있습니다만 쿼리 한번이라는 이점이 성능상 이점이 있을 확률이 높습니다.
4. OSIV(Open Session In View)와 성능 최적화
OSIV 전략이란 데이터베이스 커넥션과 영속성 컨텍스트를 어디까지 유지할지에 대한 선택 여부를 정하는 전략입니다.
지연 로딩은 영속성 컨텍스트가 살아있어야 가능하며 영속성 컨텍스트는 데이터 베이스 커넥션을 유지시켜줍니다.
하지만 true 설정이라면 커넥션 시작부터 API 응답까지 계속 데이터베이스 커넥션 리소스를 사용하기 때문에 커넥션이 말라버릴 수 있다는 위험이 있습니다.
반대로 OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환합니다. 따라서 커넥션 리소스 를 낭비하지 않습니다. OSIV를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭 션 안으로 넣어야 하는 단점이 있습니다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 합니다.
하지만 실무에서 트래픽이 아주 큰 상황이면 OSIV를 꺼야 하는 상황이 있을 수 있습니다. 이를 위해 강의에서는 Command와 Query를 분리하는 것을 권장합니다. 예를 들어 주문 서비스라면
위와 같이 구분할 수 있습니다.