본문 바로가기

IT/Spring JPA

JPA 예외처리 및 프록시 심화 익히기

반응형

JPA 표준 예외 정리
1) 트랜잭션 롤백을 표시하는 예외
- 심각한 예외로 트랜잭션을 강제로 커밋해도 RollbackException 에러 발생
2) 트랜잭션 롤백을 표시하지 않는 예외
- 심각한 예외가 아니므로 개발자가 트랜잭션을 커밋할지 롤백할지 결정할 수 있음

JPA 표준 예외 정리
1) 트랜잭션 롤백을 표시하는 예외
- 심각한 예외로 트랜잭션을 강제로 커밋해도 RollbackException 에러 발생
2) 트랜잭션 롤백을 표시하지 않는 예외
- 심각한 예외가 아니므로 개발자가 트랜잭션을 커밋할지 롤백할지 결정할 수 있음


스프링 프레임워크의 JPA 예외 변환
서비스 계층에서 데이터 접근 계층의 구현 기술에 직접 의존하는 것은 좋은 설계가 아니듯이 예외도 같다.
서비스 계층에서 JPA 예외를 직접 사용할 경우 JPA에 의존하게 됨으로 권장하지 않는다

그러므로 JPA 예외 대신 스프링프레임워크에서 제공하는 추상화된 스프링 예외로 변환해서 사용하는 걸 권장한다.



스프링 프레임워크에 JPA 예외 변환기 적용
1) PersistenceExceptionTranslationPostProccesor를 스프링 빈으로 등록

@Bean 
public PersistenceExceptionTranslationPostProcessor exceptionTranslation(){ 
	return new PersistenceExceptionTranslationPostProcessor(); 
}

2) @Repository 어노테이션을 사용

@Repository하면 예외 변환 AOP를 적용하면 자동으로 JPA 예외가 스프링 프레임워크가 추상화한 예외로 변환된다.

만약 예외를 변환하지 않고 JPA 예외를 그대로 사용하고 싶으면 throws 절을 통해 반환하면 된다.

public Member findMember() throws javax.persistence.NoResultException{ 
	return e.createQuery("select m from member m", Member.class).getSingleResult(); 
}


트랜잭션 롤백 시 주의사항
트랜잭션을 롤백하는 것은 데이터베이스의 반영사항만 롤백하는 것이지 수정한 자바 객체까지 원상태로 복구하는 것이 아니다.
그러므로 트랜잭션 롤백이 발생하면 반드시 EntityManager.clear()로 초기화하거나 영속성 컨텍스트를 새로 생성해야 한다.

* OSIV처럼 영속성 컨텍스트의 범위를 트랜잭션 범위보다 넓게 사용해서 여러 트랜잭션이 하나의 영속성 컨텍스트를 사용할 경우
트랜잭션 롤백 시 반드시 영속성 컨텍스트 초기화가 필요하다.

엔티티비교
영속성 컨텍스트 내부에는 엔티티 인스턴스를 보관하기 위한 1차 캐시가 존재한다. 1차 캐시를 통해 변경 감지 기능, 빠른 데이터 조회가 가능하다.

같은 영속성 컨텍스트 안에 있는 엔티티를 비교할 경우


1) 동일성 (==) 비교가 같다.
2) 동등성 (equals) 비교가 같다.
3) 데이터베이스 동등성 (@id) 데이터베이스 식별자가 같다.

다른 영속성 컨텍스트 안에 있는 엔티티를 비교할 경우


1) 동일성 (==) 비교가 다르다.
2) 동등성 (equals) 비교가 같다 (단, equals()를 비즈니스 키로 비교하도록 구현해야 됨)
3) 데이터베이스 동등성 (@id) 데이터베이스 식별자가 같다.

같은 영속성 컨텍스트 안에 있는 경우는 동일성 (==)을 통해서 엔티티 비교를 하면 된다.
하지만 다른 영속성 컨텍스트 안에 있는 경우 동등성 또는 데이터베이스 동등성을 사용해야 되는데 데이터베이스 동등성을 영속화해야 식별자 @id 값을 가질 수 있기 때문에 사용하는데 제한적이다.

즉, 다른 영속성 컨테스트인 경우 동등성 equals를 사용해야 된다.
동등성 비교를 위해 equals()를 오버라이딩 할 때 비즈니스 키가 되는 필드를 선택해야 된다. 비즈니스 키란 중복되지 않고 해당 엔티티의 유일한 정보를 의미하는 값이다. 예를 들어 주민번호를 사용할 수도 있고 이름 + 연락처처럼 여러 데이터를 조합해서 정의할 수도 있다.


프록시 심화 주제
프록시는 원본 엔티티를 상속받아서 만들어지므로 엔티티를 사용하는 클라이언트는 엔티티가 프록시인지 원본 엔티티인지 구분하지 않고 사용할 수 있다.

Case 1

Member refMember = em.getReference(Member.class, "member1");
Member findMember = em.find(Member.class, "member1"); 

=> refMember type = class jpabook.advanced.Member_$$_jvst843_0 
=> findMember type = class jpabook.advanced.Member_$$_jvst843_0

refMember == findMember 결과 : true

Case 2

Member findMember = em.find(Member.class, "member1"); 
Member refMember = em.getReference(Member.class, "member1"); 

=> refMember type = class jpabook.advanced.Member 
=> findMember type = class jpabook.advanced.Member

refMember == findMember 결과 : true

영속성 컨텍스트는 영속 엔티티가 원본 엔티티이든 프록시 엔티티이든 무조건 동일성을 보장한다. (==)

프록시는 원본 엔티티의 자식 클래스임으로 refMember.class == Member.class를 비교하면 false지만, refMember (프록시) instanceof Member를 비교하면 true를 반환한다.

이러한 프록시와 원본 엔티티의 차이 때문에 일반 프레임워크나 IDE에서 자동으로 제공하는 equals를 사용할 경우 정확한 객체 비교가 어렵다.

@Entity 
public class Member { 
  @id 
  private String id; 
  private String name; 

... 

	public String getName() { 
		return name; 
	} 

	public void setName(String name) { 
		this.name = name; 
	} 

  @Override 
  public boolean equals(Object obj) { 

  	if (this == obj) return true; 

  	if (obj == null) return false; 
  	if (this.getClass() != obj.getClass()) return false; 

  	Member member = (Member) obj; 

  	if(name != null ? !name.equals(member.name) : member.name != null) { 
  		return false; 
  	} 

  	return true; 
  
  } 
} 

---- 

@Test public void test(){ 
  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)); 

}

이 로직에서 오류가 2개 존재한다.

1) if (this.getClass() != obj.getClass())
equals 메소드의 파라미터로 받는 obj는 프록시 객체이다. 프록시 클래스는 엔티티의 자식 클래스임으로 동일성 == 이 true 가 나올 수 없다.

그러므로 instanceof 로 변경해야 된다.

2) if(name != null ? !name.equals(member.name) : member.name != null)
프록시 객체는 실제 데이터를 가지고 있지 않음으로 직접 접근하면 아무런 값도 조회할 수 없다.

그러므로 .name 대신 .getName()으로 변경해야 된다.

[변경한 로직]

@Override public boolean equals(Object obj) { 
	if (this == obj) return true; 
    if (obj == null) return false; 
    
    if (obj instanceof Member) return false; 
    
    Member member = (Member) obj; 
    
    if(name != null ? !name.equals(member.getName()) : member.getName() != null) { 
    	return false; 
    } 
    
    return true; 
}


상속관계와 프록시
상속관계를 프록시로 조회할 때 발생하는 문제점과 해결 방안에 대해 설명하고자 한다.

프록시를 부모타입으로 조회할 경우 문제가 발생한다.
1) instanceof 연산을 사용할 수 없음
2) 하위 타입으로 다운 캐스팅을 할 수 없음

@Entity 
public class OrderItem {

@Id @GeneratedValue 
private Long id; 

@ManyToOne(fetch = FetchType.LAZY)
@JoinColum(name = "ITEM_ID") 
privat Item item; 

  public Item getItem(){ 
  	return item; 
  } 

  public void setItem(Item item){ 
  	this.item = item; 
  } 
} 

---- 

@Test 
public void test(){ 
  Book book = new Book(); 
  book.setName("jpaBook"); 
  book.setAuthor("kim"); 
  em.persist(book); 
  
  OrderItem saveOrderItem = new OrderItem(); 
  saveOrderItem.setItem(book); 
  
  em.persist; 
  em.flush(); 
  em.clear();

  //테스트 시작 
  OrderItem orderItem = em.find(OrderItem.class, saveOrderItem.getId()); 
  Item item = orderItem.getItem(); 
  System.out.println("item="+item.class()); 

  //결과 검증 
  Assert.assertFalse(item.getclass() == Book.class); 
  Assert.assertFalse(item instanceof Book); 
  Assert.assertTrue(item instanceof Item); 
}

위 문제의 해결 방법으론 4가지가 있다.
1) JPQL로 대상 직접 조회
2) 프록시 벗기기
3) 기능을 위한 별도의 인터페이스 제공
4) 비지터 패턴 사용

JPQL로 대상 직접 조회
자식 타입을 직접 조회해서 필요한 연산을 적용하는 방법
단점 : 다형성을 사용할 수 없다.

Book jpaBook = em.createQuery("select b from Book b where b.id = :bookId", Book.class) 
    	.setParameter("bookId", item.getId()) 
            .getSingleResult();


프록시 벗기기
unproxy()를 통해 프록시에서 원본 엔티티를 가져올 수 있다.
단점 : 프록시에서 원본 엔티티를 직접 꺼내기 때문에 프록시와 원본 엔티티의 동일성 비교가 실패한다는 문제점이 있다.
ex) item == unproxy(item) => false
단점 때문에 잠깐 사용하고 다른 곳에서는 unproxy된 item을 사용해서는 안된다.

기능을 위한 별도의 인터페이스 제공

public interface TitleView {
	String getTitle(); 
} 

@Entity 
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) 
@DiscriminatorColumn(name = "DTYPE") 
public abstract class Item implements TitleView { 
	@Id @GeneratedValue @Column(name ="ITEM_ID") 
    private Long ig; 
    private String name; 
    private int price; 
    private int stockQuantity; 
    // Getter, Sstter 
} 

@Entity @DiscriminatorValue("B") 
public class Book extends Item { 
	private String author; private String isbn; 
    // Getter, Setter 
    
    @Override public String getTitle() { 
    	return "this is book"; 
    } 
} 

@Entity @DiscriminatorValue("M") 
public class Movie extends Item { 
	private String director; 
    private String author; 
    // Getter, Setter 
    
    @Override public String getTitle() { 
    	return "this is Movie"; 
    } 
}

장점 : 클라이언트가 대상 객체가 프록시인지 원본 엔티티인지 구분할 필요가 없다.
단점 : 프록시 대상이 되는 타입에 인터페이스를 적용해야 된다.

비지터 패턴 사용

비지터 패턴은 Visitor와 Visitor를 받아들이는 대상 클래스로 구성된다.

item은 단순히 Visitor를 받아들이기만 하고 실제 로직은 Visitor가 처리한다.


Visitor Interface

public interface Visitor { 
    void visit(Book book); 
    void visit(Album album); 
    void visit(Movie movie); 
}

Visitor 구현

/**
 * 대상 클래스의 내용 출력
 */
public class PrintVisitor implements Visitor {
    @Override
    public void visit(Book book) {
        // 넘어오는 book은 Proxy가 아닌 원본 엔티티 
        System.out.println(book.getClass());
        System.out.println(book.getName() + " " + book.getAuthor());
    }

    @Override
    public void visit(Album album) { ...}

    @Override
    public void visit(Movie movie) { ...}
}

/**
 * 대상 클래스의 제목을 보관
 */
public class TitleVisitor implements Visitor {
    private String title;

    public String getTitle() {
        return title;
    }

    @Override
    public void visit(Book book) {
        title = book.getNAme() + " " + book.getAuthor();
    }

    @Override
    public void visit(Album album) { ...}

    @Override
    public void visit(Movie movie) { ...}
}

Visitor 대상 클래스
- Item에 Visitor를 받아들일 수 있도록 accept(visitor) 메소드 추가

@Entity
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "DTYPE")
public abstract class Item {
    @Id
    @GeneratedValue
    @Column(name = "ITEM_ID")
    private Long ig;
    private String name;
    private int price;
    private int stockQuantity;

    // Getter, Sstter 
    public abstract void accept(Visitor visitor);
}

@Entity
@DiscriminatorValue("B")
public class Book extends Item {
    private String author;
    private String isbn;

    // Getter, Setter 
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}

@Entity
@DiscriminatorValue("M")
public class Movie extends Item {
    private String director;
    private String author;

    // Getter, Setter 
    @Override
    public void accept(Visitor visitor) {
        visitor.visit(this);
    }
}


Visitor 사용
- item은 프록시이므로 먼저 프록시가 accept() 메소드를 받고 원본 엔티티의 accept()를 실행

- accept 메소드 내부에 visitor.visit(this) this는 프록시가 아닌 원본 엔티티임

@Test
public void VisitorPattern() {
    // ... 
    OrderItem orderitem = em.find(OrderItem.class, orderItemId);
    Item item = orderItem.getItem();
    item.accept(new PrintVisitor());
}

TitleVisitor 사용
- 비지터 패턴은 새로운 기능이 필요할 때 Visitor만 추가하면 됨

TitleVisitor titleVisitor = new TitleVisitor();

item.accept(titleVisitor);
String title = titleVisitor.getTitle();

장점
1) 프록시에 대한 걱정 없이 안전하게 원본 엔티티에 접근할 수 있음
2) instanceof나 타입 캐스팅 없이 코드를 구현할 수 있음
3) 알고리즘과 객체 구조를 분리해서 구조를 수정하지 않고 새로운 동작을 추가 할 수 있음

단점
1) 너무 복잡하고 더블 디스패치를 사용하기 때문에 이해하기 어려움
2) 객체 구조가 변경되는 모든 vistor를 수정해야 됨

반응형

'IT > Spring JPA' 카테고리의 다른 글

JPA 성능 최적화  (0) 2021.08.13
JPA 영속성 관리  (0) 2021.07.15
JPA 웹 어플리케이션 개발  (0) 2021.07.01
QueryDSL 사용법  (3) 2020.02.17
생성자 제한하기  (0) 2020.02.16