JPA 프록시에게 크게 데인 썰..과 트러블 슈팅

주리링·2022년 8월 1일
0
post-thumbnail

Level3 팀 프로젝트를 진행하던 도중 겪은 Jpa 프록시에게 크게 데인 썰과 트러블 슈팅을 공유합니다..^0^;;

도메인 소개

Member와 article class가 일대다 관계이며 외래키의 주인은 article이고 단방향 연결지연로딩으로 하고 있습니다.
클래스는 아래와 같습니다.

게시글을 나타내는 Article class

@Entity
public class Article {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String title;

    private String content;

    @Enumerated(value = EnumType.STRING)
    @Column(nullable = false)
    private Category category;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", nullable = false)
    private Member member;

    @CreatedDate
    @Column(updatable = false)
    private LocalDateTime createdAt;

    private int views;
    
    //생성자
    
    public boolean isAuthor(Member member) {
        return member.equals(this.member);
    }
    
    //로직
}

게시글의 작성자를 나타내는 Member class

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false)
    private String name;

    @Column(nullable = false)
    private String githubId;

    @Column(nullable = false)
    private String avatarUrl;
    
    //생성자 및 로직
    
    @Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {
            return false;
        }
        Member member = (Member) o;
        return Objects.equals(id, member.id);//식별자로 equals 재정의
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

문제 상황

로그인을_하고_게시물을_단건_조회할_수_있다 인수테스트의 흐름은 아래와 같습니다.

  1. 로그인을 하면 토큰이 나온다.
  2. 1에서 나온 토큰(내부에 member id 존재)을 이용하여 게시물을 등록한다.
  3. 1, 2에서 나온 토큰과 게시물 응답을 이용하여 게시물 단건 조회를 한다.(게시물 작성자가 게시물을 조회)

같은 게시물을 작성한 사람이 조회하는 흐름이므로 isAuthor부분이 true가 나오길 기대했는데 계속 false가 나왔습니다.

ArticleService 내의 게시물 단건 조회 로직은 아래와 같습니다.

public ArticleResponse findOne(AppMember appMember, Long articleId) {
	//게시물의 id로 게시물을 찾고
    Article article = articleRepository.findById(articleId)
            .orElseThrow(() ->newIllegalArgumentException("게시글이 존재하지 않습니다."));
    //조회수 증가
    article.addViews();
    //비회원인 경우 isAuthor == false
	if(appMember.isGuest()) {
		return newArticleResponse(article,false);
	}
    //회원인 경우 id로 회원을 찾고
	Member member = memberRepository.findById(appMember.getPayload())
		            .orElseThrow(() ->newIllegalArgumentException("회원이 존재하지 않습니다."));
		
    //작성자인지 확인    
    return newArticleResponse(article, article.isAuthor(member));
}

문제 상황 이해

디버그를 찍어봤더니 findById 메서드를 통해 찾은 member 인스턴스가 프록시 객체였고, 내부 값이 모두 null이였습니다.
또한 select쿼리가 나가지 않았습니다.

문제 상황에 대해 아래와 같은 가정을 해보았습니다.

지연로딩으로 설정한 article 내부에 있는 member를 찾아올 때 proxy로 찾아왔기 때문에 같은 id인 member를 찾을 때도 proxy객체를 찾아온다.

그렇다면 member를 먼저 조회하면 proxy객체가 아니여야 합니다.
그래서 ArticleService 내의 게시물 단건 조회 로직을 아래와 같이 변경한 후 실행해보았습니다.

public ArticleResponse findOne(AppMember appMember, Long id) {
	//id를 이용하여 Member를 먼저 찾고
    Member member = memberRepository.findById(appMember.getPayload())
            .orElseThrow(() ->newIllegalArgumentException("회원이 존재하지 않습니다."));
    //이 후에 Article 객체를 찾아온다. 
    //즉 Article의 지연 로딩으로 설정한 참조객체인 Member를 먼저 영속성 컨텍스트에 올린다.
    Article article = articleRepository.findById(id)
            .orElseThrow(() ->newIllegalArgumentException("게시글이 존재하지 않습니다."));
    article.addViews();
		if(appMember.isGuest()) {
		return newArticleResponse(article,false);
		    }
		
		return newArticleResponse(article, article.isAuthor(member));
}

디버깅 결과는 예상과 같이 proxy객체가 아니였습니다.

문제 원인

프록시 객체로 Id 비교 하는 equals 메서드

JPA에서는 하나의 트랜잭션 내에서 Repeatable Read가 가능합니다.
이를 통해 동일성 보장이 됩니다.(자세한 설명은 아래에서)
식별자인 Id로 equal를 재정의 해서 객체를 식별해야겠다고 생각했는데, JPA에서 equal 재정의는 항상 주의 해야한다는 것을 명심하게 되었습니다.

@Override
    public boolean equals(Object o) {
        if (this == o) {
            return true;
        }
        if (o == null || getClass() != o.getClass()) {//1
            return false;
        }
        Member member = (Member) o;
        return Objects.equals(id, member.id);//2
    }

equals 재정의에서 문제가 되는 부분

  1. 해당 객체가 프록시 객체이므로 Member를 상속받은 객체입니다.
    그러므로 getClass()가 아닌 instanceof를 이용하여 클래스 타입을 비교해야합니다.
  2. 프록시 객체이므로 내부 필드 값은 null입니다. 그러므로 Member로 클래스 변환을 한 객체의 필드 값을 바로 꺼내는 것이 아닌 getter를 사용해야합니다.

equals를 아래와 같이 수정하면 문제가 해결 됩니다.

@Override
    public boolean equals(Object o) {
        if (this == o) {
            return true
            
        }
        if (!(o instanceof Member)) {
            return false;
        }
        Member member = (Member) o;
        return Objects.equals(id, member.getId());
    }

JPA와 프록시

그렇다면 왜 Member객체를 id를 통해 조회를 했는데 프록시 객체가 나오는 것일까요?

이유는 지연 로딩영속성 컨텍스트의 특징에 있습니다.
현재 상황에 예시를 들어보면 Article의 참조 객체는 Member입니다.
fetchType를 지연 로딩으로 설정한 경우 Article을 find할 때 Member에 대한 정보는 FK인 member_id뿐입니다.

영속성 컨텍스트의 특징으로는 1차 캐시, 동일성 보장이 있습니다.
그렇다면 Article을 찾아올 때 Member 객체도 생성이 될텐데 참조 값이 바뀐다면 동일성이 보장되지 않습니다.
그래서 JPA에선 참조 객체를 proxy로 가져옵니다.

proxy객체는 참조 객체를 상속받아 참조 객체가 가진 모든 필드를 가지며, 내부에 참조 객체의 인스턴스인 target을 가집니다.
그래서 먼저 참조 객체가 proxy객체라면 동일성 보장을 위해 트랜잭션 내에서 계속 프록시로 사용되는 것 입니다.
이 후에 참조 객체를 DB에서 조회할 경우에는 null로 저장되어 있던 target인스턴스가 실제 객체로 초기화됩니다.

위의 ArticleService의 findOne로직을 예시로 들어보겠습니다.

public ArticleResponse findOne(AppMember appMember, Long articleId) {
    Article article = articleRepository.findById(articleId)//1
            .orElseThrow(() ->newIllegalArgumentException("게시글이 존재하지 않습니다."));
    article.addViews();
	if(appMember.isGuest()) {
		return newArticleResponse(article,false);
	}
	Member member = memberRepository.findById(appMember.getPayload())//2
		            .orElseThrow(() ->newIllegalArgumentException("회원이 존재하지 않습니다."));
		
    return newArticleResponse(article, article.isAuthor(member));
}
  1. 영속성 컨택스트에서 articleId를 이용하여 db에 select쿼리를 날립니다.
    이 때, findOne 메서드의 트랜잭션에는 article객체와 member proxy객체가 존재합니다.
    member proxy객체의 target이라는 인스턴스는 null입니다.
  2. member_id로 조회를 할 때 트랜잭션 내에 해당 id로 생성된 프록시 객체가 있으므로 select쿼리가 나가지 않습니다.

이 후에 member의 id이외의 다른 필드 값이 필요할 때 id를 이용한 select쿼리가 나갑니다.

profile
코딩하는 감자

0개의 댓글