JPA 프록시 관련 버그 경험기

JPA 프록시 관련 버그 경험기

2021, Sep 28    

INTRO

  • JPA 에서는 데이터베이스에서 연관객체 탐색을 효율적으로 하기 위해서 지연로딩 전략을 사용한다.
  • 지연로딩의 핵심은 연관관계에 있는 Entity가 실제로 사용되기 이전까지 DB에 실제로 참조하지 않고 프록시 객체로 대체하는 것이다.
  • JPA의 프록시 객체는 유용하지만 내부 동작방식에 대해서 제대로 알고있지 않으면 찾기 어려운 버그를 만날 수도 있다.
  • 다음은 JPA proxy 관련해서 프로젝트 진행시 만난 버그에 대한 내용이다.



문제 상황

Entity 구조

  • 참고: 설명과 관련된 부분만 남기고 다른 로직 및 어노테이션은 대부분 생략했다.
  1. Post - 게시물 엔티티

     @Entity
     public class Post {
    
         @Id
         @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         @ManyToOne(fetch = FetchType.LAZY)
         @JoinColumn(name = "user_id")
         private User user;
    
         @Embedded
         private Images images;
    
         @Embedded
         private PostContent content;
    
         @Embedded
         private Likes likes; //설명과 관련된 프로퍼티!! 
    
         @Embedded
         private Comments comments;
    
         @Embedded
         private PostTags postTags;
    
         private String githubRepoUrl;
    
         protected Post() {
         }
    
         //...부생성자 생략 
    
         //...불필요한 비지니스 로직 생략 
         //...getter 생략
     }
    


  2. LikesLike - Post 엔티티 하위의 Embedded 게시물 Like collection 포장객체
    • 참고: 설명하고자 하는 부분과 깊게 연관된 핵심 Entity는 아니지만 상황 설명을 위해 간단히 프로퍼티만 소개한다.
     @Embeddable // Post 엔티티 안에 Embedded 되어 있음 
     public class Likes {
    
         @OneToMany(
             mappedBy = "post",
             fetch = FetchType.LAZY,
             cascade = CascadeType.PERSIST,
             orphanRemoval = true
         )
         private List<Like> likes;
    
         public Likes() {
             this(new ArrayList<>());
         }
    
         public Likes(List<Like> likes) {
             this.likes = likes;
         }
    
         //...getter 및 불필요한 비지니스 로직 생략
     }
    
     @Entity
     public class Like {
    
         @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         @ManyToOne(fetch = FetchType.LAZY)
         @JoinColumn(name = "post_id")
         private Post post;
    
         @ManyToOne(fetch = FetchType.LAZY)
         @JoinColumn(name = "user_id")
         private User user;
    
         protected Like() {
         }
    
         //...getter 및 불필요한 비지니스 로직 생략
     }    
    


  3. User - 어플리케이션 사용자 (게시물 좋아요, 유저간 팔로우 팔로잉 등의 행위를 함)

     @Entity
     public class User {
    
         @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         @Embedded
         private Followers followers;
    
         @Embedded
         private Followings followings;
    
         //...일부 프로퍼티 생략 
    
         protected User() {
         }
    
         //...부 생성자 생략 
    
         public Boolean isFollowing(User targetUser) { //문제를 발생시킨 핵심 메소드!!! 
             if (this.equals(targetUser)) {
                 return null;
             }
    
             return this.followings.isFollowing(targetUser);
         }
    
         //...getter 및 불필요한 비지니스 로직 생략 
    
         @Override
         public boolean equals(Object o) { //User는 Entity 이므로 Id로 동일성 및 동등성 확인 
             if (this == o) {
                 return true;
             }
    
             if (o == null || getClass() != o.getClass()) { //중요한 포인트!!!! 
                 return false;
             }
    
             User user = (User) o;
    
             return id != null ? id.equals(user.getId()) : user.getId() == null;
         }
    
         @Override
         public int hashCode() {
             return Objects.hash(getId());
         }
     }
    


  4. FollowingsFollow
    • Followings - 해당 User의 팔로워리스트를 저장하는 포장객체 (Followers도 동일한 형태로 되어 있다.)
    • Follow - Followers, Followings 리스트에 담겨 있는 VO 엔티티로 source, target 유저간의 팔로우 관계를 나타내는 엔티티
     @Embeddable
     public class Followings {
    
         @OneToMany(
             mappedBy = "source",
             fetch = FetchType.LAZY,
             cascade = CascadeType.PERSIST,
             orphanRemoval = true
         )
         private List<Follow> followings;
    
         protected Followings() {
         }
    
         //...생성자 생략 
    
         public Boolean isFollowing(User targetUser) { //문제를 발생시킨 핵심 메소드!!! 
             return followings.stream()
                 .anyMatch(follow -> follow.isFollowing(targetUser));
         }
    
         //...일부 비지니스 로직 생략
     }
    
     @Entity
     @Table(
         uniqueConstraints = {
             @UniqueConstraint(columnNames = {"source_id", "target_id"})
         }
     )
     public class Follow {
    
         @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
         private Long id;
    
         @ManyToOne(fetch = FetchType.LAZY)
         @JoinColumn(name = "source_id")
         private User source;
    
         @ManyToOne(fetch = FetchType.LAZY)
         @JoinColumn(name = "target_id")
         private User target;
    
         protected Follow() {
         }
    
         //...생성자 및 유효성 검사 로직 생략 
    
         public boolean isFollowing(User targetUser) { //문제를 발생시킨 핵심 메소드!!! 
             return this.target.equals(targetUser);
         }
    
         //...getter 생략 
         //equals & hashcode는 VO로 취급되어 필드가 같은지 확인 (즉, 유저가 같은 유저인지 확인)
     }
    


  5. PostRepository 게시물 좋아요 리스트 조회 쿼리

     @Query("select distinct p from Post p left join fetch p.likes where p.id = :postId")
     Optional<Post> findPostWithLikeUsers(@Param("postId") Long postId); // 포스트를 조회할 때 좋아요 리스트를 fetch join 해서 즉시로딩 한다. 
    


버그 발생

  • 현재 흐름은 다음과 같다.
    1. Post를 좋아요 한 유저 리스트를 반환하려함. (Post내부의 Likes를 반환)
    2. 좋아요 한 유저 리스트를 조회할 때, 조회하는 source 유저가 팔로잉 하고 있는 target 유저는 팔로잉 중이라고 나타냄.


    1. source 유저가 target user를 following 하고 있는 여부를 UserisFollowing 메소드를 통해서 확인함.
      • 이때 source와 target이 같은 경우(자기 자신인 경우) - null 반환
      • source가 target을 팔로잉 중인 경우 - true 반환
      • source가 target을 필로우 하지 않는 경우 - false 반환


  • 문제는, 로그인 한 source 유저와 즉시로딩 해 가져온 좋아요 리스트의 User 간의 팔로잉 여부가 모두 false로 출력이 된 것이다.



발생 원인

  • PostRepository에서 findPostWithLikeUsers()를 사용해 포스트와 좋아요 리스트를 즉시로딩(@OneToMany 관계) 할 때 Like 엔티티 내부의 User는 즉시로딩 하지 않으므로 proxy 객체이다.
  • 좋아요한 target 유저 리스트를 가져와서 로그인 유저인 source 유저의 isFollowing() 메소드로 두 유저간의 팔로잉 여부를 확인한다.

      // User.java
      public Boolean isFollowing(User targetUser) {
          if (this.equals(targetUser)) { //자기 자신일 경우 null 반환
              return null;
          }
    
          return this.followings.isFollowing(targetUser);
      }
    
      //Followings.java
      public Boolean isFollowing(User targetUser) {
          return followings.stream()
              .anyMatch(follow -> follow.isFollowing(targetUser));
      }
    
      //Follow.java
      public boolean isFollowing(User targetUser) { 
          return this.target.equals(targetUser); //User.java 의 equals & hashCode를 사용 
      }
    
  • 아무리 디버깅을 해봐도 비교하는 source 유저와 target 유저의 식별자(Id)가 같음에도 불구하고 Follow.javaisFollowing()에서 false가 반환 되었다.
  • 그 원인은 User.java에서 오버라이드한 equals hashcode에 있었다.

      @Override
      public boolean equals(Object o) {
          if (this == o) {
              return true;
          }
    
          if (o == null || getClass() != o.getClass()) { //(1) 중요한 포인트!!!! 
              return false;
          }
    
          User user = (User) o;
    
          return id != null ? id.equals(user.getId()) : user.getId() == null;
      }
    
      @Override
      public int hashCode() {
          return Objects.hash(getId());
      }
    
  • (1) 에서 User객체의 Id로 비교하기 이전에 두 객체가 같은 클래스인지 o.getClass()로 비교하고 있었다. 하지만 proxy 객체는 getClass() 로 비교하면 실제 entity와 같지 않기 때문에 false를 반환한다.
  • 따라서 프록시 객체와 실제 entity를 비교할때는 instance of를 사용해야한다.
    JPA Proxy 참고링크



해결 방법

  • 생각한 해결방법은 2가지 이다.
    1. PostLikefetch joinLikeUser 까지 모두 fetch join으로 즉시로딩
    • Post와 Like -> @OneToMany 관계
    • Like와 User -> @ManyToOne 관계
    • 위와 같은 연관관계는 두 번 fetch join 하여 Like의 User까지 즉시로딩 할 수 있다.
    •   @Query("select distinct p from Post p left join fetch p.likes.likes l left join fetch l.user where p.id = :postId")
        Optional<Post> findPostWithLikeUsers(@Param("postId") Long postId);
      
    • 위와 같이 User까지 즉시로딩 한다면, User가 더 이상 proxy 객체가 아니기 때문에 getClass()를 해도 문제가 발생하지 않는다.
    • 하지만 지나치게 복잡한 연관관계를 즉시로딩 하는 것이며 JPQL에서 fetch join 시 별칭을 쓰는 것은 JPA 표준 스펙에 맞지 않기 때문에 추천하는 방법이 아니다. (Hibernate 구현상 가능하므로 할 수 있긴 하다.)
      JPA fetch join 시 별칭 참고링크


  1. Userequals() 메소드의 getClass() 비교 부분을 instance of 로 수정
    • User.java의 equals 메소드를 다음과 같이 수정하면 올바른 값을 반환한다.

        @Override
        public boolean equals(Object o) {
            if (this == o) {
                return true;
            }
            if (!(o instanceof User)) { // 이 부분!!  
                return false;
            }
      
            User user = (User) o;
      
            return id != null ? id.equals(user.getId()) : user.getId() == null;
        }
      



마무리

  • JPA Proxy 객체에 대해서 학습했으나, 이론으로 알고 있던 부분을 직접 버그로 경험하며 학습할 수 있었다.
  • 디버깅 시 User 객체의 주소값이 ID가 같을 경우 같은 해시값으로 찍혔기 때문에 원인을 알기 더 어려웠다.
  • 또한 디버깅 포인트를 override 하여 IDE에서 자동으로 추가한 equals()에 걸 생각을 하지 못한 것도 디버깅을 어렵게 했던 포인트였다.
  • 개인적으로 equals()를 수정하는 두번째 해결방법을 추천하지만, override 한 메소드를 수정하는 것이 다른 팀원에게 충분히 공유되지 않으면 또다른 버그 포인트가 될 수 있다고 생각한다. (당연하게 생각하여 자세히 들여다보지 않는 부분이므로)



백엔드 코다입니다 🙌