From 325ee47030eafea3dac37bf6a417b2f712660b81 Mon Sep 17 00:00:00 2001 From: jihukimme Date: Sun, 26 May 2024 02:17:31 +0900 Subject: [PATCH] =?UTF-8?q?=F0=9F=8E=89=EC=A2=85=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...5]\352\271\200\354\247\200\355\233\204.md" | 586 ++++++++++++++++++ ...5]\352\271\200\354\247\200\355\233\204.md" | 121 ++++ 2 files changed, 707 insertions(+) create mode 100644 "\355\225\231\354\212\265\354\236\220\353\243\214/11\354\243\274\354\260\250/[11\354\243\274\354\260\250-15\354\236\245]\352\271\200\354\247\200\355\233\204.md" create mode 100644 "\355\225\231\354\212\265\354\236\220\353\243\214/12\354\243\274\354\260\250/[12\354\243\274\354\260\250-16\354\236\245]\352\271\200\354\247\200\355\233\204.md" diff --git "a/\355\225\231\354\212\265\354\236\220\353\243\214/11\354\243\274\354\260\250/[11\354\243\274\354\260\250-15\354\236\245]\352\271\200\354\247\200\355\233\204.md" "b/\355\225\231\354\212\265\354\236\220\353\243\214/11\354\243\274\354\260\250/[11\354\243\274\354\260\250-15\354\236\245]\352\271\200\354\247\200\355\233\204.md" new file mode 100644 index 0000000..38c9d47 --- /dev/null +++ "b/\355\225\231\354\212\265\354\236\220\353\243\214/11\354\243\274\354\260\250/[11\354\243\274\354\260\250-15\354\236\245]\352\271\200\354\247\200\355\233\204.md" @@ -0,0 +1,586 @@ +# **1. 예외 처리** + +JPA 표준 예외들은 `javax.persistence.PersistenceException`의 자식 클래스다. 그리고 이 예외 클래스는 `RuntimeException`의 자식이다. 따라서 `JPA` 예외는 모두 언체크 예외다. + +JPA 표준 예외는 크게 2가지로 나눌 수 있다. + +- **트랜잭션 롤백을 표시하는 예외** +- **트랜잭션 롤백을 표시하지 않는 예외** + +트랜잭션 롤백을 표시하는 예외는 심각한 예외이므로 복구해서는 안된다. 이 예외가 발생한 경우 트랜잭션을 강제로 커밋해도 커밋되지 않고 RollbackException이 발생한다. + +### **트랜잭션 롤백을 표시하는 예** + +| 예 | 설 | +| -------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | +| javax.persistence.EntityExistException | EntityManager.persist(...) 호출 시 이미 같은 엔티티가 있으면 발생한다. | +| java.persistence.EntityNotFoundException | EntityManager.getReference(...)를 호출했는데 실제 사용 시 엔티티가 존재하지 않으면 발생. refersh(...), lock(...)에서도 발생한다. | +| javax.persistence.OptimisticLockException | 낙관적 락 충돌 시 발생한다. | +| javax.persistence.PessimisticLockException | 비관적 락 충돌 시 발생한다. | +| javax.persistence.RollbackException EntityTransaction.commit() | 실패 시 발생, 롤백이 표시되어 있는 트랜잭션 커밋 시에도 발생한다. | +| javax.persistence.TransactionRequiredException | 트랜잭션이 필요할 때 트랜잭션이 없으면 발생. 트랜잭션 없이 엔티티를 변경할 때 주로 발생한다. | + +### **트랜잭션 롤백을 표시하지 않는 예** + +| 예외 | 설명 | +| ------------------------------------------ | -------------------------------------------------------------- | +| javax.persistence.NoResultException | Query.getSingleResult() 호출 시 결과가 하나도 없을 때 발생한다 | +| javax.persistence.NonUniqueResultException | Query.getSingleResult() 호출 시 결과가 둘 이상일 때 발생한다. | +| javax.persistence.LockTimeoutException | 비관적 락에서 시간 초과 시 발생한다 | +| javax.persistence.QueryTimeoutException | 쿼리 실행 시간 초과 시 발생한다. | + +## **트랜잭션 롤백 시 주의사항** + +`트랜잭션`을 `롤백`하는 것은 데이터베이스의 반영 사항만 롤백 하는 것이지 수정한 `자바 객체`까지 원 상태로 복구해주지는 않는다. + +**예를 들어 엔티티를 조회해서 수정하는 중에 문제가 있어서 트랜잭션을 롤백하면 데이터베이스의 데이터는 원래대로 복구되지만 객체는 수정된 상태로 영속성 컨텍스트에 남아 있다.** + +따라서 새로운 `영속성 컨텍스트`를 생성해서 사용하거나 `EntityManager.clear()`를 호출해서 `영속성 컨텍스트`를 초기화한 다음에 사용해야 한다. + +**기본 전략인 트랜잭션당 영속성 컨텍스트 전략은 문제가 발생하면 트랜잭션 `AOP` 종료 시점에 트랜잭션을 롤백하면서 영속성 컨텍스트도 함께 종료하므로 문제가 발생하지 않는다.** + +문제는 `OSIV`처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 때 발생한다. + +스프링 프레임워크는 영속성 컨텍스트의 범위를 트랜잭션의 범위보다 넓게 설정하면 트래잭션 롤백시 영속성 컨텍스트를 초기화해서 잘못된 영속성 컨텍스트를 사용하는 문제를 예방한다. + +# **2. 엔티티 비교** + +영속성 컨텍스트를 통해 데이터를 저장하거나 조회하면 `1차 캐시`에 엔티티가 저장된다. 이 1차 캐시 덕분에 `변경 감지` 기능도 동작하고, 이름 그대로 `1차 캐시`로 사용 되어서 데이터베이스를 통하지 않고 데이터를 바로 `조회`할 수도 있다. + +``` +@RunWith(SpringJUnit4ClassRunner.class) +@ContextConfiguration(locations = "classpath:appConfig.xml") +@Transactional +public class MemberServiceTest { + + @Test + public void 회원가입() throws Exception { + // Given + Member member = new Member("kim"); + + // When + Long saveId = memberService.join(member); + + // Then + Member findMember = memberRepository.findOne(saveId); + assertTrue(member == findMember); // true 참조값 비교 + } +} +``` + +이것은 같은 트랜잭션 범위에 있으므로 같은 영속성 컨텍스트를 사용하기 때문이다. 따라서 영속성 컨텍스트가 같으면 엔티티를 비교할 때 다음 3가지 조건을 모두 만족한다. + +- 동일성: `==` 비교가 같다 +- 동등성 : `equals()` 비교가 같다 +- 데이터베이스 동등성 : `@Id`인 데이터베이스 식별자가 같다 + +https://incheol-jung.gitbook.io/~gitbook/image?url=https%3A%2F%2F2649832514-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fassets%252F-M5HOStxvx-Jr0fqZhyW%252F-MAK5k8zeTSu7qf8xf5z%252F-MAKAYE0YwURQjdOHOry%252F1.png%3Falt%3Dmedia%26token%3Df35b5624-96c6-4f66-acf3-88cfab7fabe6&width=768&dpr=4&quality=100&sign=e0a842e4dd97f5df30fbe1c0988a58e39a9d689646173851e48425bc55f7a41a + +정리하자면 `동일성 비교`는 같은 영속성 컨텍스트의 관리를 받는 영속 상태의 엔티티에만 적용할 수 있다. 그렇지 않을 때는 `비즈니스 키`를 사용한 `동등성 비교`를 해야 한다. + +# **3. 프록시 심화 주제** + +`프록시`는 원본 엔티티를 상속받아서 만들어지므로 `엔티티`를 사용하는 클라이언트는 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있다. 이로 인해 예상하지 못한 문제들이 발생하기도 하는데, 어떤 문제가 발생하고 어떻게 해결해야 하는지 알아보자 + +## **영속성 컨텍스트와 프록시** + +`엔티티`의 동등성을 비교하려면 비즈니스 키를 사용해서 `equals()` 메소드를 오버라이딩하고 비교하면 된다. 그런데 IDE나 외부 라이브러리를 사용해서 구현한 equals() 메소드로 엔티티를 비교할 때, 비교 대상이 원본 엔티티면 문제가 없지만 `프록시`면 문제가 발생할 수 있다. + +``` +@Entity +public class Member { + @Id + private String id; + private String name; + + ... + + @Override + public boolean equals(Object obj) { + if (this == obj) return true; + if (obj == null) return false; + if (this.getClass() != obj.getClass()) return false; // 1 + + Member member = (Member) obj; + + if (this.name != null ? !this.name.equals(member.name) : member.name != null) // + return false; + + return true; + } +} +``` + +테스트 코드를 작성해보자 + +``` +@Test +public void 프록시와_동등성비교() { + Member saveMember = new Member("member1", "회원1"); + em.persist(saveMember); + em.flush(); + em.clear(); + + Member newMember = new Member("member1", "회원1"); + Member refMember = em.getReference(Member.class, "member1"); + + Assert.assertTrue(newMember.equals(refMember)); // false +} +``` + +### **왜 이런 문제가 발생할까?** + +- 프록시는 원본을 상속받은 `자식 타입`이므로 프록시의 타입을 비교할 때는 `==` 비교가 아닌 `instanceof`를 사용해야 한다,. 따라서 다음처럼 변경해야 한다. + ``` + if (!(obj instanceof Member)) return false; + ``` +- `memner.name`을 보면 프록시의 멤버변수에 직접 접근하는데 이 부분을 주의깊게 봐야 한다. 프록시는 실제 데이터를 가지고 있지 않기 때문에 프록시의 `멤버 변수`에 직접 접근하면 아무값도 조회할 수 없다. 그러므로 프록시의 데이터를 조회할 때는 `접근자(Getter)`를 사용해야 한다. + +## **상속관계와 프록시** + +`상속 관계`를 프록시로 조회할 때 발생할 수 있는 문제점과 해결방안을 알아보자 + +https://incheol-jung.gitbook.io/~gitbook/image?url=https%3A%2F%2F2649832514-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fassets%252F-M5HOStxvx-Jr0fqZhyW%252F-MAK5k8zeTSu7qf8xf5z%252F-MAKAeIw9Gwb3DXYv31K%252F2.png%3Falt%3Dmedia%26token%3Da1b350ac-e252-42fa-8c63-041a09efc288&width=768&dpr=4&quality=100&sign=740b7f38e1e8f7291fb12f6615f33b35cb33c73f61a8e9c0201712e54118318a + +위와 같은 구조의 클래스 모델을 생성할 경우 프록시를 부모 타입으로 조회하면 문제가 발생한다. + +``` +@Test +public void 부모타입으로_프록시조회() { + //테스트 데이터 준비 + Book saveBook = new Book(); + saveBook.setName("jpaBook"); + saveBook.setAuthor("kim"); + em.persist(saveBook); + + em.flush(); + em.clear(); + + //테스트 시작 + Item proxyItem = em.getReference(Item.class, saveBook.getId()); + System.out.println("proxyItem = " + proxyItem.getClass()); + + if (proxyItem instanceof Book) { + System.out.println("proxyItem instanceof Book"); + Book book = (Book) proxyItem; + System.out.println("책 저자 = " + book.getAuthor()); + } + + //결과 검증 + Assert.assertFalse(proxyItem.getClass() == Book.class); + Assert.assertFalse(proxyItem instanceof Book); + Assert.assertTrue(proxyItem instanceof Item); +} +``` + +그런데 출력 결과를 보면 기대와는 다르게 저자가 출력되지 않은 것을 알 수 있다. + +https://incheol-jung.gitbook.io/~gitbook/image?url=https%3A%2F%2F2649832514-files.gitbook.io%2F%7E%2Ffiles%2Fv0%2Fb%2Fgitbook-legacy-files%2Fo%2Fassets%252F-M5HOStxvx-Jr0fqZhyW%252F-MAK5k8zeTSu7qf8xf5z%252F-MAKAfuCbYCpkBkThqP6%252F3.png%3Falt%3Dmedia%26token%3D0e6944c3-96a1-4360-af9d-f4685819c5f5&width=768&dpr=4&quality=100&sign=95d7ea3d214af5800cffdd8e2c5b278bdaf147a9d320fa6a343049c9e2c09c05 + +### **왜 원하는 출력값이 다를까?** + +실제 조회된 엔티티는 `Book`이므로 `Book` 타입을 기반으로 원본 엔티티 인스턴스가 생성된다. 그런데 `em.getReference()` 메소드에서 `Item` 엔티티를 대상으로 조회 했으므로 프록시인 `proxyItem`은 `Item` 타입을 기반으로 만들어진다. 이런 이유로 다음 연산이 기대와 다르게 `false`를 반환한다. 왜냐하면 `proxyItem`은 `Item&Proxy` 타입이고 이 타입은 `Book` 타입과 관계가 없기 때문이다. + +``` +proxyItem instanceof Book // false +``` + +따라서 직접 다운캐스팅을 해도 문제가 발생한다. + +``` +Book book = (Book) proxyItem; // java.lang.ClassCastException +``` + +### **그렇다면 상속관계에서 발생하는 프록시 문제를 어떻게 해결해야 할까?** + +- JPQL로 대상 직접 조회 + ``` + Book jpqlBook = em.createQuery + ("select b from Book b where b.id=:bookId", Book.class) + .setParameter("bookId", item.getId()) + .getSingleResult(); + ``` +- 프록시 벗기기 + ``` + ... + Item item = orderItem.getItem(); + Item unProxyItem = unProxy(item); + + if (unProxyItem instanceof Book) { + System.out.println("proxyItem instanceod Book"); + Book book = (Book) unproxyItem; + System.out.println("책 저자 = " + book.getAuthor()); + } + + Assert.assertTrue(item != unProxyItem); + } + + //하이버네이트가 제공하는 프록시에서 원본 엔티티를 찾는 기능을 사용하는 메소드 + public static T unProxy(Object entity){ + if(entity instanceof HibernateProxy) { + entity = ((HibernateProxy) entity) + .getHibernateLazyInitializer() + .getImplementation(); + } + return (T) entity; + } + ``` + 그런데 이 방법은 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 `동일성 비교`가 실패한다는 문제점이 있다. + ``` + item == unProxyItem // false + ``` +- 기능을 위한 별도의 인터페이스 제공 + ``` + public interface TitleView { + String getTitle(); + } + + @Inheritance(strategy = InheritanceType.SINGLE_TABLE) + @DiscriminatorColumn(name = "DTYPE") + public abstract class Item implements TitleView { + @Id @GeneratedValue + @Column(name = "ITEM_ID") + private Long id; + + ... + } + ``` +- 비지터 패턴 + ``` + public interface Visitor { + void visit(Book book); + void visit(Album album); + void visit(Movie movie); + } + ``` + ``` + public class PrintVisitor implements Visitor { + @Override + public void visit(Book book) { + //넘어오는 book은 Proxy가 아닌 원본 엔티티다. + System.out.println("book.class = " + book.getClass()); + } + + @Override + void visit(Album album) {...} + @Override + void visit(Movie movie) {...} + ``` + ``` + @Entity + @Inheritance(strategy = InheritanceType.SINGLE_TABLE) + @DiscriminatorColumn(name = "DTYPE") + public abstract class Item { + + @Id @GeneratedValue + @Column(name = "ITEM_ID") + private Long id; + + ... + + public abstract void accept(Visitor visitor); + } + + @Entity + @DiscriminatorValue("B") + public class Book extends Item { + + ... + @Override + public void accept(Visitor visitor){ + visitor.visit(this); + } + } + ``` + ``` + @Test + public void 상속관계와_프록시_VisitorPattern() { + ... + OrderItem orderItem = em.find(OrderItem.class, orderItemId); + Item item = orderItem.getItem(); + + //PrintVisitor + item.accept(new PrintVisitor()); + } + ``` + ``` + public class PrintVisitor implements Visitor { + public void visit(Book book) { + //넘어오는 book은 Proxy가 아닌 원본 엔티티다. + System.out.println("book.class = " + book.getClass()); + + } + public void visit(Album album) {...} + public void visit(Movie movie) {...} + } + ``` + **비지터 패턴의 장점은 다음과 같다.** + - 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있다. + - `instanceof`와 타입 캐스팅 없이 코드를 구현할 수 있다. + - 알고리즘과 객체 주고를 분리해서 구조를 수정하지 않고 `새로운 동작`을 추가할 수 있다. + **단점은 다음과 같다.** + - 너무 복잡하고 `더블 디스패치`를 사용하기 때문에 이해하기 어렵다. + - 객체 구조가 변경되면 모든 Visitor를 `수정`해야 한다. + +# **4. 성능 최적화** + +JPA로 애플리케이션을 개발할 때 발생하는 다양한 성능 문제와 해결 방안을 알아보자. + +## **N+1 문제** + +JPA로 애플리케이션을 개발할 때 성능상 가장 주의해야하는 문제 + +``` +@Entity +public class Member { + + @Id @GeneratedValue + private Long id; + + @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) + private List orders = new ArrayList(); + + ... +} +``` + +``` +@Entity +public class Order { + @Id @GeneratedValue + private Long id; + + @ManyToOne + private Member member; + ... +} +``` + +1:N, N:1 양방향 연관관계다. 회원이 참조하는 주문정보인 Member.orders를 즉시 로딩으로 설정했다. + +### **즉시 로딩과 N+1** + +`em.find()` 메소드로 조회하면 즉시 로딩으로 설정한 주문정보도 함께 조회한다. + +``` +em.find(Member.class, id); + +// 결과값 +SELECT M.*, O.* +FROM + MEMBER M +OUTER JOIN ORDERS O ON M.ID = O.MEMNBER_ID +``` + +문제는 `JPQL`을 사용할 때 발생한다. + +``` +List members = + em.createQuery("select m from Member m", Member.class) + .getResultList(); + +// 결과 SQL 로그 +SELECT * FROM MEMNER +SELECT * ORDERS WHERE MEMBER_ID =1 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =2 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =3 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =4 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =5 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =6 // 회원과 연관된 주문 +``` + +위에서 보는 것처럼 SQL을 추가로 실행하게 된다. + +### **지연 로딩과 N+1** + +``` +for (Member member : members) { + //지연 로딩 초기화 + System.out.println("member = " + member.getOrders().size(); +} + +// 결과 SQL 로그 +SELECT * ORDERS WHERE MEMBER_ID =1 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =2 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =3 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =4 // 회원과 연관된 주문 +SELECT * ORDERS WHERE MEMBER_ID =5 // 회원과 연관된 주문 +``` + +지연로딩으로 변경해도 N+1 문제에서 자유로울 수는 없다. 모든 회원에 대해 연관된 주문 컬렉션을 사용할 때 문제가 발생한다. + +### **N+1 문제 해결 방법** + +### **페치 조인 사용** + +페치 조인은 SQL 조인을 사용해서 연관된 엔티티를 함께 조회하므로 `N+1` 문제가 발생하지 않는다. + +``` +select m from Member m join fetch m.orders + +// 결과 SQL 로그 +SELECT M.*, O.* FROM MEMBER M +INNER JOIN ORDERS O ON M.ID=O.MEMBER_ID +``` + +> 참고로 이 예제는 일대다 조인을 했으므로 결과가 늘어나서 중복된 결과가 나타날 수 있다. 따라서 JPQL의 DISTINCT를 사용해서 중복을 제거하는 것이 좋다. + +### **하이버네이트 @BatchSize** + +`BatchSize` 어노테이션을 사용하면 연관된 엔티티를 조회할 때 지정한 size만큼 SQL의 IN절을 사용해서 조회한다. 만약 조회한 회원이 10명인데 `size=5`로 지정하면 2번의 `SQL`만 추가로 실행한다. + +``` +@Entity +public class Member { + + @Id @GeneratedValue + private Long id; + + @BatchSize(size = 5) + @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) + private List orders = new ArrayList(); + + ... +} +``` + +### **하이버네이트 @Fetch(FetchMode.SUBSELECT)** + +`FetchMode`를 `SUBSELECT`로 사용하면 연관된 데이터를 조회할 때 서브 쿼리를 사용해서 `N+1` 문제를 해결할 수 있다. + +``` +@Entity +public class Member { + + @Id @GeneratedValue + private Long id; + + @Fetch(FetchMode.SUBSELECT) + @OneToMany(mappedBy = "member", fetch = FetchType.EAGER) + private List orders = new ArrayList(); + + ... +} +``` + +``` +select m from Member m where m.id > 10 + +// 결과 SQL 로그 +SELECT O FROM ORDERS O + WHERE O.MEMBER_ID IN ( + SELECT + M.ID + FROM + MEMBER M + WHERE M.ID > 10 + } +``` + +### **읽기 전용 쿼리의 성능 최적화** + +엔티티가 영속성 컨텍스트에 관리되면 `1차 캐시`부터 `변경 감지` 까지 얻을 수 있는 혜택이 많다. 하지만 영속성 컨텍스트는 `변경 감지`를 위해 `스냅샷` 인스턴스를 보관하므로 더 많은 메모리를 사용하는 단점이 있다. 이때는 읽기 전용으로 엔티티를 조회하면 `메모리 사용량`을 `최적화`할 수 있다. + +``` +@Transactional(readOnly = true) +``` + +위와 같이 `읽기 전용`으로 설정하면 `트랜잭션`을 `커밋`해도 영속성 컨텍스트를 `플러시`하지 않는다. 그러므로 플러시할 때 일어나는 `스냅샷` 비교와 같은 무거운 로직들을 수행하지 않으므로 성능이 향상된다. + +> 엔티티 매니저의 플러시 설정에는 AUTO, COMMIT 모드만 있고, MANUAL 모드가 없다. 반면에 하이버네이트 세션의 플러시 설정에는 MANUAL 모드가 있다. MANUAL 모드는 강제로 플러시를 호풀하지 않으면 절대 플러시가 발생하지 않는다. + +## **배치 처리** + +수백만 건의 데이터를 `배치 처리`해야 하는 상황이라 가정해보자. 일반적인 방식으로 엔티티를 계속 조회하면 `영속성 컨텍스트`에 아주 많은 엔티티가 쌓이면서 `메모리 부족 오류`가 발생한다. 따라서 이런 배치 처리는 적절한 단위로 영속성 컨텍스트를 초기화해야 한다. 배치 처리는 아주 많은 데이터를 조회해서 수정한다. 이때 수많은 데이터를 한번에 메모리에 올려둘 수 없어서 2가지 방법을 주로 사용한다. + +- 페이징 처리 +- 커서 + +### **JPA 페이징 배치 처리** + +``` +EntityManager em = entityManagerFactory.createEntityManager(); +EntityTransaction tx = em.getTransaction(); + +tx.begin(); + +int pageSize = 100; +for(int i =0; i<10; i++){ + List resultList = em.createQuery("select p from Product p", Product.class) + .setFirstResult(i * pageSize) + .setMaxResults(pageSize) + .getResultList(); + // 비즈니스 로직 실행 + for( Product product : resultList) { + product.setPrice(product.getPrice() + 100); + } + + em.flush(); + em.clear(); +} + +tx.commit(); +em.close(); +``` + +이는 한번에 100건씩 페이징 쿼리로 조회하면서 상품의 가격을 100원씩 증가한다. 그리고 페이지 단위마다 영속성 컨텍스트를 플러시하고 초기화한다. + +### **하이버네이트 scroll 사용** + +하이버네이트는 scroll이라는 이름으로 JDBC 커서를 지원한다. + +``` +EntityTransaction tx = em.getTransaction(); +Session session = em.unwrap(Session.class); + +tx.begin(); +ScrollableResults scroll = session.createQuery("select p from Product p") + .setCacheMode.IGNORE) // 2차 캐시 기능을 끈다 + .scroll(ScrollMode.FORWARD_ONLY); + +int count = 0; + +while (scroll.next()) { + Product p = (Product) scroll.get(0); + p.setPrice(p.getPrice() + 100); + + count++; + if(count % 100 ==0) { + session.flush(); // 플러시 + session.clear(); // 영속성 컨텍스트 초기화 + } +} + +tx.commit(); +session.close(); + +``` + +scroll은 하이버네이트 전용 기능이므로 먼저 em.unwrap() 메소드를 사용해서 하이버네이트 세션을 구한다. 다음으로 쿼리를 조회하면서 scroll() 메소드로 ScrollableResults 객체를 반환받는다. 이 객체의 next() 메소드를 호출하면 엔티티를 하나씩 조회할 수 있다. + +### **SQL 쿼리 힌트 사용** + +SQL 힌트를 사용하려면 하이버네이트를 직접 사용해야 한다. SQL 힌트는 하이버네이트 쿼리가 제공하는 addQueryHint() 메소드를 사용한다. + +``` +Session session = em.unwrap(Session.class); // 하이버네이트 직접 사용 +List list = session.createQuery("select m from Member m") + .addQueryHint("FULL (MEMBER)") + .list(); + +// 실행 결과 +select + /*+ FULL (MEMBER) */ m.id, m.name +from + Member m +``` + +# **정리** + +- JPA의 예외는 트랜잭션 롤백을 표시하는 예외와 표시하지 않는 예외로 나눈다. 트랜잭션을 롤백하는 예외는 심각한 예외이므로 트랜잭션을 강제로 커밋해도 커밋되지 않고 롤백된다. +- 스프링 프레임워크는 JPA의 예외를 스프링 프레임워크가 추상화한 예외로 변환해준다. +- 프록시를 사용하는 클라이언트는 조회한 엔티티가 프록시인지 아니면 원본 엔티티인지 구분하지 않고 사용할 수 있어야 한다. 하지만 프록시는 기술적인 한계가 있으므로 한계점을 인식하고 사용해야 한다. +- JPA를 사용할 때는 N+1 문제를 가장 조심해야 한다. N+1 문제는 주로 페치 조인을 사용해서 해결한다. +- 엔티티를 읽기 전용으로 조회하면 스냅샷을 유지할 필요가 없고 영속성 컨텍스트도 초기화해야 한다. +- JPA는 SQL 쿼리 힌트를 지원하지 않지만 하이버네이트 구현체를 사용하면 SQL 쿼리 힌트를 사용할 수 있다. +- 트랜잭션을 지원하는 쓰기 지연 덕분에 SQL 배치 기능을 사용할 수 있다. diff --git "a/\355\225\231\354\212\265\354\236\220\353\243\214/12\354\243\274\354\260\250/[12\354\243\274\354\260\250-16\354\236\245]\352\271\200\354\247\200\355\233\204.md" "b/\355\225\231\354\212\265\354\236\220\353\243\214/12\354\243\274\354\260\250/[12\354\243\274\354\260\250-16\354\236\245]\352\271\200\354\247\200\355\233\204.md" new file mode 100644 index 0000000..bdaa4ac --- /dev/null +++ "b/\355\225\231\354\212\265\354\236\220\353\243\214/12\354\243\274\354\260\250/[12\354\243\274\354\260\250-16\354\236\245]\352\271\200\354\247\200\355\233\204.md" @@ -0,0 +1,121 @@ +이 장에서는 다음의 내용을 다룬다. + +- 트랜잭션과 락 : JPA가 제공하는 트랜잭션과 락 기능 +- 2차 캐시 : JPA가 제공하는 애플리케이션 범위의 캐시 + +# 16.1 트랜잭션과 락 + +## 16.1.1 트랜잭션과 격리 수준 + +트랜잭션은 ACID를 보장해야한다. + +- 원자성(Atomicity) : 트랜잭션 내 작업들이 마치 하나의 작업인 것 처럼 모두 성공 or 실패해야 한다. +- 일관성(Consistency) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. +- 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. +- 지속성(Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. + +트랜잭션 간에 완벽한 격리성을 보장하려면 트랜잭션을 차례대로 실행해야 한다. 이렇게 하면 동시성 처리 성능이 매우 나빠진다. + +이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다. + +- READ UNCOMMITED + - DIRTY READ : 다른 트랜잭션이 수정 중일 때 해당 데이터를 조회할 수 있다. +- READ COMMITED : 다른 사용자가 데이터를 변경하는 동안 사용자는 데이터를 읽을 수 없다. + - NON-REPEATABLE READ : 선행 트랜잭션이 조회하는 중에 후행 트랜잭션이 수정하고 커밋하면 선행 트랜잭션이 다시 조회했을 때 수정된 데이터가 조회된다. +- REPEATABLE READ : 선행 트랜잭션이 조회한 데이터는 트랜잭션이 종료될때까지 후행 트랜잭션은 수정,삭제가 불가능하다. + - PHANTOM READ : 반복 조회 시 결과 집합이 달라진다. +- SERIALIZABLE : 동일한 데이터에 대해서 두 개의 트랜잭션이 수행될 수 없다. + +애플리케이션은 동시성 처리가 중요하므로 보통 READ COMMITED를 기본으로 사용한다. + +## 16.1.2 낙관적 락과 비관적 락 + +JPA의 영속성 컨텍스트를 적절히 활용하면 데이터베이스의 격리수준이 READ COMMITED어도 애플리케이션 레벨에서 반복 가능한 읽기가 가능하다. + +일부 로직에서 더 높은 격리 수준이 필요하면 낙관적 락과 비관적 락 중 하나를 사용해야 한다. + +- **낙관적 락** + - 애플리케이션, JPA가 제공하는 버전 관리 기능을 사용한다. 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다. +- **비관적 락** + - 트랜잭션의 충돌이 발생한다고 우선 락을 걸고 보는 방법이다. 데이터베이스에서 제공하는 락 기능을 사용한다. + +데이터베이스 트랜잭션 범위를 넘어서는 문제인 **second lost updates problem (두 번의 갱신 분실 문제)**가 발생할 수 있다. 예를 들면, 사용자 A,B가 동시에 이름을 수정하는 중에 사용자 A가 먼저 수정완료 버튼을 누르고, 후에 사용자 B가 버튼을 누른다면 A의 수정사항은 사라지고 B의 수정사항만 남게 된다. + +- 데이터베이스 트랜잭션 범위를 넘어서는 문제 해결하는 방법 + - **마지막 커밋만 인정하기** : 마지막에 커밋한 사용자 B의 내용만 인정 + - **최초 커밋만 인정하기** : 먼저 수정한 사용자 A의 내용이 수정되고 B는 오류가 발생한다. + - **충돌하는 갱신 내용 병합하기** : A,B의 내용을 병합 + +## 16.1.3 @Version + +낙관적 락을 사용하려면 @Version 어노텡션을 사용해서 버전관리 기능을 추가해야 한다. + +수정 시점의 버전이 다르면 예외가 발생한다. + +--- + +# 16.2 2차 캐시 + +## 16.2.1 1차 캐시와 2차 캐시 + +네트워크를 통해 데이터베이스에 접근하는 방식은 서버에서 내부 메모리를 접근하는 것보다 시간 비용이 수만에서 수십만 배 이상 높다. + +따라서 데이터베이스를 조회해서 내부 메모리 캐시에 저장해두면 비용을 아낄 수 있다. + +### 1차 캐시 + +- 1차 캐시는 영속성 컨텍스트 내부에 있다. 엔티티 매니저로 조회하거나 변경하는 모든 엔티티는 1차 캐시에 저장된다. 영속성 컨텍스트 자체가 사실상 1차 캐시이다. 1차 캐시는 켜고 끌 수 있는 옵션이 아니다. +- 1차 캐시는 같은 엔티티가 있으면 해당 엔티티를 그대로 반환한다. 따라서 1차 캐시는 개체 동일성(a==b)을 보장한다. +- 1차 캐시는 기본적으로 영속성 컨텍스트 범위의 캐시다(컨테이너 환경에서는 트랜잭션 범위의 캐시, OSIV를 적용하면 요청 범위의 캐시다). + +### 2차 캐시 + +!https://velog.velcdn.com/images/offsujin/post/2ba6e893-7b88-4f53-8957-64becacd1e30/image.png + +- 애플리케이션에서 공유하는 캐시를 JPA는 공유 캐시(shared caches)라 하는데 일반적으로 2차 캐시(second level cache, L2 cache)라 부른다. 2차 캐시는 애플리케이션 범위의 캐시로 애플리케이션을 종료할 때까지 캐시가 유지된다. +- 2차 캐시는 동시성을 극대화하려고 캐시한 객체를 직접 반환하지 않고 복사본을 만들어서 반환한다. +- 2차 캐시는 데이터베이스 기본 키를 기준으로 캐시하지만 영속성 컨텍스트가 다르면 객체 동일성(a==b)를 보장하지 않는다. + +## 16.2.2 JPA 2차 캐시 기능 + +엔티티 클래스에 @Cacheable 어노테이션을 사용한다. + +캐시 모드는 javax.persistence.SharedCacheMode에 정의되어 있다. + +캐시 모드에 따라 캐시를 무시할수도 있고 더 세밀하게 캐시 사용을 설정할 수 있다. + +JPA는 캐시를 관리하기 위한 javax.persistence.Cache 인터페이스를 제공한다. + +## 16.2.3 하이버네이트와 EHCACHE 적용 + +하이버네이트와 EHCACHE를 사용해서 2차 캐시를 적용해보자. + +하이버네이트가 지원하는 캐시는 크게 3가지가 있다. + +- 엔티티 캐시 : 엔티티 단위로 캐시한다. +- 컬렉션 캐시 : 엔티티와 연관된 컬렉션을 캐시한다. +- 쿼리 캐시 : 쿼리와 파라미터 정보를 키로 사용해서 캐시한다. + +.. 환경설정과 @Cache의 속성 등은 생략하도록 하겠다.. 필요시 찾아보도록 + +### 캐시 영역 + +캐시를 하면 엔티티는 엔티티 캐시 영역, 연관 참조 엔티티는 컬렉션 캐시 영역에 저장된다. + +- 엔티티 캐시 영역: [패키지 명 + 클래스 명] +- 컬렉션 캐시 영역: [패키지 명 + 클래스 명 + 필드 명] + +### 쿼리 캐시 + +쿼리 캐시는 쿼리와 파라미터 정보를 키로 활용해서 쿼리 결과를 캐시하는 방법이다. + +org.hibernate.cacheable = true로 설정해야 한다. + +쿼리/컬렉션 캐시는 대상 엔티티 캐시와 함께 써야한다. 쿼리 캐시는 엔티티 식별자 값만 들어있기 때문에 실제 엔티티 정보는 엔티티 캐시를 통해 하나씩 조회해오기 때문이다. + +쿼리캐시와 컬렉션 캐시 사용시 결과 집합의 식별자 값만 캐시한다는 것을 알고 이를 주의하도록 하자.!? → 결과 대상 엔티티에는 꼭 엔티티 캐시를 적용해야 한다..!? + +# 16.3 정리 + +- JPA는 낙관적 락과 비관적 락을 지원한다. 낙관적 락은 애플리케이션이 지원하는 락이고, 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존한다. +- 2차 캐시를 사용하면 애플리케이션의 조회 성능을 극적으로 끌어올릴 수 있다.