JAVA/JPA

[JAVA / Spring / JPA] - JPA 정리

nam_ji 2024. 3. 6. 19:04

JPA 정리

용어 정리

  • EntityManager
    • 엔티티 매니저란 영속성 컨텍스트를 관리합니다.
    • 엔티티 매니저를 통하여 영속성 컨텍스트에 접근할 수 있습니다. 영속성 컨텍스트는 엔티티에 대한 조회, 수정, 삭제와 같은 API를 제공합니다.
  • PersistenceContext
    • 영속성 컨텍스트는 엔티티를 보관하고 관리합니다. 영속성 컨텍스트는 1차 캐시, 쓰기 지연, 더티 체킹과 같은 이점들을 제공합니다.
  • EntityManagerFactory
    • 엔티티 매니저 팩토리는 애플리케이션 전역으로 하나만 만들고, 요청이 오면 엔티티 매니저를 만들어서 제공하는 역할을 합니다.
  • Session vs EntityManager
    • EntityManager는 JPA 스펙이고 Session은 Hibernate에서 제공해주는 API입니다. 같다고 생각하면 됩니다.

영속성 컨텍스트 특징

1) 1차 캐시

  • 영속성 컨텍스트는 내부적으로 Map을 이용해서 엔티티들을 보관합니다. 이를 1차 캐시라고 합니다.
    Key는 엔티티에 사용한 @Id로 매핑한 식별자고, 값은 엔티티입니다.
  • 데이터베이스를 조회하기 전 영속성 컨텍스트를 조회하기 때문에 영속성 컨텍스트에 엔티티가 있을 경우 추가적인 조회가 발생하면 성능상 이점을 가져갈 수 있습니다.

2) 동일성 보장

  • 앞서 1차 캐시 개념으로 생각해보면 당연한 얘기입니다. 1차 캐시에 있는 같은 엔티티 인스턴스를 반환하기 때문에 동일성을 보장해 줄 수 있습니다.
    • Member mem1 = em.find(Member.class, 1);
      Member mem2 = em.find(Member.class, 1);
      
      mem1 == mem2

 

3) 쓰기 지연

  • 엔티티를 저장하면 엔티티 매니저는 영속성 컨텍스트에만 저장해 놓고 실제 데이터베이스에 SQL을 날리지 않습니다. 트랜잭션이 정상적으로 수행되어 커밋하는 순간 모아둔 insert SQL들을 데이터베이스에 날립니다. 한 번에 SQL을 날리기 때문에 성능상 이점을 가져갈 수 있습니다. 이것을 쓰기 지연이라고 합니다.

4) 더티 체킹

  • 기본적으로 데이터에 변경이 있을 경우 조회쿼리 -> 수정 -> 수정쿼리 이런식으로 작업을 진행해야 합니다.
    데이터 수정 쿼리에서도 실수가 발생할 수 있고, 비즈니스 로직이 SQL에 의존하게 됩니다.
    JPA에서는 이를 개선하고 더티 체킹이라는 개념을 도입합니다. JPA는 엔티티를 영속성 컨텍스트에 보관할 때, 최초 상태에 대한 스냅샷을 갖고 있습니다. 그리고 flush가 발생하는 순간 스냅샷 시점과, 현재 엔티티를 비교하여 변경된 것이 있는지 확인합니다. 변경된 엔티티가 있다면, SQL을 저장해두고 트랜잭션을 커밋하는 순간 SQL을 날립니다.
    이러한 과정 덕분에 JPA에서는 update를 사용하지 않고, 엔티티 데이터만 변경해 주기만 하면 됩니다.
    • @Transactional
      public void foo(final Long id) {
      	final Member mem = memberRepository.findById(id).orElseThrow(EntityNotFoundException::new);
      	member.changeName("foo");
        // memberRepository.save(mem); 이런 코드가 필요 없습니다.
      }

프록시

  • JPA에서는 연관 관계 객체를 데이터 조회 시점에 처음부터 조회하는 것이 아니라, 해당 객체가 사용될 시점에 데이터베이스에서 조회할 수 있도록 프록시라는 개념을 사용합니다.
    • @Entity
      @Table(name = "users")
      public class User {
      
          @Id
          @GeneratedValue(strategy = GenerationType.IDENTITY)
          private Long id;
      
          private String name;
      
          @ManyToOne
          @JoinColumn(name = "company_id")
          private Company company;
      }
      
      ```
      
      ```java
      
      public void printUserName(final String name) {
            final User user = userRepository.findByName(name).orElseThrow(EntityNotFoundException::new);
            log.info("username = {}", user.getName());
          }
      }
      ```
       
    • 위 예시에서는 user를 조회한 뒤 name을 출력하고 있습니다. 연관 관계인 company 엔티티에 대한 정보는 불필요한 상황입니다. 이런 경우 user 엔티티를 조회할 때 company 엔티티를 함께 조회하는 것은 붏필요합니다.
    • 따라서 JPA에서는 company를 실제 사용되는 시점가지 데이터베이스 조회를 지연하는 방법을 제공하는데 이를 지연로딩이라 합니다.
    • 하지만 생각해보면 해당 연관 관계를 조회하지 않는다고 해서 null로 사용할 수는 없습니다.
    • 따라서 프록시라는 가짜 객체를 넣어주는 것입니다. 클라이언트 입장에서는 진짜 객체인지 프록시인지 구분하지 앟고 사용하기만 하면 됩니다.
    •     @Transactional(readOnly = true)
          public void foo(final String name) {
              final User user = userRepository.findByName(name).orElseThrow(EntityExistsException::new);
              log.info("=============================================");
              final Company company = user.getCompany();
              log.info("company location = {}", company.getLocation());
          }
    • 위 예제처럼 company 엔티티가 사용이 될 때 영속성 컨텍스트를 통해서 데이터베이스에 조회하는데, 이 과정을 초기화라고 부릅니다.
  • 주의 사항
    1. JPA 지연로딩 방식은 JPA 구현체에 위임했습니다. 이는 하이버네이트 구현에 대한 설명입니다.
    2. 프록시가 초기화 되었다고 해서 프록시가 실제 엔티티로 바뀌는 것은 아닙니다. 프록시를 통해 엔티티에 접근합니다.
    3. 프록시 초기화는 처음 사용할 때 한 번만 진행합니다.
    4. 프록시 객체는 엔티티를 상속받아서 만들었습니다. equals 같은 타입 체크가 필요할 때 주의해야 합니다.
    5. 준영속 상태의 프록시를 조회할 경우 Lazyuinitializationexception이 발생합니다.

EntityManager와 ThreadSafe

  • 웹 애플리케이션을 EntityManager를 빈으로 주입 받아서 사용하면 멀티 스레드 환경에서 동시성 문제가 발생할 수 있습니다.
    • 스프링에서는 어떻게 스레드 세이프를 보장해 줄까?
      • 빈으로 주입 받아서 사용한 적이 없다고 할 수 있습니다. 그러나 JpaRepository를 사용하면 내부적으로 구현체인 SimpleJpaRepository에서 EntityManager를 주입받아서 사용합니다.
    • 결론은 프록시입니다. 스프링에서는 엔티티 매니저를 프록시로 감쌉니다. 그 후 EntityManager를 호출할 때 필요에 의해 내부적으로 EntityManager를 생성하는 방식으로 동작합니다.
    • 스프링에서 이러한 방식으로 동시성 문제를 해결해주기 때문에, 개발자는 동시성에 대한 이슈 없이 EntityManager를 사용할 수 있습니다.

OSIV

  • 스프링에서 기본적으로 영속성 컨텍스트의 생존 범위는 트랜잭션 범위와 같습니다.
  • 트랜잭션이 시작될 때 영속성 컨텍스트를 생성하고 트랜잭션이 끝날 때 영속성 컨텍스트를 종료합니다.
  • 앞서 얘기한 LazyinitializationException은 위 그림에서 Controller와 같이 영속성 컨텍스트가 끝나는 시점에서 프록시를 초기화할 경우 발생하게 됩니다.
  • OSIV(Open-Session-in-view) 설정은 영속성 컨텍스트를 뷰까지 열어둡니다. OSIV 설정은 Filter, interceptor 원하는 것을 선택해서 적용할 수 있습니다.
  • 동작 방식은 다음과 같습니다. 클라이언트 요청이 들어오면 영속성 컨텍스트를 생성합니다. 트랜잭션이 정상적으로 끝나면 커밋 후 영속성 컨텍스트를 이전과 같이 flush를 합니다. 그리고 영속성 컨텍스트를 종료하지 않고 서블릿이나 필터에 요청이 돌아오면 영속성 컨텍스트를 종료합니다.
  • 따라서 트랜잭션이 끝난 프레젠테이션 레이어 같은 곳에서도 영속성 컨텍스트가 살아 있습니다. 하지만 영속성 컨텍스트를 통한 변경은 트랜잭션 안에서 발생해야 하기 때문에, 수정은 불가능합니다. 트랜잭션은 없지만 트랜잭션 없이 일기를 사용하여 초기화할 수 있습니다.
  • OSIV를 이용하면 Lazy 로딩을 적극적으로 활용해서 좋아보일 수 있습니다. 하지만 단점들이 존재합니다.
    • 예측 못한 조회 쿼리들이 실행될 수 있습니다.
      • 트랜잭션 밖에서 언제든 조회가 생길 수 있기 때문에, 예측 불가능한 쿼리들이 많이 생길 수 있습니다. 오히려 처음부터 필요한 데이터들을 조회해서 DTO로 반환해서 사용하는 것이 더 효과적입니다.
      • public class UserController {
        	public void foo() {
        	    final User user = userService.getUser();
        	    log.info("companyLocation = {}", user.getCompany.getLocation()); // sql 발생
        	}
        }
    • 여러 트랜잭션에 공유할 수 있습니다.
      • 영속성 컨텍스트가 트랜잭션 밖에서 수정된 두 트랜잭션에서 다시 들어가면 영속성 컨텍스트를 플러쉬하기 때문에 예상하지 못한 데이터베이스 변경이 발생할 수 있습니다.
      • public class UserController {
        	public void foo() {
        	    final User user = userService.getUser();
        			user.setName("*****"); //
              userService.doSomething(); // 트랜잭션을 실행하는 다른 비즈니스 로직을 실행
        	}
        }
    • 성능 저하
      • 데이터베이스 커넥션을 물고 있으므로 처리량이 제한되고 성능 저하가 발생할 수 있습니다.
  • 결론
    • OSIV는 많은 단점들이 존재하여, 안티패턴으로 소개되기도 합니다.
    • 데이터 조회는 한 번에 필요한 것들을 미리 가져올 수 있는 것이 좋고, 엔티티를 트랜잭션 범위 밖으로 노출하지 말고 DTO로 반환해서 응답하는 것이 좋습니다.
    • @Transactional(readOnly = true)
      public MemberDto findMember(final Long id) {
      		final Member mem = memberRepository.findById(id).orElseThrow(EntityNotFoundException::new);
      		return new MemberDto(member.getId(), member.getName()); // Dto로 반환해서 응답한다.
      }