[ 김영한 자바 ORM 표준 JPA 프로그래밍 - 기본편 #12 ] 실무 참고 - 개발

김수호·2024년 5월 18일
0
post-thumbnail

✔️ 변경 감지와 병합(merge)

  • 준영속 엔티티 ?
    • 영속성 컨텍스트가 더는 관리하지 않는 엔티티를 말한다.
    • 만약 임의로 만들어낸 엔티티가 식별자를 가지고 있으면 준영속 엔티티로 볼 수 있다.
  • 준영속 엔티티를 수정하는 2가지 방법
    • 변경 감지 기능 사용
      • 영속성 컨텍스트에서 엔티티를 다시 조회한 후에 데이터를 수정하는 방법
        • ex) 트랜잭션 안에서 엔티티를 다시 조회, 변경할 값 적용 -> 트랜잭션 커밋 시점에 변경 감지(Dirty Checking)이 동작해서 데이터베이스에 UPDATE SQL 실행
    • 병합(merge) 사용
      • 병합은 준영속 상태의 엔티티를 영속 상태로 변경할 때 사용하는 기능이다.
      • 참고) 병합 동작 방식
        • merge() 를 실행한다.
        • 파라미터로 넘어온 준영속 엔티티의 식별자 값으로 1차 캐시에서 엔티티를 조회한다.
          • 만약 1차 캐시에 엔티티가 없으면 데이터베이스에서 엔티티를 조회하고, 1차 캐시에 저장한다.
        • 조회한 영속 엔티티(mergeMember)에 member 엔티티의 값을 모두 채워넣는다.
          • member 엔티티의 모든 값을 mergeMember 에 밀어 넣는다. 이때 mergeMember 의 "회원1" 이라는 이름이 "회원명변경"으로 바뀐다.
        • 영속 상태인 mergeMember 를 반환한다.
        • 트랜잭션 커밋 시점에 변경 감지 기능이 동작해서 데이터베이스에 UPDATE SQL이 실행된다.
        • 참고) member 는 영속 상태로 바뀌지 않는다. 병합된 mergeMember 가 영속성 컨텍스트에서 관리되는 객체이다. 따라서 이후에 로직에서 뭔가 다시 더 사용할 일이 있으면 mergeMember 로 쓰자.
        • 주의) 변경 감지 기능을 사용하면 원하는 속성만 선택해서 변경할 수 있지만, 병합을 사용하면 모든 속성이 변경된다. 그러므로 만약 병합시 값이 없으면 null로 업데이트 할 위험도 있다. (병합은 모든 필드를 교체한다.)
  • 실무에서는 엔티티를 변경할 때는 병합(merge)이 아닌 변경 감지를 사용하자.
    • 컨트롤러에서 어설프게 식별자가 있는 엔티티를 생성해서 넘기지 말자.
      • 웹 계층에서만 사용하는 DTO나 객체를 서비스 계층에 보내지 않으려고 컨트롤러에서 엔티티를 만들거나 하지 말자. 아래 예시와 같이 명확히 전달하는게 더 나은 설계다.
    • 트랜잭션이 있는 서비스 계층에 식별자(id)와 변경할 데이터를 명확하게 전달하자. (파라미터 or DTO)
      • ex) 컨트롤러: itemService.updateItem(itemId, form.getName(), form.getPrice(), form.getStockQuantity());
      • ex) 서비스: @Transactional public void updateItem(Long itemId, String name, int price, int stockQuantity) { ... }
      • 참고) 만약 전달해야하는 데이터가 많은 경우, 별도 DTO를 만들고 해당 DTO를 전달해줘도 무관하다.
    • 트랜잭션이 있는 서비스 계층에서 영속 상태의 엔티티를 조회하고, 엔티티의 데이터를 직접 변경하자.
      • 참고) 변경시에는 엔티티 내부에 의미있는 메서드( ex. xxx.changePrice() )를 만들어서 거기에서 처리하자.
        • 서비스 계층에서 비즈니스 로직에 하나하나 필드마다 setter 를 호출해서 변경하지 말자. ( 서비스 규모가 커지고 기능이 많아지면, 도대체 어디서 바꾸는지 추적하기 힘들어진다. )
    • 그러면 이후 트랜잭션 커밋 시점에 변경 감지가 실행된다.
  • 참고) 주로 merge 는 영속 상태의 엔티티가 어떤 이유로 영속 상태를 벗어난 상황에서, 다시 영속 상태가 되어야 할 때 사용한다. ( 데이터를 변경하는 목적으로 사용하는 것은 권장하지 않는다. )
    • 실무에서 merge 를 사용할 일은 거의 없다.

 

✔️ OSIV와 성능 최적화

  • JPA: Open EntityManager In View , 하이버네이트: Open Session In View
    • 참고) 관례상 OSIV 라 한다.
    • 참고) spring.jpa.open-in-view : true (default)
  • OSIV ON ( spring.jpa.open-in-view : true )
    • 애플리케이션을 구동해보면 다음과 같은 warn 로그를 확인할 수 있다.
      • spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering. Explicitly configure spring.jpa.open-in-view to disable this warning
    • 이 로그를 남기는 데에는 이유가 있다. OSIV 전략은 트랜잭션 시작처럼 최초 데이터베이스 커넥션 시작 시점부터 API 응답이 끝날 때 까지 영속성 컨텍스트와 데이터베이스 커넥션을 유지한다. ( 그래서 View Template 이나 API 컨트롤러에서 지연 로딩이 가능해진다. )
    • 지연 로딩은 영속성 컨텍스트가 살아있어야 가능하고, 영속성 컨텍스트는 기본적으로 데이터베이스 커넥션을 유지한다. 이것 자체가 큰 장점이다.
    • 그런데 이 전략은 치명적 단점이 존재한다. 너무 오랜 시간동안 데이터베이스 커넥션 리소스를 사용하기 때문에, 실시간 트래픽이 중요한 애플리케이션에서는 커넥션이 모자랄 수 있다. 이것은 결국 장애로 이어진다. ( 예를 들어서 컨트롤러에서 외부 API를 호출한다고 가정해보자. 그러면 외부 API 처리 시간 만큼 커넥션 리소스를 반환하지 못하고, 유지해야 한다. )
  • OSIV OFF ( spring.jpa.open-in-view : false )
    • OSIV를 끄면 트랜잭션을 종료할 때 영속성 컨텍스트를 닫고, 데이터베이스 커넥션도 반환한다. 따라서 커넥션 리소스를 낭비하지 않는다.
    • 그런데 OSIV 를 끄면 모든 지연로딩을 트랜잭션 안에서 처리해야 한다. 따라서 지금까지 작성한 많은 지연 로딩 코드를 트랜잭션 안으로 넣어야 하는 단점이 있다. 그리고 view template 에서 지연로딩이 동작하지 않는다. 결론적으로 트랜잭션이 끝나기 전에 지연 로딩을 강제로 호출해 두어야 한다.
  • 커멘트와 쿼리 분리
    • 실무에서 OSIV를 끈 상태로 복잡성을 관리하는 좋은 방법이 있다. 바로 Command 와 Query 를 분리하는 것이다.
    • 보통 비즈니스 로직은 특정 엔티티 몇 개를 등록하거나 수정하는 것이므로 성능이 크게 문제가 되지 않는다. 그런데 복잡한 화면을 출력하기 위한 쿼리는 화면에 맞추어 성능을 최적화 하는 것이 중요하다. 하지만 그 복잡성에 비해 핵심 비즈니스에 큰 영향을 주는 것은 아니다
    • 그래서 크고 복잡한 애플리케이션을 개발한다면, 이 둘의 관심사를 명확하게 분리하는 선택은 유지보수 관점에서 충분히 의미있다.
    • 단순하게 설명해서 다음처럼 분리하는 것이다.
      • OrderService
        • OrderService: 핵심 비즈니스 로직
        • OrderQueryService: 화면이나 API에 맞춘 서비스 (주로 읽기 전용 트랜잭션 사용)
      • 보통 서비스 계층에서 트랜잭션을 유지한다. 두 서비스 모두 트랜잭션을 유지하면서 지연 로딩을 사용할 수 있다.
  • 실무 참고)
    • 고객 서비스와 같은 실시간 API를 제공하는 애플리케이션은 OSIV를 끄고, ADMIN 처럼 커넥션을 많이 사용하지 않는 곳에서는 OSIV 를 켠다.

 

✔️ JPA를 사용하면서 API를 개발할 때 참고

  • API 를 만들때는 엔티티를 파라미터로 받지 말자. 또한 엔티티를 외부에 노출해서도 안된다. 각 API 스펙에 맞는 별도의 DTO를 생성하자.
  • Lombok 애노테이션 사용
    • 엔티티에는 Getter 정도만 사용하고, 최대한 사용을 자제하자.
    • DTO는 데이터 이동용으로만 주로 사용하기 때문에 크게 제한을 두지 않고 자유롭게 사용하자.
  • 엔티티를 직접 노출할 때는 양방향 연관관계가 걸린 곳은 꼭! 한 곳을 @JsonIgnore 처리해야 한다. 안그러면 양쪽을 서로 호출하면서 무한 루프가 걸린다.
    • 참고로 위에서 설명한 대로 엔티티를 API 응답으로 외부로 반환하는 것은 좋지 않다. DTO로 변환해서 반환하는 것이 더 좋은 방법이다.
  • 지연 로딩(LAZY)을 피하기 위해 즉시 로딩(EAGER)으로 설정하면 안된다. 즉시 로딩 때문에 연관관계가 필요 없는 경우에도 데이터를 항상 조회해서 성능 문제가 발생할 수 있다. 즉시 로딩으로 설정하면 성능 튜닝이 매우 어려워 진다. 항상 지연로딩을 기본으로 하고, 성능 최적화가 필요한 경우에는 페치 조인(fetch join)을 사용해라.
  • JPA 에서 조회 시 반환 타입은 기본적으로 엔티티나 값 타입으로만 반환할 수 있다. 그 외 임의로 만든 DTO 등은 안된다. 만약 그걸 하려면 new 명령을 사용해야 한다.
  • (성능의 관점에서) JPA 에서 조회 시 엔티티로 조회하는 것과 DTO로 조회하는 것 중 무엇을 선택해야 할까? ( 엔티티 조회 방식은 모든 컬럼 정보를 다 조회하기에, 경우에 따라 복잡하거나 대량의 정보 추출시, 원하는 정보만 DTO로 조회하는 편이 성능 관점에서 더 나을 때도 있다. )
    • 실무 권장 )
      • 1) 엔티티 조회 방식으로 우선 접근
        • 엔티티로 조회해서 이후에 DTO 로 변환한다. 이때, 필요 시 페치 조인으로 성능을 최적화하자. (대부분의 성능 이슈는 해결된다.)
        • 참고) 컬렉션은 페치 조인시 페이징이 불가능한 한계 등이 있다. 따라서 아래와 같이 처리하는게 좋다.
          • @xxxToOne 관계는 페치 조인으로 쿼리 수를 최적화하자.
          • 컬렉션은 페치 조인 대신에 지연 로딩을 유지하고, hibernate.default_batch_fetch_size, @BatchSize 로 최적화하자.
      • 2) 엔티티 조회 방식으로 해결이 안되면 DTO 조회 방식 사용
        • @xxxToOne 관계들은 먼저 조회하고, 컬렉션은 별도로 조회한다.
        • 일대다 관계인 컬렉션은 IN 절을 활용해서 최대한 최적화하자.
      • 3) DTO 조회 방식으로 해결이 안되면 Native SQL or 스프링 JdbcTemplate 을 사용해서 SQL을 직접 사용한다.
      • 참고) 엔티티 조회 방식은 페치 조인이나, hibernate.default_batch_fetch_size, @BatchSize 같이 코드를 거의 수정하지 않고, 옵션만 약간 변경해서, 다양한 성능 최적화를 시도할 수 있다. 반면에 DTO를 직접 조회하는 방식은 성능을 최적화 하거나 성능 최적화 방식을 변경할 때 많은 코드를 변경해야 한다. (직접 로직으로 핸들링 해줘야 하므로)
  • repository 는 가급적 순수한 엔티티를 조회하도록 하는 것이 좋다. 그런데 만약 정말 복잡한 쿼리들로 원하는 정보만 뽑아서 DTO 로 바로 조회해야 하는 경우가 있다면, 그런 경우는 repository 패키지 내부에 별도 패키지를 두고 관리하는 편이 운영시 더 효과적이다.

강의를 듣고 정리한 글입니다. 코드와 그림 등의 출처는 김영한 강사님께 있습니다.

profile
현실에서 한 발자국

0개의 댓글