객체지향 쿼리

EntityManager.find() 메소드를 사용하면 식별자로 엔티티 하나를 조회할 수 있습니다. 이렇게 조회한 엔티티에 객체 그래프 탐색을 사용하면 연관된 엔티티들을 찾을 수 있습니다.

  • 식별자로 조회(EntityManager.find())
  • 객체 그래프 탐색(getB(), getC())

하지만 이기능만으로 애플리케이션을 개발하기는 어렵습니다. 예를 들어 나이가 30살 이상인 회원을 모두 검색하고 싶다면 좀 더 현실적이고 복잡한 검색 방법이 필요합니다. 그러핟고 모든 회원 엔티티를 메모리에 올려두고 어플리케이션에서 30살 이상인 회원을 검색하는 것은 현실성이 없습니다. JPQL은 이런 문제를 해결하기 위해 만들어졌는데 아래와 같은 특징을 가집니다.

  • 테이블이 아닌 객체를 대상으로 검색하는 객체지향 쿼리 입니다.
  • SQL을 추상화해서 특정 데이터베이스 SQL에 의존하지 않습니다.

JPA는 이 JPQL을 분석한 다음 적절한 SQL을 만들어 데이터베이스를 조회합니다. 그리고 조회한 결과로 엔티티 객체를 생성해서 반환합니다.
JPQL은 한마디로 정의하면 객체지향 SQL이라고 생각하면 됩니다. JPA가 공식 지원하는 기능은 JPQL, Criteria 쿼리, 네이티브 SQL, QueryDSL 등이 있습니다.

Criteria 쿼리: JPQL을 편하게 작성하도록 도와주는 API, 빌더 클래스 모음

네이티브 SQL: JPA에서 JPQL 대신 직접 SQL을 사용할 수 있습니다.

QueryDSL: Criteria 쿼리처럼 JPQL을 편하게 작성하도록 도와주는 빌더 클래스 모음, 비표준 오픈소스 프레임워크 입니다.

JDBC 직접 사용, MyBatis와 같은 SQL 매퍼 프레임워크 사용: 필요하면 JDBC를 직접 사용할 수 있습니다.

여기서 가장 중요한건 JPQL입니다. Criteria나 QueryDSL은 JPQL을 편하게 작성하도록 도아주는 빌더 클래스일 뿐입니다. 따라서 JPQL을 이해해야 나머지도 이해할 수 있습니다.

JPQL 소개

JPQL은 엔티티 객체를 조회하는 객체지향 쿼리입니다. 문법은 SQL과 비슷하고 ANSI 표준 SQL이 제공하는 기능을 유사하게 지원합니다.
JPQL은 SQL을 추상화해서 특정 데이터베이스에 의존하지 않습니다. 그리고 데이터베이스 방언만 변경하면 JPQL을 수정하지 않아도 자연스럽게 데이터베이스를 변경할 수 있습니다. 예를들어 같은 SQL 함수라도 데이터베이스마다 사용 문법이 다른 것이 있는데, JPQL이 제공하는 표준화된 함수를 사용하면 선택한 방언에 따라 해당 데이터베이스에 맞춘 적절한 SQL 함수가 실행됩니다.

JPQL은 SQL보다 간결합니다. 엔티티 직접 조회, 묵시적 조인, 다형성 지원으로 SQL보다 코드가 간결합니다.

회원 엔티티를 대상으로 JPQL을 사용하는 간단한 예제를 살펴보겠습니다.

@Entity(name = "Member")
public class Member{

    @Column(name = "name")
    private String username;

}

//JPQL 사용
String jpql = "select m from Member as m where m.username = 'kim'";
List<Member> resultList = em.createQuery(jpql, Member.class).getResultList();

위의 코드에서 보면 회원이름이 kim인 엔티티를 조회합니다. JPQL에서 Member는 엔티티 이름입니다. 그리고 m.username은 테이블 컬럼명이 아니고 엔티티 객체의 필드명입니다.

em.createQuery() 메소드에 실행할 JPQL과 반환할 엔티티의 클래스 타입인 Member.class를 넘겨주고 getResultList() 메소드를 실행하면 JPA는 JPQL을 SQL로 변환해서 데이터베이스를 조회합니다. 그리고 조회한 결과로 Member 엔티티를 생성해서 반환합니다.

실제로 실행한 JPQL은 아래와 쿼리문으로 전송됩니다.

select 
    member.id as id,
    member.age as age,
    member.tema_id as team,
    member.name as name
from 
    Member member
where
    member.name = 'kim'

참고로 하이버네이트 구현체가 생성한 SQL은 별칭이 너무 복잡해서 알아보기 힘듭니다.

Criteria 쿼리 소개

Criteria는 JPQL을 생성하는 빌더 클래스입니다. Criteria의 장점은 문자가 아닌 query.select(m).where(...)처럼 프로그래밍 코드로 JPQL을 작성할 수 있다는 점입니다. 예를 들어 JPQL에서 select m from membeeee m 처럼 오타가 있다고 가정해보겠습니다. 그래도 컴파일은 성공하고 애플리케이션을 서버에 배포할 수 있습니다. 문제는 해당 쿼리가 실행되는 런타임 시점에 오류가 발생한다는 점입니다. 이것이 문자기반 쿼리의 단점입니다. 반면에 Criteria는 문자가 아닌 코드로 JPQL을 작성합니다. 따라서 컴파일 시점에 오류를 발견할 수 있습니다.

문자로 작성한 JPQL보다 코드로 작성한 Criteria의 장점은 아래와 같습니다.

  • 컴파일 시점에 오류를 발견할 수 있습니다.
  • IDE를 사용하면 코드 자동완성을 지원합니다.
  • 동적 쿼리를 작성하기 편합니다.

JPA는 2.0부터 Criteria를 지원합니다.

// Criteria 쿼리
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Member> query = cb.createQuery(Member.class);

// 루트 클래스(조회를 시작할 클래스)
Root<Member> m = query.from(Member.class);

// 쿼리 생성
CriteriaQuery<Member> cq = query.select(m).where(cb.equal("username"), "kim"));

List<Member> resultList = em.createQuery(cq).getResultList();

위의 쿼리를 보면 문자가 아닌 코드로 작성한 것을 확인할 수 있습니다. 안타깝게도 m.get("username")을 보면 필드 명을 문자로 작성했습니다. 만약 이 부분도 문자가 아닌 코드로 작성하고 싶다면 메타 모델을 사용하면 됩니다.
메타 모델 API에 대해 알아보겠습니다. 자바가 제공하는 어노테이션 프로세서 기능을 사용하면 어노테이션을 분석해서 클래스를 생성할 수 있습니다. JPA는 이 기능을 사용해서 Member 엔티티 클래스로부터 Member_라는 Criteria 전용 클래스를 생성하는데 이것을 메타 모델이라고 합니다. 메타 모델을 사용하면 온전한 코드만 사용해서 쿼리를 작성할 수 있습니다.

m.get("username") -> m.get(Member_.username)

이것만 보면 Criteria가 가진 장점이 많지만 모든 장점을 상쇄할 정도로 복잡하고 장황합니다... 너무 TMI 같은 느낌이죠. 따라서 사용하기 불편한 건 물론이고 Criteria로 작성한 코드도 한눈에 들어오지 않는다는 단점이 있습니다.

QueryDSL 소개

QueryDSL도 위에서 살펴본 Criteria처럼 JPQL 빌더 역할을 합니다. QueryDSL의 장점은 코드 기반이면서 단순하고 사용하기 쉽습니다. 그리고 작성한 JPQL과 비슷해서 한눈에 들어옵니다. QueryDSL과 Criteria를 비교하면 Criteria는 너무 복잡합니다.

참고로 QueryDSL은 JPA 표준은 아니고 오픈소스 프로젝트입니다. 이것은 JPA뿐만 아니라 JDO, 몽고 DB, Java Collection, Lucene, Hibernate Search도 거의 같은 문법으로 지원합니다. 현재 스프링 데이터 프로젝트가 지원할 정도로 많이 기대되는 프로젝트 입니다.

QueryDSL로 작성한 코드

JPAQuery query = new JPAQuery(em);
QMember member = QMember.member;

// 쿼리, 결과조회
List<Member> members = query.from(member)
                            .where(member.username.eq("kim"))
                            .list(member);

보셨나요? 위의 Criteria와 비교했을때... 그냥 보기만해도 어떤 코드를 리턴할지 예상이 됩니다. QueryDSL도 어노테이션 프로세서를 사용해서 쿼리 전용 클래스를 만들어야 합니다. QMember는 Member 엔티티 클래스를 기반으로 생성한 QueryDSL 쿼리 전용 클래스입니다.

네이티브 SQL 소개

JPA는 SQL을 직접 사용할 수 있는 기능을 지원하는데 이것을 네이티브 SQL이라고 합니다.
JPQL을 사용해도 가끔은 특정 데이터베이스에 의존하는 기능을 사용해야 할 때가 있습니다. 예를 들어 오라클 데이터베이스만 사용하는 CONNECT BY 기능이나 특정 데이터베이스에서만 동작하는 SQL 힌트는 같은 것입니다. 물론 하이버네이트는 SQL 힌트 기능을 지원합니다. 이런 기능들은 전혀 표준화되어 있지 않으므로 JPQL에서 사용할 수 없습니다. 그리고 SQL은 지원하지만 JPQL이 지원하지 않는 기능도 있습니다. 이때는 네이티브 SQL을 사용하면 됩니다.

네이티브 SQL의 단점은 특정 데이터베이스에 의존하는 SQL을 작성해야 한다는 것입니다. 따라서 데이터베이스를 변경하면 네이티브 SQL도 수정해야 합니다.

String sql = "select ID, AGE, TEAM_ID, NAME FROM MEMBER WHERE NAME = 'kim'"l

List<Member> resultList = em.createNativeQuery(sql, Member.class)
.getResultList();

네이티브 SQL은 em.createNativeQuery()를 사용하면 된다. 나머지는 API는 JPQL과 같습니다. 실행하면 직접 작성한 SQL을 데이터베이스에 전달합니다.

참고: ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

JPA Auditing 기능이란?  (0) 2019.12.11
JPA에 대한 사실과 오해  (0) 2019.11.28
값 타입  (0) 2019.11.26
영속성 전이 및 고아객체 제거  (0) 2019.11.23
지연로딩과 즉시로딩  (0) 2019.11.23

JPA Auditing이란?

Java에서 ORM 기술인 JPA를 사용하여 도메인을 관계형 데이터베이스 테이블에 매핑할 때 공통적으로 도메인들이 가지고 있는 필드나 컬럼들이 존재합니다. 대표적으로 생성일자, 수정일자, 식별자 같은 필드 및 컬럼이 있습니다.

도메인마다 공통으로 존재한다는 의미는 결국 코드가 중복된다는 말과 일맥상통합니다.
데이터베이스에서 누가, 언제하였는지 기록을 잘 남겨놓아야 합니다. 그렇기 때문에 생성일, 수정일 컬럼은 대단히 중요한 데이터 입니다.

그래서 JPA에서는 Audit이라는 기능을 제공하고 있습니다. Audit은 감시하다, 감사하다라는 뜻으로 Spring Data JPA에서 시간에 대해서 자동으로 값을 넣어주는 기능입니다. 도메인을 영속성 컨텍스트에 저장하거나 조회를 수행한 후에 update를 하는 경우 매번 시간 데이터를 입력하여 주어야 하는데, audit을 이용하면 자동으로 시간을 매핑하여 데이터베이스의 테이블에 넣어주게 됩니다.

1. Auditi 사용 예제 코드

build.grade에 의존성 추가

dependencies {
compile('org.springframework.boot:spring-boot-starter-web')
compile('org.projectlombok:lombok')
compile('org.springframework.boot:spring-boot-starter-data-jpa')
}

기본적으로 스프링 부트에서 gradle로 의존성을 관리하게 될 경우 spring-boot-starter-data-jpa만 추가해도 Audit을 하는데는 문제가 없습니다.

참고로 자바 1.8 이상부터는 기존의 문제가 있던 Date, Calander 클래스를 사용하지 않고 LocalDate, LocalDateTime 클래스를 사용합니다. 또한 LocalDateTime 객체와 테이블 사이의 매핑이 안되던 이슈는 하이버네이트 5.2 버전부터 해결이 되었습니다.

BaseTimeEntity.java

@Getter
@MappedSuperclass 
@EntityListeners(AuditingEntityListener.class) 
public abstract class BaseTimeEntity{

    // Entity가 생성되어 저장될 때 시간이 자동 저장됩니다.
    @CreatedDate
    private LocalDateTime createdDate;

    // 조회한 Entity 값을 변경할 때 시간이 자동 저장됩니다.
    @LastModifiedDate
    private LocalDateTime modifiedDate;

}

 

 

어노테이션
설명
@MappedSuperclass JPA Entity 클래스들이 해당 추상 클래스를 상속할 경우 createDate, modifiedDate를 컬럼으로 인식
@EntityListeners(AuditingEntityListener.class 해당 클래스에 Auditing 기능을 포함
@CreatedDate Entity가 생성되어 저장될 때 시간이 자동 저장
@LastModifiedDate 조회한 Entity의 값을 변경할 때 시간이 자동 저장

클래스 상속

Posts.java

@Getter
@NoArgsConstructor
@Entity
public class Posts extends BaseTimeEntity{

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(length = 500, nullable = false)
    private String title;

    @Column(columnDefinition = "TEXT", nullable = false)
    private String content;

    private String author;

    @Builder
    public Posts(String title, String content, String author) {
        this.title = title;
        this.content = content;
        this.author = author;
    }

    public void update(String title, String content) {
        this.title = title;
        this.content = content;
    }
}

JPA Auditing 활성화

@EnableJpaAuditing 
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
}

Posts 클래스가 @MappedSuperclass가 적용된 BaseTimeEntity 추상 클래스를 상속하기 때문에 JPA가 생성일자, 수정일자 컬럼을 인식하게 됩니다. 그리고 영속성 컨텍스트에 저장 후 BaseTimeEntity 클래스의 Auditing 기능으로 인해서 트랜잭션 커밋 시점에 플러시가 호출할 때 하이버네이트가 자동으로 시간 값을 채워주는것을 확인 할 수가 있습니다.

스프링 부트의 Entry 포인트인 실행 클래스에 @EnableJpaAuditing 어노테이션을 적용하여 JPA Auditing을 활성화 해야하는 것을 잊지 말아야 합니다.

테스트 수행

    @Test
    public void BaseTimeEntity_등록() throws Exception{
        //given
        LocalDateTime now = LocalDateTime.of(2019,6,4,0,0,0);

        postsRepository.save(Posts.builder()
                .title("title")
                .content("content")
                .author("author")
                .build());

        //when
        List<Posts> postsList = postsRepository.findAll();
        Posts posts = postsList.get(0);

        System.out.println(">>createdDate="+ posts.getCreatedDate() + ", modifiedDate=" + posts.getModifiedDate());

        // then
        assertThat(posts.getCreatedDate()).isAfter(now);
        assertThat(posts.getModifiedDate()).isAfter(now);
    }

간단하게 테스트 코드를 작성하여 실제로 Auditing 기능이 활성화 되어 하이버네이트가 생성일자, 수정일자 값을 자동으로 채워주는지 확인해봤습니다.

실행결과

스크린샷 2019-12-11 오전 12 37 54

참조 문헌: 스프링 부트와 AWS로 혼자 구현하는 웹서비스

'SpringFramework > JPA' 카테고리의 다른 글

객체지향 쿼리 소개(Criteria, QueryDSL, Native Query)  (0) 2019.12.19
JPA에 대한 사실과 오해  (0) 2019.11.28
값 타입  (0) 2019.11.26
영속성 전이 및 고아객체 제거  (0) 2019.11.23
지연로딩과 즉시로딩  (0) 2019.11.23

JPA 사실에 대한 오해

NHN FORWARD 워크숍에 참가하여 신동민 개발자님이 발표한 JPA 사실에 대한 오해에 대해서 듣고 실습을 통해서 의미있는 유익한 시간을 보냈습니다.

JPA에 대해서 흔히 잘못알고 있는 사실 중 하나가 엔티티와의 연관관계는 단방향이면 매핑이 끝나는 것은 맞지만 성능상 일대다 단방향 관계를 가질때 자식엔티티를 영속석 전이를 통해 저장하게 되면 트랜잭션 커밋시점에 플러시가 호출되어 insert 쿼리가 발생 한 후에 자식 엔티티에 대해서 update 문이 자식엔티티 수만큼 수행되기 때문에 성능상의 문제가 발생합니다.

다음은 OrderDetail -> Order 단방향 연관관계를 가질때 발생하는 쿼리를 확인해 보겠습니다.

@ManyToOne 단방향일 경우

// Order(주문정보) 엔티티
@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;
}
// OrderDetails(주문내역) 엔티티
@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

    @ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private Order order;

    private String type;

    private String description;

}

간단한 테스트를 위해서 lombok을 이용하여 setter를 넣었지만 실환경에서 영속성을 이용한 수정 과정에서 공유 참조를 하여 실수로 값 변경이 잘못되면 큰 재앙을 초래하기 때문에 setXxx() 메소드를 써야할지 고민해야 된다고 생각합니다.

// 실행 클래스
@Bean
CommandLineRunner onStartUp(OrderService orderService) {
    return args -> {
        orderService.createOrderWithDetails();
    };
}

@Service
public class OrderService {

    private final OrderDetailRepository orderDetailRepository;


    public OrderService(OrderDetailRepository orderDetailRepository) {
        this.orderDetailRepository = orderDetailRepository;
    }


    @Transactional
    public void createOrderWithDetails() {
        Order order = new Order();
        order.setOrderDate(LocalDateTime.now());

        OrderDetail orderDetail1 = new OrderDetail();
        orderDetail1.setOrder(order);
        orderDetail1.setType("type1");
        orderDetail1.setDescription("order1-type1");

        OrderDetail orderDetail2 = new OrderDetail();
        orderDetail2.setOrder(order);
        orderDetail2.setType("type2");
        orderDetail2.setDescription("order1-type2");

        orderDetailRepository.saveAll(Arrays.asList(orderDetail1, orderDetail2));
    }

CommandLineRunner를 스프링 빈으로 등록하여 웹 애플리케이션 실행 시에 Order 엔티티와 OrderDetail 엔티티의 영속성에 저장할때 자식 엔티티인 OrderDetail에 영속성 전이를 설정했기 때문에 자식 엔티티를 영속성 컨텍스트에 저장하면 자동으로 부모 엔티티도 영속화 됩니다. 실제 트랜잭션이 커밋되는 시점에 플러시가 호출되어 insert 쿼리가 부모 1, 자식 2개에 대해서만 데이터베이스에 보내는 것을 알 수가 있습니다.

// 영속성 전이 설정
@ManyToOne(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    private Order order;

실행결과

스크린샷 2019-11-27 오후 9 15 28

이제 일대다(@OneToMany) 단 방향 연관관계일 때 영속성 컨테스트에 저장 시 발생하는 쿼리 수를 확인 해보겠습니다.

@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(cascade = CascadeType.ALL)
    @JoinColumn(name = "order_id")
    List<OrderDetail> orderDetails = new ArrayList<OrderDetail>();
}


@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

    private String type;
    private String description;

}

@Transactional
public void createOrderWithDetails() {
    Order order = new Order();
    order.setOrderDate(LocalDateTime.now());
    orderRepository.save(order);

    OrderDetail orderDetail1 = new OrderDetail();
    orderDetail1.setType("type1");
    orderDetail1.setDescription("order1-type1");

    OrderDetail orderDetail2 = new OrderDetail();
    orderDetail2.setType("type2");
    orderDetail2.setDescription("order1-type2");

    order.getOrderDetails().add(orderDetail1);
    order.getOrderDetails().add(orderDetail2);
}

위의 코드를 보면 일대다 단 방향 연관관계를 가질 경우에는 Order 엔티티에서 연관관계의 주인이지만 실제로 JPA에서 영속성 저장 후 insert 쿼리를 보면 외래키는 다에 해당하는 OrderDetail 테이블에 들어가는 것을 확인할 수 있습니다.
그렇기 때문에 플러시를 호출하면 OrderDetail 엔티티에 대한 UPDATE 쿼리가 발생하기 때문에 성능적으로 좋지 않습니다.

일대다(@OneToMany) 단 방향 연관관계 실행 결과

스크린샷 2019-11-27 오후 9 41 18

실행 결과를 보면 insert 쿼리 3개, UPDATE 쿼리 2개가 발생한 것을 확인 할 수가 있습니다... 만약 자식 엔티티의 수가 더 많았다면 자식 엔티티 수 만큼 쿼리가 데이터베이스에 전송될 것 입니다.

그럼 이제 오해를 풀기 위해서 일대다(@ManyToOne) 양 방향 연관관계로 설절 후에 영속성 컨텍스트에 저장 시 발생하는 쿼리를 확인해 보겠습니다.

@Getter
@Setter
@Entity
@Table(name = "Orders")
public class Order {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_id")
    private Long orderId;

    @Column(name = "order_dt")
    private LocalDateTime orderDate;

    @OneToMany(mappedBy = "order", cascade = CascadeType.ALL)
    List<OrderDetail> orderDetails = new ArrayList<OrderDetail>();
}


@Getter
@Setter
@Entity
@Table(name = "OrderDetails")
public class OrderDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "order_detail_id")
    private Long orderDetailId;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "order_id")
    private Order order;

    private String type;
    private String description;

}


@Transactional
public void createOrderWithDetails() {
    Order order = new Order();
    order.setOrderDate(LocalDateTime.now());

    OrderDetail orderDetail1 = new OrderDetail();
    orderDetail1.setOrder(order);
    orderDetail1.setType("type1");
    orderDetail1.setDescription("order1-type1");

    OrderDetail orderDetail2 = new OrderDetail();
    orderDetail2.setOrder(order);
    orderDetail2.setType("type2");
    orderDetail2.setDescription("order1-type2");

    order.getOrderDetails().add(orderDetail1);
    order.getOrderDetails().add(orderDetail2);

    orderRepository.save(order);
}

일대다 양방향 연관관계 매핑 시 영속성 컨텍스트에 저장을 하면 아래 실행 결과처럼 다대일 단방향 연관관계처럼 insert 쿼리가 3개만 나가는 것을 확인 할 수 있었습니다.

일대다(@ManyToOne) 양 방향 연관관계 실행 결과

스크린샷 2019-11-27 오후 9 48 35

위의 예제코드를 통해서 일대다 양방향 매핑단방향 매핑 보다는 조금 더 복잡하지만 실환경에서 성능상의 이점을 누릴 수 있다는 사실을 깨닫게 되었습니다.

N+1 문제

그 다음 내용은 JPA를 프로젝트를 하면서 가장 많이 겪게되는 N+1 문제에 대해서 리뷰하겠습니다.
N + 1 문제는 엔티티에 대해 하나의 쿼리로 N개의 레코드를 가져왔을 때, 연관관계 엔티티를 가져오기 위해 쿼리를 N번 추가적으로 수행하는 문제를 말합니다.

대부분 N+1 문제에 대한 오해는 흔히 글로벌 페치 전략인 즉시 로딩(EAGER)에서 발생한다고 많이 알고 있지만 사실 즉시 로딩뿐만 아니라 지연 로딩(LAZY)에서도 N+1은 존재합니다. 지연 로딩은 조회하려는 엔티티를 가지고 올때 연관된 엔티티는 조회하지 않고 실제로 객체 그래프 탐색으로 연관된 엔티티를 사용하는 시점에 프록시를 통해서 조회를 요청하는 페치 전략 입니다.
따라서 결국에는 영속성 컨텍스트에 해당 엔티티가 존재하지 않는다면 데이터베이스를 통해서 쿼리를 발송해야 하기 때문에 N + 1은 피할 수 없는 문제입니다.

다음과 같이 2가지의 대표적인 해결 방법이 있습니다.

  • Fetch Join
  • Entity Graph

JPA는 단건 조회(findOne())를 할 경우네는 외래키에 null 허용여부에 따라 선택적 관계 또는 필수적 관계를 가지게 됩니다. 이때 JPA는 선택적 관계이면 null이 존재할 수 있다고 가정하여 left outer join으로 연관된 엔티티를 한번에 가져오게 됩니다. 필수적 관계일 경우에는 null을 허용하지 않기 때문에 최적의 성능으로 inner join을 사용하여 연관관계를 가져옵니다. 하지만 JPQL로 작성한 findAll()과 같은 여러 건의 엔티티를 조회하는 메소드는 실제로 조회를 했을 때 데이터베이스에서 연관관계를 가진 엔티티를 join을 해서 가져오지만 영속상태로 반환하지 않습니다. 그리고 스프링 Data JPA는 다시 글로벌 패치전략을 보고 즉시 로딩일 경우 데이터베이스에 연관관계를 가진 엔티티를 다시 조회하게 됩니다. 그렇기 때문에 N + 1 문제가 발생하는 겁니다.

위의 설명한 이러한 문제를 해결하기 위해서 join fetch를 사용하여 N + 1 문제가 발생이 안하는지 확인하겠습니다.

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o" +
    " join o.orderDetails od")
    public List<Order> getAllOrderWithDetails();

}

// 실행결과
@Bean
CommandLineRunner onStartUp(OrderService orderService) {
    return args -> {
        orderService.createOrderWithDetails();
        orderService.getAllOrderWithDetails();
    };
}



Hibernate: select order0_.order_id as order_id1_1_, order0_.order_dt as order_dt2_1_ from orders order0_ inner join order_details orderdetai1_ on order0_.order_id=orderdetai1_.order_id

Hibernate: select orderdetai0_.order_id as order_id4_0_0_, orderdetai0_.order_detail_id as order_de1_0_0_, orderdetai0_.order_detail_id as order_de1_0_1_, orderdetai0_.description as descript2_0_1_, orderdetai0_.order_id as order_id4_0_1_, orderdetai0_.type as type3_0_1_ from order_details orderdetai0_ where orderdetai0_.order_id=?

Hibernate: select orderdetai0_.order_id as order_id4_0_0_, orderdetai0_.order_detail_id as order_de1_0_0_, orderdetai0_.order_detail_id as order_de1_0_1_, orderdetai0_.description as descript2_0_1_, orderdetai0_.order_id as order_id4_0_1_, orderdetai0_.type as type3_0_1_ from order_details orderdetai0_ where orderdetai0_.order_id=?

위의 JpaRepository 인터페이스를 확장한 OrderRepository 클래스를 만들어서 JPQL로 @Query 어노테이션을 적용한 쿼리 메소드를 작성하였습니다. Order 엔티티와 OrderDetail 엔티티를 join을 수행하는 메소드이지만 실제로 실행 결과를 보면 select 쿼리문이 데이터베이스에 3번 전송되는 것이 확인됩니다.

만약 OrderDetail 엔티티들이 같은 Order 엔티티를 참조하게 된다면 추가로 OrderDetail 엔티티는 한번만 조회하겠지만 대부분은 아마 각각 다른 Order 엔티티를 참조하기 때문에 이미 처음에 조회된 Order 엔티티들의 수 만큼 참조하는 OrderDetail 엔티티에 대한 조회가 N + 1만큼 이루어 집니다.
이런한 문제를 join fetch를 사용하면 아래와 같은 실행 결과가 나옵니다.

페치 조인(Fetch Join)

public interface OrderRepository extends JpaRepository<Order, Long> {

    @Query("select o from Order o" +
    " join fetch o.orderDetails od")
    public List<Order> getAllOrderWithDetails();
}

// 실행 결과
Hibernate: select order0_.order_id as order_id1_1_0_, orderdetai1_.order_detail_id as order_de1_0_1_, order0_.order_dt as order_dt2_1_0_, orderdetai1_.description as descript2_0_1_, orderdetai1_.order_id as order_id4_0_1_, orderdetai1_.type as type3_0_1_, orderdetai1_.order_id as order_id4_0_0__, orderdetai1_.order_detail_id as order_de1_0_0__ from orders order0_ inner join order_details orderdetai1_ on order0_.order_id=orderdetai1_.order_id

짠!.... 각각의 Order 엔티티를 OrderDetail 엔티티가 참조하고 있지만 join fetch로 인해서 한번만 쿼리가 발생하는 것을 확인할 수 가 있습니다.

Entity Graph 방법

사실 객체 그래프 방법은 오늘 처음 듣게 되었고 처음 실습시간에 사용해봤습니다. 이 객체 그래프 방식은 실제 엔티와의 연관관계가 복잡해질때 어디까지 연관된 엔티티를 조회할지 개발자가 직접 정의해서 사용할 수 있는 방법입니다. 객체 그래프 방법은 도메인애 @NamedEntityGraphs 어노테이션을 적용하여 repository에서 도메인에 정의된 @NamedEntityGraphs의 name을 이용하여 @Query 메소드와 함께 사용할 수 있습니다.

Member 엔티티와 MemberDetails 엔티티가 일대다(@OneToMany) 양 방향 연관관계를 가지고 있을때 객체 그래프 방법으로 join을 하는 예제코드를 살펴보겠습니다.

@NamedEntityGraph(name = "memberWithDetails", attributeNodes = {
        @NamedAttributeNode("details")
})
@Getter
@Setter
@Entity
@Table(name = "Members")
public class Member {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_id")
    private Long memberId;

    private String name;

    @Column(name = "create_dt")
    private LocalDateTime createDate;

    @OneToMany(cascade = CascadeType.ALL, mappedBy = "member")
    private List<MemberDetail> details = new ArrayList<>();

}


@Getter
@Setter
@Entity
@Table(name = "MemberDetails")
public class MemberDetail {
    @Id
    @GeneratedValue(strategy = GenerationType.SEQUENCE)
    @Column(name = "member_detail_id")
    private Long memberDetailId;

    @ManyToOne
    @JoinColumn(name = "member_id")
    private Member member;

    private String type;

    private String description;

}

public interface MemberRepository extends JpaRepository<Member, Long> {
    // TODO : @EntityGraph로 설정한 Entity Graph를 이용
    @EntityGraph("memberWithDetails")
    @Query("select m from Member m")
    List<Member> getAllBy();

}

위의 코드를 살펴보면 @NamedAttributeNode("details")는 Member 엔티티가 참조하고 있는 MemberDetail 엔티티의 컬렉션 객체의 참조 변수를 넣었습니다.
즉, 연관관계를 조회할 때 MemberDetail 엔티티까지 조회하겠다는 뜻입니다.

그리고 MemberRepository에 @Query 어노테이션 안에 있는 쿼리는 Member 엔티티만 조회를 하지만 @EntityGraph 어노테이션에서 Member 도메인에 정의 되어있는 memberWithDetails를 명시해주었기 때문에 실제로 getAllBy() 메소드를 호출하게 되면 Member와 MemberDetail 엔티티를 조인한 쿼리문을 데이터베이스에 전송하여 조회하게 됩니다.

실행결과

Hibernate: select member0_.member_id as member_i1_3_0_, details1_.member_detail_id as member_d1_2_1_, member0_.create_dt as create_d2_3_0_, member0_.name as name3_3_0_, details1_.description as descript2_2_1_, details1_.member_id as member_i4_2_1_, details1_.type as type3_2_1_, details1_.member_id as member_i4_2_0__, details1_.member_detail_id as member_d1_2_0__ from members member0_ left outer join member_details details1_ on member0_.member_id=details1_.member_id

여기까지 제가 오늘 NHN 워크숍에서 배운 JPA에 대한 사실과 오해에 대한 발표내용이였습니다. N + 1 문제를 해결하는 방법은 join fetch 방법밖에 몰랐었는데 객체 그래프 방법을 배움으로써 상황에 따라서 객체 그래프 방법을 이용하면 페치 조인 방법처럼 성능을 최적화 할 수 있다고 생각합니다.

'SpringFramework > JPA' 카테고리의 다른 글

객체지향 쿼리 소개(Criteria, QueryDSL, Native Query)  (0) 2019.12.19
JPA Auditing 기능이란?  (0) 2019.12.11
값 타입  (0) 2019.11.26
영속성 전이 및 고아객체 제거  (0) 2019.11.23
지연로딩과 즉시로딩  (0) 2019.11.23

값 타입

JPA는 데이터 타입을 가장 크게 분류하면 엔티티 타입과 값 타입으로 나눌 수 있습니다. 엔터티 타입은 @Entity로 정의하는 객체이고, 값 타입은 int, Integer, String 처럼 단순히 값으로 사용하는 자바 기본 타입이나 객체를 말합니다. 엔팉티 타입은 식별자를 통해서 지속해서 추적할 순 있지만, 값 타입은 식별자가 없고 숫자나 문자같은 속성만 있으므로 추적할 수 없습니다.엔티티는 키나 나이와 같은 값을 변경해도 식별자만 유지되면 같은 회원으로 인식할 수 있습니다. 하지만 숫자 값 100을 200으로 변경하면 완전히 다른 값으로 대체 됩니다.
비유하자면 엔티티 타입은 살아있는 생물이고 값 타입은 단순한 수치 정보입니다.

  • 기본값 타입

    - 자바 기본 타입(int, double)

    - 래퍼 클래스(Integer, Double, Long)

    - String

    • 임베디드 타입(복합 값 타입)

    • 컬렉션 값 타입

    기본값 타입은 String, int처럼 자바가 제공하는 기본 데이터 타입이고 임베디드 타입은 JPA에서 사용자가 직접 정의한 값 타입입니다. 마지막으로 컬렉션 값 타입은 하나 이상의 값 타입을 저장 할 때 사용합니다.

가장 단순한 기본값 타입은 아래 코드와 같습니다

@Entity
public class Member{

    @Id @GeneratedValue
    private Long id;

    private String name;

    private int age;

}

위의 코드를 보면 값 타입인 name, age 속성은 식별자 값도 없고 생명주기도 회원 엔티티에 의존합니다. 따라서 회원 엔티티의 인스턴스를 제거하면 name, age 값도 제거 됩니다.

참고: 자바에서 int, double 같은 기본 타입은 절대 공유되지 않습니다. call by value라는 것을 알아야 됩니다. 만약 a의 값을 변경했다고 b까지 변경되면 정말 끔찍할 것입니다.

임베디드 타입(보합 값 타입)

새로운 값 타입을 직접 정의해서 사용할 수 있는데 이것을 임베디드 타입이라고 합니다. 중요한 것은 직접 정의한 임베디드 타입도 int, String 처럼 값 타입이라는 것입니다.

@Entity
public class Member{

    @Id @GeneratedValue
    private Long id;

    private String name;

    // 근무 기간
    @Temporal(TemporalType.DATE) java.util.Date. startDate;
    @Temporal(TemporalType.DATE) java.util.Date. endDate;


    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;

}
  • 회원 엔티티는 이름, 근무 시작일, 근무 종료일, 주소 도시, 주소 번지, 주소 우편 번호를 가집니다.

이런설명은 단순히 정보를 풀어둔 것 뿐이니다. 그리고 근무 시작일과 우편번호는 서로 아무 관련이 없습니다. 이것보단 아래처럼 명확하게 설명하는 것이 좋습니다

  • 회원 엔티티는 이름, 근무 기간, 집 주소를 가집니다.

회원이 상세한 데이터를 그대로 가지고 있는 것은 객체 지향적이지 않으며 응직력만 떨어뜨립니다. 대신에 근무 기간, 주소 같은 타입이 있다면 코드가 더 명확해 질 것입니다. [근무기간, 집 주소] 를 가지도록 임베디드 타입을 사용해 보겠습니다.

@Entity
public class Member{

    @Id @GeneratedValue
    private Long id;

    private String name;

    @Embedded Period workPeriod; // 근무 기간
    @Embedded Address homeAddress; // 집 주소
}
public class Period{

    // 근무 기간
    @Temporal(TemporalType.DATE) java.util.Date. startDate;
    @Temporal(TemporalType.DATE) java.util.Date. endDate;

    public boolean isWork(Date date){
        //.. 값 타입을 위한 메소드를 정의 할 수 있습니다.
    }

}
public class Address{

    // 집 주소 표현
    private String city;
    private String street;
    private String zipcode;
    // ...
}

임베디드 타입 사용 후 엔티티 연관관계

스크린샷 2019-11-25 오후 9 59 13

임베디트 타입을 사용하니 엔티티가 더욱 의미 있고 응집력 있게 변한 것을 알 수 있습니다.

  • startDate, endDate를 합해서 Period 클래스를 만들었습니다.

  • city, street, zipcode를 합해서 Address 클래스를 만들었습니다.

새로 정의한 값 타입들은 재사용할 수 있고 응집도도 아주 높습니다. 또한 Period.isWork() 매소드 처럼 해당 값 타입만 사용하는 의미 있는 메소드도 만들 수 있습니다. 임베디드 타입을 사용하려면 다음 2가지 어노테이션이 필요합니다.

  • @Embeddable: 값 타입을 정의하는 곳에 표시

  • @Embedded: 값 타입을 사용하는 곳에 표시

참고로 위의 2가지 어노테이션 중 하나는 생략해도 됩니다.

그리고 임베디드 타입은 기본 생성자가 필수입니다.
하이버네이트는 임베디드 타입을 컴포넌트(component)라고 합니다.

임베디드 타입은 엔티티의 값일 뿐입니다. 따라서 값이 속한 엔티티의 테이블에 매핑합니다. 임베디드 타입 덕분에 객체와 테이블을 아주 세밀하게 매핑하는 것이 가능합니다. 잘 설계한 ORM 애플리케이션은 매핑한 테이블의 수보다 클래스의 수가 더 많습니다.

임베디드 타입과 연관관계

임베디드 타입은 값 타입을 포함하거나 엔티티를 참조할 수 있습니다. 엔티티는 공유될 수 있으므로 참조한다고 표현하고, 값 타입은 특정 주인에 소속되고 논리적인 개념상 공유되지 않으므로 포함된다고 표현합니다.

스크린샷 2019-11-25 오후 10 25 59

public class Member{

    @Embedded 
    private Address address; // 임베디드 타입 포함

    @Embedded 
    private PhoneNumber phoneNumber; // 임베디드 타입 포함
}

@Embeddable
public class Address{

    private String street;
    private String city;
    private String state;

    @Embedded
    Zipcode zipcode; // 임베디드 타입 포함
}

@Embeddable
public class Zipcode{

    private String zip;
    private String plusFour;
}


@Embeddable
public class phoneNumber{
    private String areaCode;
    private String localNumber;

    @ManyToOne
    PhoneServiceProvider provider; // 엔티티 참조
    ...
}

@Entity
public class PhoneServiceProvider{

    @Id
    private String name;

}

코드를 보면 값 타입인 Address가 값 타입인 Zipcode를 포함하고, 값 타입인 PhoneNumber가 엔티티 타입인 PhoneServiceProvider를 참조하고 있습니다.

@AttributeOverride 속성 재정의

임베디드 타입에 정의한 매핑정보를 재정의하려면 엔티티에 @AttributeOverride를 사용하면 됩니다. 예를 들어 회원에 주소가 하나 더 필요하면 아래 코드와 같이 @AttributeOverrides를 사용해서 매핑정보를 재정의해야 합니다.

@Entity
public class Member{

    @Id @GeneratedValue
    private Long id;
    private String name;

    @Embedded Address homeAddress;


    @Embedded
    @AttributeOverrides({
        @AttributeOverride(name = "city", column=@Column(name = "COMPANY_CITY")),
        @AttributeOverride(name = "street", column=@Column(name = "COMPANY_STREET")),
        @AttributeOverride(name = "zipcode", column=@Column(name = "COMPANY_ZIPCODE"))
    })
    private Address companyAddress;
}

참고 @AttributeOverrides는 엔티티에 설정해야 합니다. 임베디드 타입이 임베디드 타입을 가지고 있어도 엔티티에 설정해야 합니다.

임베디드 타입과 null

임베디드 타입이 null이면 매핑한 컬럼 값은 모두 null 입니다.

member.setAddress(null);
em.persist(member);

회원 테이블의 주소와 관련된 city, street, zipcode 컬럼 값은 모두 null이 됩니다.

값 타입과 불변 객체

값 타입은 복잡한 객체 세상을 조금이라도 단순화하여 만든 개념입니다. 따라서 값 타입은 단순하고 안전하게 다룰 수 있어야 합니다.

값 타입 공유 참조

임베디드 타입 값 타입을 여러 엔티티에서 공유하면 위험합니다. 공유하려면 어떤 문제가 발생하는지 알아봐야 합니다.

스크린샷 2019-11-26 오전 12 32 59

위의 그림을 코드로 나타내면 다음과 같습니다.

member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();

address.setCity("NewCity");
member2.setHomeAddress(address);

회원2에 새로운 주소를 할당하려고 회원1의 주소를 그대로 참조해서 사용했습니다. 실제로 회원2만 NewCity로 변경되길 기대했지만 회원1의 주소도 NewCity로 변경되어 버립니다. 회원1과 회원2 둘 다 같은 address 인스턴스를 참조하기 때문입니다. 영속성 컨텍스트는 회원1과 회원2 둘 다 city 속성이 변경된 것으로 판단해서 회원1, 회원2 각각 UPDATE SQL을 실행한다.

이러한 공유 참조로 발생하는 버그는 정말 찾아내기 어렵습니다. 이렇듯 뭔가를 수정했는데 전혀 예상치 못한 곳에서 문제가 발생하는 것을 부작용이라 합니다. 이런 부작용을 막으려면 값을 복사해서 사용하면 됩니다.

값 타입의 복사

값 타입의 실제 인스턴스인 값을 공유하는 것은 위험합니다. 대신에 값(인스턴스)를 복사해서 사용해야 합니다.

스크린샷 2019-11-26 오전 12 53 37

member1.setAddress(new Address("OldCity"));
Address address = member1.getAddress();

// 회원1의 address 값을 복사해서 새로운 newAddress 값을 생성
Address newAddress = address.clone();

address.setCity("NewCity");
member2.setHomeAddress(address);

회원2에 새로운 주소를 할당하기 위해 clone() 메소드를 만들었는데, 이 메소드는 자신을 복사해서 반환하도록 구현했습니다. 이 코드를 실행하면 의도한 대로 회원2 주소만 NewCity로 변경됩니다. 그리고 영속성 컨텍스트는 회원2의 주소만 변경된 것으로 판단해서 회원2에 대해서만 UPDATE SQL을 실행합니다.

이처럼 항상 값을 복사해서 사용하면 공유 참조로 인해 발생하는 부작용을 피할 수 있습니다.
문제는 임베디드 타입처럼 직접 정의한 값 타입은 자바의 기본타입이 아니라 객체 타입이라는 것입니다.
자바는 기본 타입에 값을 대입하면 값을 복사해서 전달합니다.
자바는 객체에 값을 대입하면 항상 참조 값을 전달합니다.
Address b=a 에서 a가 참조하는 인스턴스의 참조 값을 b에 넘겨줍니다. 따라서 a와 b는 같은 인스턴스를 공유 참조합니다. 마지막 줄의 b.setCity("New")의 의도는 b.city 값만 변경하려 했지만 공유 참조로 인해 부작용이 발생해서 a.city 값도 변경됩니다.

물론 객체는 대입할 때마다 인스턴스를 복사해서 대입하면 공유 참조를 피할 수 있습니다. 문제는 복사하지 않고 원본의 참조 값을 직접 넘기는 것을 막을 방법이 없다는 것입니다. 자바는 대입하려는 것이 값 타입인지 아닌지는 신경쓰지 않습니다. 단지 자바 기본 타입이면 값을 복사해서 넘기고, 객체면 참조를 넘길 뿐입니다.

객체의 공유 참조는 피할 수 없습니다. 따라서 근본적인 해결책이 필요한데 가장 단순한 방법은 객체의 값을 수정하지 못하게 막으면 됩니다. 수정자 메소드인 setCity() 같은 메소드를 모두 제거하면 됩니다. 이렇게 하면 공유 참조를 해도 값을 변경하지 못하므로 부작용의 발생을 막을 수가 있습니다.

불변 객체

한번 만들면 절대 변경할 수 없는 객체를 불변 객체라고 합니다. 불변 객체의 값은 조회할 수 있지만 수정할 수는 없습니다. 불변 객체도 결국은 객체입니다. 따라서 인스턴스의 공유 참조를 피할 수 없습니다. 하지만 참조 값을 공유해도 인스턴스의 값을 수정할 수 없으므로 부작용이 발생하지 않습니다.

불변 객체를 구현하는 다양한 방법이 있지만 가장 간단한 방법은 생성자로만 값을 설정하고 수정자를 만들지 않으면 됩니다. Address를 불변 객체로 만들면 아래 코드와 같습니다.

@Embeddable
public class Address {

    private String city;

    protected Address() {} // JPA에서 기본 생성자는 필수입니다.

    public Address(String city){
        this.city = city;
    }

    public String getCity(){
        return city;
    }

    // 수정자(setter)는 만들지 않습니다.

}
불변 객체 사용
Address address = member1.getHomeAddress();
//회원1의 주소값을 조회해서 새로운 주소값을 생성
Address newAddress = new Address(address.getCity());
member2.setHomeAddress(newAddress);

Address는 이제 불변 객체입니다. 값을 수정할 수 없으므로 공유해도 부작요이 발생하지 않습니다. 만약 값을 수정해야 한다면 새로운 객체를 생성해서 사용해야 합니다. 참고로 Integer, String은 자바가 제공하는 대표적인 불변 객체 입니다.

결론은 불변이라는 작은 제약으로 부작용이라는 큰 재앙을 막을 수 있습니다.

참조: ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

JPA Auditing 기능이란?  (0) 2019.12.11
JPA에 대한 사실과 오해  (0) 2019.11.28
영속성 전이 및 고아객체 제거  (0) 2019.11.23
지연로딩과 즉시로딩  (0) 2019.11.23
프록시와 연관관계 관리  (0) 2019.11.22

영속성 전이: CASCADE

특정 엔티티를 영속 상태로 만들 때 연관된 엔티티도 함께 영속 상태로 만들고 싶으면 영속성 전이 기능을 사용하면 됩니다. JPA는 CASCADE 옵션으로 영속성 전이를 제공합니다. 쉽게 말해서 영속성 전이를 사용하면 부모 엔티티를 저장할 때 자식 엔티티도 함께 저장 할 수 있습니다.

// 부모 엔티티
@Setter
@Getter
@Entity
public class Parent {

    @Id 
    @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent")
    private List<Child> children = new ArrayList<Child>();
}

//자식 엔티티
@Getter
@Setter
@Entity
public class Child {

    @Id @GeneratedValue
    @Column(name = "CHILD_ID")
    private Long id;
    private String name;

    @ManyToOne
    private Parent parent;

}

위의 코드는 부모 엔티티가 여러 자식 엔티티를 가진다고 가정해보겠습니다.

@Test
@Transactional
public void printUser() throws Exception {
    // 부모 저장
    Parent parent = new Parent();
    parent.setName("임종수");
    entityManager.persist(parent);

    // 1번 자식 저장
    Child child1 = new Child();
    child1.setName("임준영");
    child1.setParent(parent); // 자식 -> 부모 연관관계 설정
    parent.getChildren().add(child1); // 부모 -> 자식
    entityManager.persist(child1);

    // 2번 자식 저장
    Child child2 = new Child();
    child2.setName("임주리");
    child2.setParent(parent); // 자식 -> 부모 연관관계 설정
    parent.getChildren().add(child2); // 부모 -> 자식
    entityManager.persist(child2);       
}

JPA에서 엔티티를 저장할 때 연관된 모든 엔티티는 영속 상태어야 합니다.
따라서 위의 코드를 보면 부모 엔티티를 영속 상태로 만들고 자식 엔티티도 각각 영속 상태로 만듭니다. 이럴 때 영속성 전이를 사용하면 부모 엔티티만 영속 상태로 만들면 연관된 자식까지 한번에 영속 상태로 만들 수 있습니다.

영속성 전이: 저장

영속성 전이를 활성화하는 CASCADE 옵션을 적용해보겠습니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;


    @OneToMany(mappedBy = "parent", cascade = CascadeType.PERSIST)
    private List<Child> children = new ArrayList<Child>();
}

부모를 영속화 할 때 자식들도 함께 영속화하라고 cascade = CascadeType.PERSIST 옵션을 설정했습니다. 이 옵션을 적용하면 아래 코드처럼 간편하게 부모와 자식 엔티티를 한 번에 영속화 할 수 있습니다.

@Test
@Transactional
@Rollback(false)
public void printUser() throws Exception {

    // 1번 자식 저장
    Child child1 = new Child();
    // 2번 자식 저장
    Child child2 = new Child();

    Parent parent = new Parent();
    parent.setName("임종수");
    child1.setName("임준영");
    child2.setName("임주리");
    child1.setParent(parent); // 자식 -> 부모 연관관계 설정
    child2.setParent(parent); // 자식 -> 부모 연관관계 설정
    parent.getChildren().add(child1); // 부모 -> 자식
    parent.getChildren().add(child2); // 부모 -> 자식

    // 부모 저장
    entityManager.persist(parent);
}

CASCADE 실행

스크린샷 2019-11-23 오전 2 52 25

부모만 영속화하면 CascadeType.PERSIST로 설정한 자식 엔티티까지 함께 영속화해서 저장합니다.

이 코드의 쿼리 결과를 보면 데이터가 정상적으로 2건 입력된 것을 확인할 수 가 있습니다.

스크린샷 2019-11-23 오전 2 54 54

영속성 전이는 연관관계를 매핑하는 것과 아무 관련이 없습니다. 단지 엔티티를 영속화 할 때 연관된 엔티티도 같이 영속화하는 편리함을 제공할 뿐입니다.

영속성 전이: 삭제

방금 저장한 부모와 자식 엔티티를 모두 제거하려면 다음 코드와 같이 각각의 엔티티를 하나씩 제거해야 합니다.

Parent findParent = em.find(Parent.class, 1L);
Child findChild1 = em.find(Child.class, 1L);
Child findChild2 = em.find(Child.class, 2L);

em.remove(findChild1);
em.remove(findChild2);
em.remove(findParent);

영속성 전이는 엔티티를 삭제할 때도 사용할 수 있습니다. CascadeType.REMOVE로 설정하고 다음 코드처럼 부모 엔티티만 삭제하면 연관된 자식 엔티티도 함께 삭제 됩니다.

Parent findParent = em.find(Parent.class, 1L);
em.remove(findParent);

코드를 실행하면 DELETE SQL을 3번 실행하고 부모는 물론 연관된 자식도 모두 삭제합니다. 삭제 순서는 외래키 제약조건을 고려해서 자식을 먼저 삭제하고 부모를 삭제합니다.

만약 CascadeType.REMOVE를 설정하지 않고 이 코드를 실행하면 부모 엔티티만 삭제 됩니다. 하지만 데이터베이스의 부모 로우를 삭제하는 순간 자식 테이블에 걸려 있는 외래 키 제약조건으로 인해, 데이터베이스에서 외래 키 무결성 예외가 발생합니다.

CASCADE의 종류

public enum CascadeType{

    ALL, // 모두적용
    PERSIST, // 영속
    MERGE, // 병합
    REMOVE, // 삭제
    REFRESH, // REFRESH
    DETACH // DETACH
}

다음처럼 여러 속성을 같이 사용할 수 있습니다.

cascade = {CascadeType.PERSIST, CascadeType.REMOVE}

참고로 CascadeType.PERSIST, CascadeType.REMOVE는 em.persist(), em.remove()를 실행 할 때 바로 전이가 발생하지 않고 플러시를 호출 할 때 전이가 발생합니다.

고아 객체

JPA는 부모 엔티티와 연관관계가 끊어진 자식 엔티티를 자동으로 삭제하는 기능을 제공하는데 이것을 고아 객체 제거라고 합니다.
이 기능을 사용해서 부모 엔티티의 컬렉션에서 자식 엔티티의 참조만 제거하면 자식 엔티티가 자동으로 삭제하도록 코드를 작성해 보겠습니다.

@Setter
@Getter
@Entity
public class Parent {

    @Id @GeneratedValue
    @Column(name = "PARENT_ID")
    private Long id;

    private String name;

    @OneToMany(mappedBy = "parent", orphanRemoval = true)
    private List<Child> children = new ArrayList<Child>();
}

고아 객체 제거 기능을 활성화하기 위해 컬렉션에 orphanRemoval = true를 설정합니다. 이제 컬렉션에서 제거한 엔티티는 자동으로 삭제됩니다.

Parent parent1 = em.find(Parent.lcass, id);
parent1.getChildren().remove(0); //자식 엔티티를 컬렉션에서 제거

실행결과

스크린샷 2019-11-23 오후 9 06 09

사용 코드를 보면 컬렉션에서 첫 번째 자식을 제거합니다. 고아 객체 제거 기능은 영속성 컨텍스트를 플러시할 때 적용되므로 플러시 시점에 DELETE SQL이 실행됩니다.

모든 자식 엔티티를 제거하려면 다음 코드처럼 컬렉션을 비우면 됩니다.

parent1.getChildren().clear();

고아 객체를 정리하면 참조가 제거된 엔티티는 다른곳에서 참조하지 않는 고아 객체로 보고 삭제하는 기능입니다. 따라서 이 기능은 참조하는 곳이 하나일 때만 사용해야 합니다. 쉽게 말하자면 특정 엔티티가 개인 소유하는 엔티티에만 이 기능을 적용해야 합니다. 만약 삭제한 엔티티를 다른 곳에서도 참조한다면 문제가 발생할 수 있습니다. 이런 이유로 orphanRemovel은 @OneToOne, @OneToMany에만 사용할 수 있습니다.

영속성 전이 + 고아객체, 생명주기

CascadeType.ALL + orphanRemoval = true를 동시에 사용하면 부모 엔티티를 통해서 자식의 생명주기를 관리 할 수 있습니다.

//자식을 저장하려면 부모에 등록만 하면 됩니다.
Parent parent = em.find(Parent.class , parentId);
parent.addChild(child);

//자식을 삭제하려면 부모에서 제거하면 됩니다.
Parent parent = em.find(Parent.class , parentId);
parent.getChildren().remove(removeObject);
참조: ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

JPA에 대한 사실과 오해  (0) 2019.11.28
값 타입  (0) 2019.11.26
지연로딩과 즉시로딩  (0) 2019.11.23
프록시와 연관관계 관리  (0) 2019.11.22
고급매핑 - 조인테이블  (0) 2019.10.30

즉시로딩과 지연로딩

이제 JPA에서 연관관계를 조회할때 가장 중요한 글로벌 패치 전략인 즉시로딩(EAGER)지연로딩(LAZY)에 대해서 살펴보겠습니다.

Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
System.out.println(team.getName()); // 팀 엔티티 사용

위와 같이 회원 엔티티를 조회할때 조회 시점을 선택할 수 있도록 다음 두 가지 방법을 제공합니다.

  • 즉시 로딩: 엔티티를 조회할 때 연관된 엔티티도 함께 조회합니다.
    em.find(Member.class, "member1")를 호출할 때 회원 엔티티와 연관된 팀 엔티티도 함께 조회합니다.
    설정방법: @ManyToOne(fetch = FetchType.EAGER)
  • 지연 로딩: 연관된 엔티티를 실제 사용할 때 조회합니다.
    member.getTeam().getName(()처럼 조회한 팀 엔티티를 실제 사용하는 시점에 JPA가 SQL을 호출해서 팀 엔티티를 조회합니다.
    설정방법: @ManyToOne(fetch = FetchType.LAZY)

즉시로딩

즉시 로딩을 사용하려면 @ManyToOne의 fetch 속성을 FetchType.EAGER로 지정합니다.

@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;
}

//즉시 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색

위의 예제코드에서는 회원과 팀을 즉시 로딩으로 설정했습니다. 따라서 em.find(Member.class, "member1")로 회원을 조회하는 순간 팀도 함께 조회합니다. 이때 회원과 팀 두 테이블을 조회해야 하므로 쿼리를 2번 수행할 것 같지만, 대부분의 JPA 구현체는 즉시 로딩을 최적화 하기 위해 가능하면 조인 쿼리를 사용합니다. 여기서는 회원과 팀을 조인해서 쿼리 한번으로 두 엔티티를 모두 조회합니다.

즉시로딩(fetch = FetchType.EAGER)

스크린샷 2019-11-21 오전 2 52 39

실제로 JPA가 left outer join 쿼리문을 날려서 회원과 팀을 한 번으로 조회한 것을 알 수 있습니다.
이후 member.getTeam()을 호출하면 이미 로딩된 팀1 엔티티를 반환합니다.

참고

NULL 제약조건과 JPA 전략
즉시 로딩 실행 SQL에서 JPA가 내부 조인이 아닌 외부 조인을 사용하고 있습니다. 그 이유는 현재 회원 테이블에서 TEAM_ID 외래 키는 NULL 값을 허용하고 있습니다. 따라서 팀에 소속되지 않는 회원이 존재할 가능성이 있기 때문에 팀에 소속되지 않는 회원과 팀을 내부조인하면 팀은 물론이고 회원 데이터도 조회할 수 없습니다. JPA는 이런 상황을 고려해서 외부 조인을 사용합니다. 하지만 성능상으로 외부조인보다 내부조인이 최적화에 더 유리합니다. 따라서 외래키에 NOT NULL 제약 조건을 설정하면 값이 있는 것을 보장하기 때문에 이런 경우에는 내부조인만 사용해도 됩니다.

ex)

@Entity
public class Member{

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

nullable 설정에 따른 조인 전략

  • @JoinColumn(nullable = true): NULL 허용(기본값), 외부 조인 사용
  • @JoinColumn(nullable = false): NULL 허용하지 않음, 내부 조인 사용

또는 아래 코드 처럼 내부 조인을 사용할 수 있습니다.

@Entity
public class Member{

    @ManyToOne(fetch = FetchType.EAGER, optional = false)
    @JoinColumn(name = "TEAM_ID", nullable = false)
    private Team team;
    //...
}

JPA는 선택적 관계이면 외부 조인을 사용하고 필수 관계면 내부 조인을 사용합니다.

내부 조인 결과

스크린샷 2019-11-22 오전 1 08 06

지연로딩

지연 로딩을 사용하면 @ManyToOne의 fetch 속성을 FetchType.LAZY로 지정합니다.

// 지연로딩 설정
@Entity
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "team_id")
    private Team team;
}


//지연 로딩 실행 코드
Member member = em.find(Member.class, "member1");
Team team = member.getTeam(); // 객체 그래프 탐색
team.getName(); // 팀 객체 실제 사용

위의 코드에서는 회원과 팀을 지연 로딩으로 설정했습니다. 따라서 예제코드에서 em.find(Member.class, "member1")를 호출하면 회원만 조회하고 팀은 조회하지 않습니다. 대신에 team 맴버변수에 프록시 객체를 넣어 둡니다.

Team team = member.getTeam(); // 프록시 객체 

반환된 팀 객체는 프록시 객체입니다. 이 프록시 객체는 실제 사용될 때까지 데이터 로딩을 미룹니다. 그래서 지연로딩이라고 합니다.

team.getName() // 팀 객체 실제 사용

위의 코드처럼 실제 데이터가 필요한 순간이 되어서야 데이터베이스를 조회해서 프록시 객체를 초기화 합니다.

em.find(Member.class, "member1") 호출 시 실행되는 SQL은 다음과 같습니다.

SELECT * FROM MEMBER WHERE MEMBER_ID='member1';

team.getName() 호출로 프록시 객체가 초기화되면서 실행되는 SQL은 다음과 같습니다.

SELECT * FROM TEAM WHERE TEAM_ID='team1';

참고로 조회 대상이 영속성 컨텍스트에 이미 있으면 프록시 객체를 사용할 이유가 없습니다. 따라서 프록시가 아닌 실제 객체를 사용합니다.

즉시로딩과 지연로딩 중에 어느것이 더 효율적인가에 대해서는 아무래도 프로젝트 상황에 따라 다를거 같습니다. 애플리케이션 로직에서 회원과 팀 엔티티를 같이 사용한다고 하면 SQL 조인을 사용해서 회원과 팀 엔티티를 한번에 조회하는 것이 더 효율적입니다. 페이스북에서 댓글 더보기 기능처럼 실제로 조회가 필요할때 게시물과 연관된 댓글을 조회하는것도 대표적인 지연로딩 방식입니다.

지연 로딩 활용

사내 주문 관리 시스템을 개발한다고 가정하겠습니다.

스크린샷 2019-11-22 오전 1 43 29

  • 회원은 팀 하나에만 소속할 수 있습니다. (N:1)
  • 회원은 여러 주문내역을 가집니다. (1:N)
  • 주문내역은 상품정보를 가집니다. (N:1)

애플리케이션 로직을 분석해보니 다음과 같았습니다.

  • Member와 연관된 Team은 자주 함꼐 사용되었습니다. 그래서 Member와 Team은 즉시 로딩으로 설정했습니다.

  • Member와 연관된 Order는 가끔 사용되었습니다. 그래서 Member와 Order는 지연 로딩으로 설정했습니다.

  • Order와 연관된 Product는 자주 함께 사용되었습니다. 그래서 Order와 Product는 즉시 로딩으로 설정했습니다.

회원 엔티티

@Entity
@Setter
@Getter
public class Member {

    @Id
    @Column(name = "member_id")
    private String id;

    private String name;

    private int age;

    @ManyToOne(fetch = FetchType.EAGER)
    @JoinColumn(name = "team_id")
    private Team team;

    @OneToMany(mappedBy = "member",fetch = FetchType.LAZY)
    List<Order> orders;


}

회원과 팀의 연관관계를 FetchType.EAGER로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 팀 엔티티도 즉시 조회합니다.

회원과 주문내역의 연관관계를 FetchType.LAZY로 설정했습니다. 따라서 회원 엔티티를 조회하면 연관된 주문내역 엔티티는 프록시를 조회해서 실제 사용될 때까지 로딩을 지연합니다.

Member member = em.find(Member.class, "member1");

회원 엔티티를 조회하면 아래 그림처럼 엔티티를 로딩합니다.

스크린샷 2019-11-23 오전 12 22 15

회원과 팀은 즉시 로딩으로 설정했습니다. 따라서 회원을 조회할 때 하이버네이트는 조인 쿼리를 만들어 회원과 팀을 한 번에 조회합니다.
반면에 회원과 주문내역은 FetchType.LAZY로 설정했으므로 결과를 프록시로 조회합니다. 따라서 SQL문에 전혀 나타나지 않습니다.
회원을 조회한 후에 member.getTeam()을 호출하면 이미 로딩된 팀 엔티티를 반환합니다.

프록시와 컬렉션 래퍼

하이버네이트는 엔티티를 영속 상태로 만들 때 엔티티에 컬렉션이 있으면 컬렉션을 추적하고 관리할 목적으로 원본 컬렉션을 하이버네이트가 제공하는 내장 컬렉션으로 변경하는데 이것을 컬렉션 래퍼라고 합니다. 출력 결과를 보면 컬렉션 래퍼인 org.hibernate.collection.internal.PersistenceBag 이 반환된 것을 확인할 수 있습니다.

엔티티를 지연 로딩하면 프록시 객체를 사용해서 지연로딩을 수행하지만 주문 내역과 같은 컬렉션은 컬렉션 래퍼가 지연 로딩을 처리해줍니다. 컬랙션 래퍼도 컬렉션에 대한 프록시 역할을 하므로 따로 구분하지 않고 프록시로 부르겠습니다.
참고로 meber.getOrders()를 호출해도 컬렉션은 초기화되지 않습니다. 컬렉션은 member.getOrders().get(0)처럼 컬렉션에 실제 데이터를 조회할 때 데이터베이스를 조회해서 초기화 합니다.

스크린샷 2019-11-23 오전 1 22 06

JPA 기본 페치 전략

fetch 속성의 기본 설정값은 아래와 같습니다.

  • @ManyToOne, @OneToOne: 즉시 로딩(FetchType.EAGER)
  • @OneToMany, @ManyToMany: 지연 로딩(FetchType.LAZY)

가장 쉽게 외우는 방법은 xToOne으로 매핑된 엔티티는 글로벌 페치 전략이 즉시 로딩이라고 생각하면 됩니다. 그러면 나머지 xToMany로 끝나는 어노테이션은 자연스럽게 LAZY로 인식이 됩니다.

JPA 기본 페치 전략은 연관된 엔티티가 하나면 즉시 로딩을, 컬렉션이면 지연로딩을 사용합니다. 컬렉션을 로딩하는 것은 비용이 많이 들고 잘못하면 너무 많은 데이터를 로딩할 수 있기 때문입니다. 예를 들어 특정 회원이 연관된 컬렉션에 데이터를 수만 건 등록했는데, 설정한 페치 전략이 즉시 로딩이면 해당 회원을 로딩하는 순간 수만 건의 데이터도 함께 로딩됩니다. 반면에 연관된 엔티티가 하나면 즉시 로딩해도 큰 문제가 발생하지 않습니다.

이 책에서 가장 추천하는 방법은 모든 연관관계에 지연 로딩을 사용하는 것입니다.
그리고 애플리케이션 개발이 어느 정도 완료단계에 왔을 때 실제 사용하는 상황을 보고 꼭 필요한 곳에만 즉시 로딩을 사용하도록 최적화하면 됩니다.

참고로 직접 SQL을 사용하면 이런 유연한 최적화가 어렵습니다. 예를 들어 SQL로 각각의 테이블을 조회해서 처리하다가 조인으로 한 번에 조회하도록 변경하려면 많은 SQL과 애플리케이션 코드를 수정해야 합니다.

컬렉션에 FetchType.EAGER 사용 시 주의점

  • 컬렉션을 하나 이상 즉시 로딩하는 것은 권장하지 않습니다.

컬렉션과 조인한다는 것은 데이터베이스 테이블로 보면 일대다 조인입니다. 일대다 조인은 결과 데이터가 다 쪽에 있는 수만큼 증가하게 됩니다. 문제는 서로 다란 컬렉션을 2개 이상 조인할 때 발생하는데 예를 들어 A 테이블을 N, M 두 테이블과 일대다 조인하면 실행 결과가 N 곱하기 M이 되면서 너무 많은 데이터를 반환할 수 있고 결과적으로 애플리케이션 성능이 저하될 수 있습니다. JPA는 이렇게 조회된 결과를 메모리에서 필터링해서 반환합니다. 따라서 2개 이상의 컬렉션을 즉시 로딩으로 설정하는 것은 권장되지 않습니다.

  • 컬렉션 즉시 로딩은 항상 외부조인을 사용합니다.

예를 들어 다대일 관계인 회원 테이블과 팀 테이블을 조인할 때 회원 테이블의 외래 키에 not null 제약조건을 걸어두면 모든 회원은 팀에 소속되므로 항상 내부 조인을 사용해도 됩니다. 반대로 팀 테이블에서 회원 테이블로 일대다 관계를 조인할 때 회원이 한 명도 없는 팀을 내부 조인하면 팀까지 조회되지 않는 문제가 발생합니다. 데이터베이스 제약조건으로 이런 상황을 막을 수는 없습니다. 따라서 JPA는 일대다 관계를 즉시 로딩할 때 항상 외부 조인을 사용합니다.

FetchType.EAGER 설정과 조인 전략을 정리하면 다음과 같습니다.

@ManyToOne, @OneToOne

- (optional = false): 내부 조인

- (optional = true): 외부 조인

@OneToMany, @ManyToMany

- (optional = false): 외부 조인

- (optional = true): 외부 조인

참고: ORM 표준 JPA 프로그래밍

'SpringFramework > JPA' 카테고리의 다른 글

값 타입  (0) 2019.11.26
영속성 전이 및 고아객체 제거  (0) 2019.11.23
프록시와 연관관계 관리  (0) 2019.11.22
고급매핑 - 조인테이블  (0) 2019.10.30
복합 키와 식별관계 매핑  (0) 2019.10.29

+ Recent posts