Akashic Records

Spring Data JPA - 특징 및 개발주의사항 본문

Spring.io

Spring Data JPA - 특징 및 개발주의사항

Andrew's Akashic Records 2024. 11. 26. 15:04
728x90

Spring Data JPA(Spring Data Java Persistence API)

JPA의 대표적 특징

JPA(Java Persistence API)는 자바 표준 ORM(Object-Relational Mapping) 프레임워크로, 자바 객체와 관계형 데이터베이스 사이의 데이터를 매핑하여 데이터베이스 작업을 쉽게 수행할 수 있도록 해줍니다. JPA는 복잡한 SQL을 자주 작성하지 않고도 데이터베이스와 상호작용할 수 있도록 많은 유용한 특징을 제공합니다. 이 중에서도 대표적인 특징인 1차 캐시, 쓰기 지연, 지연 로딩, 변경 감지 등을 아래에서 설명하겠습니다.

1. 1차 캐시

1차 캐시는 JPA에서 매우 중요한 개념으로, 영속성 컨텍스트(Persistence Context) 내부에 엔티티를 저장하는 메커니즘입니다. 이는 엔티티의 생명 주기를 관리하고 데이터베이스와의 상호작용을 효율적으로 처리하는 데 기여합니다.

  • 엔티티 조회 시 캐싱:
    • EntityManager가 특정 엔티티를 조회하면, 해당 엔티티가 먼저 1차 캐시에 있는지 확인하고, 있다면 데이터베이스에서 다시 가져오지 않고 캐시된 객체를 반환합니다.
    • 같은 트랜잭션 내에서는 동일한 엔티티를 계속해서 재사용하기 때문에 데이터베이스로의 중복 접근을 방지하여 성능을 최적화합니다.
    User user1 = entityManager.find(User.class, 1L); // 첫 번째 조회 -> 데이터베이스 접근
    User user2 = entityManager.find(User.class, 1L); // 두 번째 조회 -> 캐시 사용
  • 엔티티 저장 시 캐싱:
    • 새로운 엔티티를 persist() 메소드를 통해 영속성 컨텍스트에 저장하면, 엔티티가 데이터베이스에 바로 저장되는 것이 아니라 1차 캐시에 저장되고 이후 트랜잭션이 커밋될 때 실제 데이터베이스에 반영됩니다.

2. 쓰기 지연 (Write-Behind / Write-Behind Cache)

쓰기 지연은 엔티티의 변경 사항을 영속성 컨텍스트에 쌓아 두었다가, 트랜잭션이 커밋될 때 한꺼번에 데이터베이스에 반영하는 방식입니다.

  • 트랜잭션 내에서의 지연:
    • JPA는 엔티티 매니저를 통해 삽입, 수정, 삭제와 같은 작업을 수행할 때, 즉시 SQL을 실행하지 않고 쓰기 지연 저장소에 쌓아둡니다.
    • 이 작업들은 트랜잭션 커밋 시점에 한꺼번에 데이터베이스로 전송됩니다. 이를 통해 여러 번의 SQL 호출을 하나의 배치 처리로 최적화할 수 있어 성능이 향상됩니다.
    User user = new User();
    user.setName("John Doe");
    entityManager.persist(user); // 여기서는 DB에 저장되지 않음
    
    entityManager.getTransaction().commit(); // 이때 쌓인 작업이 DB에 반영됨

이렇게 쓰기 지연을 통해 데이터베이스와의 불필요한 왕래를 줄이고 최적화된 처리를 할 수 있습니다.

3. 변경 감지 (Dirty Checking)

JPA는 변경 감지 기능을 통해, 엔티티의 상태가 변경되었을 때 이를 자동으로 감지하고 적절한 SQL 문을 생성하여 데이터베이스에 반영합니다.

  • 변경 감지 메커니즘:
    • EntityManager에 의해 관리되는 영속 상태의 엔티티는 지속적으로 변경을 감지합니다.
    • 엔티티의 필드 값이 변경되면, 트랜잭션 커밋 시점에 변경된 부분만 감지하여 필요한 UPDATE 쿼리를 생성합니다.
    User user = entityManager.find(User.class, 1L);
    user.setName("Jane Doe"); // 필드 값 변경
    
    entityManager.getTransaction().commit(); // 변경 감지를 통해 UPDATE 쿼리가 자동 실행됨

이 덕분에, 명시적으로 update() 메소드를 호출할 필요 없이 엔티티의 상태를 변경하는 것만으로도 데이터베이스에 자동 반영됩니다.

4. 지연 로딩 (Lazy Loading)과 즉시 로딩 (Eager Loading)

JPA는 지연 로딩 즉시 로딩이라는 두 가지 데이터 로딩 방식을 지원하여, 엔티티 간의 관계 데이터를 언제 가져올지를 개발자가 선택할 수 있도록 합니다.

  • 지연 로딩 (Lazy Loading):
    • 엔티티를 조회할 때, 관계에 있는 다른 엔티티는 실제로 필요할 때 가져오는 방식입니다. 즉, 참조된 엔티티를 사용하려는 시점에 쿼리가 실행됩니다.
    • 이는 성능 최적화에 매우 유리한 방식으로, 불필요한 데이터 로딩을 줄일 수 있습니다.
    @OneToMany(mappedBy = "user", fetch = FetchType.LAZY)
    private List<Order> orders;
    위의 예에서 orders는 User 엔티티를 조회할 때 바로 로드되지 않고, 해당 컬렉션을 처음 사용할 때 쿼리가 실행됩니다.
  • 즉시 로딩 (Eager Loading):
    • 엔티티를 조회할 때, 연관된 모든 엔티티를 즉시 가져오는 방식입니다.
    • 데이터가 한 번에 로딩되므로 간단하게 모든 정보가 필요한 경우에는 유리하지만, 불필요한 데이터까지 모두 로딩될 수 있어 성능에 영향을 줄 수 있습니다.
    @OneToMany(mappedBy = "user", fetch = FetchType.EAGER)
    private List<Order> orders;

위의 예에서 orders는 User 엔티티를 조회할 때 즉시 로딩됩니다.

728x90

JPA 개발시 주의사항

JPA는 데이터 접근과 관리를 보다 쉽게 해주지만, 올바르게 사용하지 않으면 성능 문제나 데이터 일관성 문제 등 다양한 문제가 발생할 수 있습니다. JPA를 사용한 애플리케이션 개발을 진행할 때 주의해야 할 주요 사항들을 아래에 설명합니다.

1. 지연 로딩과 N+1 문제

지연 로딩 (Lazy Loading)

  • JPA에서 엔티티 간의 관계(@OneToMany, @ManyToOne)는 기본적으로 지연 로딩(Lazy Loading)으로 설정할 수 있습니다. 지연 로딩은 관계된 엔티티를 실제 필요할 때 가져오는 방식이기 때문에, 불필요한 쿼리 실행을 줄일 수 있어 성능상 유리할 수 있습니다.
  • 하지만 잘못 사용하면 N+1 문제를 일으킬 수 있습니다. 예를 들어, 부모 엔티티와 자식 엔티티를 지연 로딩으로 설정하고 부모 엔티티를 반복문으로 조회할 때, 자식 엔티티에 대해 반복적으로 쿼리를 실행할 수 있습니다.
  • List<Department> departments = departmentRepository.findAll(); for (Department dept : departments) { List<Employee> employees = dept.getEmployees(); // 여기서 각 부서마다 추가 쿼리 발생 (N+1 문제) }

해결 방법

  • 페치 조인 (Fetch Join) 사용: 필요한 경우 명시적으로 페치 조인을 사용하여 데이터를 한 번에 조회합니다.
  • @Query("SELECT d FROM Department d JOIN FETCH d.employees") List<Department> findAllWithEmployees();
  • 배치 사이즈 설정: @BatchSize 어노테이션을 사용하거나 hibernate.default_batch_fetch_size를 설정해 데이터베이스 쿼리를 한 번에 처리할 수도 있습니다.

2. 엔티티 설계 시 주의 사항

양방향 연관관계

  • 양방향 연관관계는 엔티티 간의 두 방향 참조를 의미합니다. 양방향 매핑을 사용할 때는 무한 루프에 빠지지 않도록 주의해야 합니다. 예를 들어, A가 B를 참조하고 B도 A를 참조하는 상황에서는 JSON 직렬화 시 무한 참조를 일으킬 수 있습니다.
  • 해결 방법:
    • @JsonIgnore: 양방향 연관관계 중 하나의 참조에 @JsonIgnore를 사용해 직렬화를 막을 수 있습니다.
    • 연관관계 주인(owner)의 설정: JPA에서는 외래 키를 관리하는 엔티티를 연관관계 주인이라고 합니다. 연관관계의 주인을 명확히 설정해야 데이터 변경 시 오해가 없으며, JPA가 데이터베이스 업데이트 시점을 정확히 이해할 수 있습니다.

기본 키 선택

  • JPA에서는 보통 엔티티의 식별자로 ID 필드를 사용합니다. 기본 키로 자동 증가 키 (@GeneratedValue)를 사용할 때 주의할 점은 테이블 생성 전략이나 데이터베이스 종속성입니다.
    • GenerationType.IDENTITY: MySQL 같은 경우 사용되며, 식별자가 자동으로 생성되기 때문에 엔티티의 삽입이 이루어지는 즉시 ID가 설정됩니다.
    • GenerationType.SEQUENCE: Oracle 같은 경우 시퀀스를 사용합니다.
    UUID 비즈니스 의미가 있는 자연 키를 사용하는 경우에도 비즈니스 요구사항과 데이터베이스 성능을 잘 검토해야 합니다.

3. 영속성 컨텍스트와 쓰기 지연

쓰기 지연 (Write-Behind)

  • JPA는 쓰기 지연을 통해 변경 사항을 트랜잭션 커밋 시 한꺼번에 데이터베이스에 반영합니다. 이 과정에서 적절한 트랜잭션 처리가 이루어지지 않으면 예상하지 못한 상태에서 데이터가 저장되거나 변경될 수 있습니다.

주의 사항

  • 트랜잭션을 명확하게 정의하고, 커밋 이전에 필요한 모든 변경 사항이 올바르게 반영되었는지 확인해야 합니다.
  • 엔티티가 Detached 상태로 빠져나가거나 영속성 컨텍스트가 지워질 경우 변경 감지(Dirty Checking)가 적용되지 않습니다. merge() 메소드를 이용해 적절히 영속 상태로 변경해야 합니다.

4. 변경 감지 (Dirty Checking)

주의 사항

  • JPA는 엔티티의 필드 값을 트랜잭션 동안 계속 감시하고, 변경된 값이 있으면 데이터베이스에 자동으로 반영합니다. 이 기능은 편리하지만 의도하지 않은 변경이 발생할 수 있으므로 주의해야 합니다.
  • 해결 방법:
    • 불필요한 변경을 막기 위해 DTO(Data Transfer Object) 패턴을 사용해 필요한 필드만 업데이트하거나, 명시적으로 필요한 엔티티만 merge() 하여 관리합니다.

5. 엔티티 라이프사이클 관리

생명 주기 상태

  • 엔티티는 비영속(Transient), 영속(Persistent), 준영속(Detached), 삭제(Removed)의 생명주기를 가집니다. 각 상태에서의 엔티티 동작을 명확히 이해하고 관리해야 합니다.
  • 영속성 전이 (Cascade):
    • 관계가 있는 엔티티를 자동으로 관리하고자 할 때 Cascade 속성을 사용합니다. 그러나 모든 관계에서 CascadeType.ALL을 사용하는 것은 위험할 수 있습니다. 특정 상황에서만 PERSIST, MERGE, REMOVE 등을 사용해 관계의 올바른 전이를 정의해야 합니다.
      @OneToMany(mappedBy = "user", cascade = CascadeType.ALL)
      private List<Order> orders;

6. 데이터베이스 락과 동시성 관리

동시성 제어

  • 낙관적 락(Optimistic Locking) 비관적 락(Pessimistic Locking)을 적절히 사용해 데이터의 일관성을 유지해야 합니다.
    • 낙관적 락 @Version 어노테이션을 사용하여 버전 정보를 통해 동시에 업데이트될 때 발생할 수 있는 충돌을 방지합니다.
      @Version
      private int version;
    • 비관적 락은 데이터가 자주 갱신되거나 동시성이 높은 경우에 사용되며, SQL의 SELECT ... FOR UPDATE 구문과 유사하게 구현할 수 있습니다.

7. Query 작성 및 성능 최적화

JPQL 사용 시 주의 사항

  • JPA는 JPQL(Java Persistence Query Language)을 사용해 객체를 데이터베이스 테이블에 매핑해 쿼리를 작성합니다. 그러나 JPQL이 데이터베이스의 특수 기능을 충분히 활용하지 못할 수 있습니다. 이 경우 네이티브 쿼리를 사용해 복잡한 쿼리나 성능이 중요한 쿼리를 작성할 수 있습니다.

성능 최적화

  • 배치 처리(Batching)를 이용하여 반복적인 쿼리 실행을 줄이는 것이 좋습니다.
  • 엔티티 간의 관계가 복잡한 경우 네이티브 쿼리 쿼리 최적화를 통해 필요한 데이터만 조회하도록 설계해야 합니다.

8. 불필요한 flush() 호출 방지

  • flush() 메소드는 영속성 컨텍스트의 변경 내용을 데이터베이스에 반영하는 역할을 합니다. 필요하지 않은 시점에 flush()가 호출되면 성능에 영향을 줄 수 있습니다. 가능한 한 트랜잭션 커밋 시점에 한 번 호출되도록 관리합니다.

9. 엔티티 그래프 사용

  • 복잡한 연관관계가 있을 때 필요한 데이터를 한 번에 조회하기 위해 엔티티 그래프(Entity Graph)를 사용하면 JPQL 작성 없이 필요한 연관 엔티티를 간편히 조회할 수 있습니다.
@EntityGraph(attributePaths = {"department", "manager"})
List<Employee> findAllWithDepartmentAndManager();

10. 성능 모니터링 및 튜닝

  • JPA를 사용할 때 데이터베이스에 실행되는 SQL 쿼리를 로깅하여 성능 문제를 조기에 발견하고 해결하는 것이 중요합니다. Spring Boot에서는 아래와 같은 설정으로 SQL 로깅을 활성화할 수 있습니다.
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
728x90

'Spring.io' 카테고리의 다른 글

Spring Data JPA - @Transactional  (0) 2024.11.26
Spring Data JPA - 분산 트랜잭션  (0) 2024.11.26
Spring Data JPA - JpaRepository  (0) 2024.11.26
Spring Data JPA - EntityManager  (0) 2024.11.26
Spring Data JPA(Spring Data Java Persistence API)  (0) 2024.11.26
Comments