멈재

[크라운드] #3, 어리둥절 빙글빙글 삽질 일기 (1) 본문

크라운드

[크라운드] #3, 어리둥절 빙글빙글 삽질 일기 (1)

멈재 2023. 6. 5. 12:58
728x90

크리에이터 목록 조회를 마치고 숏클래스 목록 조회 기능을 구현하던 찰나에 마주한 삽질 일기이다.
 


 

문제 상황은 다음과 같다.
숏클래스와 콘텐츠의 조회 기능에서는 로그인한 사용자라면 좋아요 또는 북마크한 대상인지를 나타내주어야 했다.

 
 
테이블 구조는 대강 아래 이미지처럼 되어있다.

 
또한, 컨텐츠는 사용자에 따라 좋아요 또는 북마크한 대상을 나타내야 하는 비즈니스라서 양방향 관계를 맺어주는 것이 낫다고 판단되어 콘텐츠는 좋아요와 북마크간에 양방향인 상태이다.
 
 
숏클래스 목록 조회의 전체 과정은 이렇다.

1. 클라이언트가 요청을 보낸다 
2. (일부 과정 생략)
3. 커스텀 애너테이션을 컨트롤러 메서드 파라미터에 붙여 ArgumentResolver가 동작되어 객체로 바인딩
4. 서비스 계층에서 바인딩된 객체의 값으로 회원 정보를 가져옴
5. 숏클래스 목록을 데이터베이스로부터 읽어옴
6. 숏클래스 목록과 회원 정보를 응답 객체(DTO)에 넘기고 정제하여 응답으로 보냄

 
데이터 조회는 정상적으로 되었지만 좋아요한 대상인지를 나타내는 플래그 값이 정상적으로 떨어지지 않았다.
처음에는 4번이 문제겠거니싶어 디버깅 해보았는데 바인딩된 값도 잘 넘어오고 회원 정보도 잘 넘어오고 있었다.
 
설마.. 싶어서 equals와 hashcode를 오버라이딩 안 한 건 아닐까 싶어 확인해봐도 정상적으로 되어있는 것을 확인했다.

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EqualsAndHashCode(of = {"shorts", "member"})
public class ShortsLike {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "like_id")
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "shorts_id", foreignKey = @ForeignKey(name = "fk_shorts_like_to_board"))
    private Shorts shorts;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "fk_shorts_like_to_member"))
    private Member member;
    ...
}

 
그렇다면 어디서 문제가 발생한 것일까.
머쓱하게도,,, 올바른 객체를 넘겨주지 않았었다.
 
앞서 설명한 6번의 과정에서 응답 객체에 숏클래스 목록과 회원 정보를 함께 전달하여 응답 객체를 만들게 된다.
회원 정보는 좋아요 또는 북마크한 대상인지를 위해 쓰인다.
 
아래 코드는 기존(문제가 있는) 코드이다.

// Shorts#isLikedBy
public boolean isLikedBy(Member member) {
    if(Objects.isNull(member)) {
        return false;
    }
    return shortsLikes.isLikedBy(member);
}
@Getter
@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ShortsLikes {

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "shorts", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<ShortsLike> shortsLikes = new ArrayList<>();

    public boolean isLikedBy(Member member) {
        return shortsLikes.contains(member);
    }

	...
}

 
위 코드를 보면 숏클래스 좋아요 객체가 아닌 회원 객체만으로  비교하니 당연히 정상적으로 실행될 수가 없다.

노란 네모 = 정상적인 비교 대상, 빨간 네모 = 잘못된 비교 대상

 
따라서 다음과 같이 숏클래스를 함께 전달하여 좋아요 객체를 생성하게끔 변경하게 되면

// Shorts#isLikedBy
public boolean isLikedBy(Member member) {
    if(Objects.isNull(member)) {
        return false;
    }
    return shortsLikes.isLikedBy(this, member);
}

// ShortsLikes#isLikedBy
public boolean isLikedBy(Shorts shorts, Member member) {
    ShortsLike shortsLike = ShortsLike.of(shorts, member);
    return shortsLikes.contains(shortsLike);
}

 
원하는 결과를 정상적으로 받아오는 것을 알 수 있다.

 

 

++) 동일한 문제가 팔로우에도 발생해서 추가하게 되었다.
 
실수해서 발생했던 앞선 문제와는 달리 모든 조건이 다 된 상황임에도 '팔로우한 상태'의 플래그가 false인 결과를 얻게 되었다. 

팔로워 컬렉션은 다음과 같이 정의되어 있다.

@Embeddable
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Followers {

    @OneToMany(fetch = FetchType.LAZY, mappedBy = "target", cascade = CascadeType.PERSIST, orphanRemoval = true)
    @LazyCollection(LazyCollectionOption.EXTRA)
    private List<Follow> followers = new ArrayList<>();
    
    public boolean isFollowedBy(Creator creator, Member member) {
        Follow follow = Follow.of(member, creator);
        return followers.contains(follow);
	}
    ...
}

언뜻 보기에 잘 맞추어 작성한듯싶지만 익숙하지 않은 애너테이션인 LazyCollection이 존재하고 있다.
 
컬렉션 최적화를 찾아보던 중에 스택오버플로우에 달린 답변을 하나 읽게 되었는데 답변의 일부는 다음과 같다.

You need "LazyCollection" annotation with EXTRA option.
```
@OneToMany(fetch = FetchType.LAZY)
@LazyCollection(LazyCollectionOption.EXTRA)
private List<CommentEntity> comments;
```
This annotation would allow to access "size()" without loading.

 
조금 더 자세한 내용을 알아보고자 Hibernate Docs를 살펴봤더니 '상태가 요청될 때 로드된다'고 정의하고 있다.
쉽게 말해 Collection.size() 또는 Collection.contains()로 접근할 때에는 컬렉션을 초기화하지 않는 기능을 한다.
 
그러나 이것이 문제가 되었다.
 
LazyCollection으로 인해 비교하려는 대상이 실제 객체로 초기화되지 않고 프록시 상태를 유지하게 되면서 동등성 비교에 실패하여 다른 객체라는 결과가 나타나게 된 것이다.
 
따라서 다음과 같은 결과가 나오게 된다.

Hibernate.isInitialized는 프록시 컬렉션이 초기화 되었는지 확인

앞서 설명한 대로 초기화되지 않은 상태(프록시 컬렉션)라는 false인 결과를 얻게 된다.
또한, 프록시 객체이므로 실제 객체를 가져오는 또 하나의 쿼리가 나간 것을 볼 수 있다.
 
LazyCollection을 실제로 적용했던 이유는 노란 네모 박스의 쿼리가 나갔기 때문이다.
원래는 팔로우 컬렉션에 접근없이 팔로워의 수만 구했기 때문에 노란 네모 박스의 결과처럼 쿼리가 최적화(count(id))돼서 나간 것을  확인할 수 있다. 

밑에서 결과로 보여줄 LazyCollection을 적용하지 않았을 때에는 모든 필드를 가져와서 사이즈를 구하기 때문에 성능상의 이점을 조금이나마 가져가기 위해 적용했었다.


반면 LazyCollection을 적용하지 않았을 때에는 다음과 같은 결과가 나오게 된다.

팔로워의 수를 구하는 로직이 실행될 때 프록시가 진짜 객체로 초기화되기 때문에 실제 컬렉션이 되므로 true인 결과와 추가적인 쿼리가 나가지 않는 것을 확인할 수 있다

 

이 문제를 계기로 두 가지의 배움을 몸소 배웠다.

  • 디버깅을 생활화하자
  • 컴파일 에러는 정말 고마운 에러다