본문 바로가기

JPA

JPA의 여러가지 팁

 

짧은 팁들

  1. 모든 연관관계는 지연로딩 으로 하자
  2. 예상하지 못한 지연로딩을 방지하자
    1. fetch join을 사용하여 필요한 데이터를 한 번에 조회하도록 최적화한다.
    2. @JsonIgnore를 활용하여 JSON 직렬화 과정에서 불필요한 로딩을 방지한다.
    3. IDE 디버그 모드에서 toString() 호출로 인해 지연로딩이 동작할 수 있다
    4. @ToString(exclude = "entity")를 활용하여 toString() 호출 시 연관 엔티티 접근을 막는다.
  3. Bulk Insert는 JPA가 아닌 JDBC 혹은 Hibernate Batch Insert 사용
    1. JpaRepository의 saveAll() 메소드는 엔티티 개별 INSERT 쿼리를 실행하므로 대량 삽입에 적합하지 않다.
    2. 성능을 고려할 경우, JdbcTemplate 또는 Hibernate의 Batch Insert 기능을 활용하는 것이 더 효율적이다.
  4. deleteAll() 메소드 대신 deleteAllInBatch() 메소드를 사용하자
    1. deleteAll()은 엔티티를 개별 조회 후 삭제하는 방식으로 동작하지만, deleteAllInBatch()는 한 번의 DELETE 쿼리로 처리하여 성능이 훨씬 뛰어나다.
  5. flush는 commit이 아니다
    1. flush()는 SQL을 즉시 실행하지만, 트랜잭션을 commit하지 않으며, 트랜잭션이 롤백되면 변경 사항이 무효화된다.
    2. 데이터베이스의 격리 수준이 READ COMMITTED 이상인 경우, 다른 트랜잭션에서는 해당 변경 사항을 볼 수 없다.
  6. Bulk Query 실행 시 @Modifying(clearAutomatically = true, flushAutomatically = true)를 사용하자
    1. @Query를 활용한 벌크 연산은 JPA의 영속성 컨텍스트를 자동으로 갱신하지 않는다.
    2. @Modifying(clearAutomatically = true, flushAutomatically = true)를 설정하면, 쿼리 실행 시 flush()후 영속성 컨텍스트를 비워 불일치를 방지할 수 있다.
    3. 이를 사용하지 않으면, 벌크 삭제 후에도 영속성 컨텍스트에 남아있는 엔티티가 조회되는 문제가 발생할 수 있다.
  7. Bulk insert, delete,update 벌크 쿼리는 영속성 컨테스트를 무시한다
    1. 벌크 업데이트 수행 후 findById()를 호출하면, 데이터베이스에는 반영되었지만 영속성 컨텍스트에는 기존 데이터가 남아있을 수 있다.
    2. @LastModifiedDate와 같은 Auditing 기능이 정상 동작하지 않을 수 있으며, 이를 해결하려면 @Modifying(clearAutomatically = true)를 설정하거나 직접 쿼리에 updated_at = CURRENT_TIMESTAMP를 추가해야 한다.
    3. @Query를 이용한 insert, update, delete는 영속성 컨텍스트를 거치지 않고 실행되므로, 이에 대한 고려가 필요하다.
  8. @Column(length=) 보단 @Size 를 사용하자
    1. @Column(length=...)와 @Size는 둘 다 DDL 생성 시 반영되지만, @Size를 사용하면 Hibernate의 Bean Validation이 활성화되어 persist, update 전 데이터 검증이 가능하다.
  9. DISTINCT 사용시 기대한 결과가 나오지 않을 수 있다.
    1. JPA에서 countByLastName()을 호출하면, 내부적으로 SELECT DISTINCT u.id FROM User u WHERE u.lastName = ?가 실행된다.
    2. countDistinctByLastName()을 호출하더라도 같은 결과를 반환하므로, 원하는 결과를 얻기 위해 JPQL 또는 네이티브 쿼리를 직접 작성하는 것이 좋다.

@GeneratedValue를 사용하지 않는다면 쿼리가 두 번 실행되지 않도록 주의해야 한다

엔티티의 ID 값을 @GeneratedValue를 사용하여 자동 생성하지 않고 직접 할당하는 경우, 예상치 못한 추가 쿼리가 실행될 수 있다.

Spring Data JPA의 SimpleJpaRepository는 save() 호출 시 내부적으로 persist() 또는 merge()를 호출한다.

java
복사편집
public <S extends T> S save(S entity) {
    if (entityInformation.isNew(entity)) {
        em.persist(entity);
        return entity;
    } else {
        return em.merge(entity);
    }
}

위 코드에서 isNew() 메서드를 통해 엔티티가 새로운 객체인지 여부를 판단하여 persist() 또는 merge()를 선택하는 것을 확인할 수 있다.

JPA는 기본적으로 ID 값을 기준으로 엔티티가 신규 객체인지 판단한다.

  • ID가 원시 타입(long, int 등)인 경우: 값이 0이면 새로운 객체로 간주
  • ID가 객체 타입(Long, Integer, String 등)인 경우: null이면 새로운 객체로 간주

즉, ID 필드가 @GeneratedValue 없이 직접 할당된 경우, isNew()가 false를 반환하여 merge()가 실행된다.

merge()는 기존 엔티티와 변경된 엔티티를 병합하는 과정이 필요하므로, 먼저 기존 데이터를 조회하는 SELECT 쿼리를 실행한다.

따라서 신규 엔티티임에도 불구하고 SELECT 쿼리가 실행되는 비효율적인 상황이 발생할 수 있다.

이러한 문제를 해결 하기 위해

ID 값을 직접 할당하는 경우, Persistable 인터페이스를 구현하여 isNew()의 판단 기준을 변경할 수 있다.

public class Item implements Persistable<String> {
    @Id private String id;

    @CreatedDate private LocalDateTime createdDate;
    
    @Override
    public String getId() {
        return id;
    }

    @Override
    public boolean isNew() {
        return createdDate == null;
    }
}
@MappedSuperclass
public abstract class AbstractEntity<ID> implements Persistable<ID> {

  @Transient private boolean isNew = true; 

  @Override
  public boolean isNew() {
    return isNew; 
  }

  @PrePersist 
  @PostLoad
  void markNotNew() {
    this.isNew = false;
  }
}

테스트 코드에서 spring.jpa.hibernate.ddl-auto=validate를 활용한 스키마 검증

Hibernate의 DDL 스키마 검증 기능을 활용하면 엔티티 클래스와 실제 데이터베이스 스키마 간의 불일치를 감지할 수 있다.

테스트 환경에서 spring.jpa.hibernate.ddl-auto=validate를 설정하면 애플리케이션 실행 시점에 Hibernate가 스키마를 검증하며,

엔티티와 데이터베이스 간의 차이가 있을 경우 예외를 발생시킨다.

@DataJpaTest(
    properties = "spring.jpa.hibernate.ddl-auto=validate"
)
@AutoConfigureTestDatabase(replace = Replace.NONE)
public class SchemaValidationTest {
 
    @Test
    public void testSchemaValidity() {}
     
}

샘플 코드 : https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/SchemaValidationTest.java

hibernate의 parameter padding 로 in 쿼리 최적화

JPA에서 IN 절을 사용할 때, 파라미터 개수에 따라 서로 다른 SQL 쿼리가 생성된다.

예를 들어, IN 절의 파라미터 개수가 3개, 4개, 5개, 6개일 경우, 각각 다른 SQL 쿼리가 생성된다.

이로 인해 Execution Plan이 매번 새롭게 생성되어 성능 저하를 초래할 수 있다.

 

Hibernate에서는 in_clause_parameter_padding 옵션을 활성화하여 IN 절의 파라미터 개수를 일정한 크기로 패딩(padding) 처리함으로써,

Execution Plan의 재사용을 가능하게 하고 성능을 최적화할 수 있다.

in_clause_parameter_padding 옵션을 활성화 하면

2의 거듭제곱 단위(1, 2, 4, 8, 16, 32, 64, 127)로 패딩 처리하게되어

3,4,5,6 파라미터 4개의 쿼리가 2개의 쿼리로 줄어들게 된다.

spring.jpa.properties.hibernate.query.in_clause_parameter_padding=true
spring.jpa.properties.hibernate.query.plan_cache_max_size: 2048

샘플 코드: https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/HibernateInClauseParameterPaddingTest.java

비지니스에서 나오는 자연키는 @NaturalId 를 사용

JPA는 기본적으로 PK(Primary Key)를 기준으로 1차 캐시를 활용하여 엔티티를 관리한다.

그러나 PK가 아닌 비즈니스 자연키(Natural Key)로 조회하는 경우,

JPA의 기본 1차 캐시를 활용할 수 없어 불필요한 데이터베이스 조회가 발생할 수 있다.

 

Hibernate에서는 이러한 문제를 해결하기 위해 @NaturalId 어노테이션을 제공하며,

이를 활용하면 자연키(Natural Key) 기반의 1차 캐싱을 활성화할 수 있다.

해당 기능은 Hibernate 의 기능이기 때문에

JPA EntityManger이 아닌 Hibernate Session을 사용해야한다.

 

자연키 필드에 @NaturalId를 부여하고

@Entity
public class PaymentCard{
		@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    
    @NaturalId
    @Column(name = "number", unique = true)
    private String number;

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

    @Column(name = "expiryDate")
    private String expiryDate;
}

Hibernate Session을 사용하여 조회하는 메소드를 작성한다.

@RequiredArgsConstructor
class PaymentCardsQueryImpl implements PaymentCardsQuery {

  private final EntityManager entityManager;

  @Override
  public Optional<PaymentCard> findByNumber(String paymentCardNumber) {
      return entityManager.unwrap(Session.class)
              .bySimpleNaturalId(PaymentCard.class)
              .loadOptional(paymentCardNumber);
  }
}

샘플 코드: https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/HibernateNaturalIdCacheTest.java

DynamicInsert & DynamicUpdate로 동적 쿼리

JPA는 모든 필드에 대해 SQL을 생성하고 캐싱하여 쿼리 실행 속도를 높이는 방식을 사용한다.

하지만, 업데이트 시 모든 필드를 포함하여 실행하기 때문에 불필요한 연산이 발생할 수 있다.

 

DynamicInsert, DynamicUpdate 는 Hibernate에서 제공하는동적 SQL 생성 기능으로,SQL 최적화를 위해 필요한 컬럼만 포함한 쿼리를 동적으로 생성 할 수 있다.

 

변경된 컬럼만을 가지고 SQL 구문을 생성해야하기에 엔티티 필드 수준의 추적이필요하게되며

그로인해 성능 오버헤드가 발생하게된다

 

따라서 이 기능은 항상 사용하는것이 아닌 필요한 경우에만 사용해야하게되는데

  1. 엔티티가 많은 컬럼을 가진경우
  2. 컬럼 수준의 lock 을 사용하는경우
  3. 컬럼 수준의 버전을 사용하는경우
  4. 다양한 컬럼에서 동시성 이슈가 발생 가능한경우

사용하는법은 엔티티에 어노테이션을 추가만 하면 된다.

@DynamicUpdate
@DynamicInsert
public class PaymentCard{}
//적용 전
update PaymentCard
set cvc=?,expiry_date=?,owner_id=? where	id=?

//적용 후        
update PaymentCard set	cvc=? whereid=?

//적용 전
insert into tb_payment (amount, create_at, installment_month, payment_card_id, vat) 
values (?, ?, ?, ?, ?)
        
//적용 후        
insert into tb_payment_card (number, owner_id) 
values (?, ?)

샘플 코드: https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/HibernateDynamicUpdateTest.java

대규모 작업시 StatelessSession 으로 처리

기존에 하이버네이트가 사용하던 StatefulSession 은

SessionFactory를 통해 생성되며 1차캐시,더티채킹, AutoFlush를 원하는 트랜젝션에 적합한 방식이다

 

StatelessSession은 SessionFactory를 통해 생성되며 1차캐시 및 2차캐시, 쿼리캐시를 지원하지않는다

더티채킹을 하지않으며 Lazy로딩을 지원하지않고

하이버네이트의 이벤트 모델과 인터셉터가 동작하지않는다

 

따라서 더티채킹, Lazy loading, 캐시 등이 필요하지 않는 Read-Only 혹은 Batch Processing에서 StatfulSession 보다 성능을 챙길수있고 오버헤드를 줄이는데 도움이 된다

public void statelessSaveAll(List<PaymentCard> paymentCards) {
    StatelessSession statelessSession = entityManager.getEntityManagerFactory()
            .unwrap(SessionFactory.class)
            .openStatelessSession();

    Transaction tx = statelessSession.beginTransaction();
    try {
        for (PaymentCard card : paymentCards) {
            statelessSession.insert(card);
        }
        tx.commit();
    } catch (Exception e) {
        tx.rollback();
        throw e;
    } finally {
        statelessSession.close();
    }
}

샘플 코드: https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/HibernateStatelessSessionTest.java

Slow쿼리 로그 활성화

hibernate의 옵션을 사용하여 slow 쿼리에 대해 로그를 남기도록 지정 가능하다

spring.jpa.properties.hibernate.session.events.log.LOG_QUERIES_SLOWER_THAN_MS=1

위와같이 설정하면

Slow query took 4 milliseconds [select pc1_0.id,pc1_0.create_at,pc1_0.cvc,pc1_0.expiry_date,pc1_0.number,pc1_0.owner_id from tb_payment_card pc1_0]

어떤 쿼리를 실행시 발생했는지 로그로 출력한다.

샘플 코드: https://github.com/birariro/jpa-hibernate-tip/blob/main/src/test/java/com/example/jpahibernatetip/HibernateSlowQueryLogTest.java

MySQL엔진 사용시 네이티브 쿼리 로그 출력

MySQL사용시 쿼리 로그 출력을 Hibernate 설정이나 P6spy 같은 라이브러리를 사용하는 방법 말고

네이티브쿼리의 로그를 활성화 가능하다.

spring:
  datasource:
    hikari:
      data-source-properties:
        profileSql: true # Driver에서 전송되는 쿼리 출력
        logger: Slf4JLogger # 쿼리 출력시 사용 로거
        maxQuerySizeToLog: 999999 # 출력할 쿼리 최대 길이

JPA 쿼리 로그 출력

spring:
  jpa:
    properties:
      hibernate:
        highlight_sql: true
        format_sql: true 
        use_sql_comments: true 

logging:
  level:
    org.hibernate.orm.jdbc.bind: TRACE