각종 후기/우아한테크코스

[우아한 테크코스 3기] LEVEL 3 회고 (179일차)

제이온 (J.ON) 2021. 7. 30.

안녕하세요? 제이온입니다.

 

오늘을 포함하여 3일간 정렬 기능을 구현하였는데, 생각보다 굉장히 험난한 과정이었습니다. 이를 중점적으로 이야기해 보려합니다.

 

 

JPA 정렬 기능

우리 프로젝트에서 정렬 방식은 최신순, 과거순, 좋아요순이 있습니다. 여기서 최신순과 과거순은 간단합니다. 기본 키인 ID를 기준으로 오름차순 혹은 내림차순으로 정렬하면 되기 때문이죠. 제가 이전 포스팅에서 페이지네이션 기능 구현 과정을 설명했었는데, 사실 JpaRepository만으로도 페이지네이션과 정렬이 모두 가능했습니다. 왜냐하면 JpaRepository 클래스는 PagingAndSortingRepository 클래스의 상속을 받기 때문이죠.

 

 

@NoRepositoryBean
public interface JpaRepository<T, ID> extends PagingAndSortingRepository<T, ID>, QueryByExampleExecutor<T> {

	@Override
	List<T> findAll();

	@Override
	List<T> findAll(Sort sort);
    
    ...

 

 

우리가 주로 사용하는 find 계열 메소드 인자에 Sort 객체를 추가로 담아서 로직 처리를 하면 됩니다. Sort 객체는 주로 정적 팩토리 메소드인 by()를 많이 사용하는데, 오름차순으로 정렬할지 내림차순으로 정렬할지 정하는 인자 하나와 무슨 속성을 기준으로 정렬할지 정하는 인자를 넣어 줍니다.

 

 

    public static Sort by(Direction direction, String... properties) {

		Assert.notNull(direction, "Direction must not be null!");
		Assert.notNull(properties, "Properties must not be null!");
		Assert.isTrue(properties.length > 0, "At least one property must be given!");

		return Sort.by(Arrays.stream(properties)//
				.map(it -> new Order(direction, it))//
				.collect(Collectors.toList()));
	}

 

 

그래서 댓글을 등록한 최신순 혹은 과거순 정렬은 'findAll(Sort.by(Direction.DESC, "id")'와 같이 간단한 코드 한 줄로도 가능합니다. 하지만 좋아요순 정렬은 꽤 까다롭습니다.

 

 

@Getter
@NoArgsConstructor
@Entity
public class Comment extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Project project;

    @OneToMany(mappedBy = "comment", fetch = LAZY, cascade = ALL, orphanRemoval = true)
    private List<CommentLike> commentLikes = new ArrayList<>();

    private String url;

    private String content;
    
    ...
}

 

 

현재 댓글 객체는 CommentLike 컬렉션을 필드로 갖고 있지만, 실제로 DB의 댓글 테이블은 CommentLike과 관련된 컬럼을 갖고 있지 않습니다. 단순히 Comment_Like 테이블이 comment_id를 외래키로 갖고 있을 뿐이죠. 그래서 정렬을 SQL문으로 정렬한다고 하면 아래와 같이 조금 복잡해 집니다.

 

 

select * from comment left join comment_like on comment.id = comment_like.comment.id 
group by comment.id order by count(comment_like.comment.id) desc

 

 

이렇게 두 테이블을 join하고 group by를 사용한 뒤 count 함수를 이용하여 정렬해야하는데 단순히 Sort.by의 속성 인자만으로는 이를 구현할 수 없었습니다. 재미있는 사실은 Sort.by의 속성 인자로 commentLikes를 넣을 수 있었는데, 이러면 다음과 같이 commentLikes의 기본 키를 기준으로 정렬한다는 것이었습니다.

 

 

    select
        distinct comment0_.id as id1_0_,
        comment0_.created_date as created_2_0_,
        comment0_.modified_date as modified3_0_,
        comment0_.content as content4_0_,
        comment0_.project_id as project_6_0_,
        comment0_.url as url5_0_,
        comment0_.user_id as user_id7_0_ 
    from
        comment comment0_ 
    left outer join
        project project1_ 
            on comment0_.project_id=project1_.id 
    left outer join
        comment_like commentlik2_ 
            on comment0_.id=commentlik2_.comment_id 
    where
        comment0_.url=? 
        and project1_.secret_key=? 
    order by
        commentlik2_.id desc,
        comment0_.id asc

 

 

즉, 최근에 좋아요가 눌린 댓글순으로 정렬한다는 것인데 이것이 제가 원하는 것은 아니었습니다. 좋아요가 많이 눌린 순으로 정렬되는 것이 필요했죠.

 

가장 편한 방법은 @Query 어노테이션을 활용하여 JPQL을 통해 구현하는 것이었습니다. 다음과 같이 적당한 메소드를 Repository에 만들고 그 위에 @Query를 붙여주면 됩니다.

 

 

    @Query("select c from Comment c left join CommentLike as l on c.id = l.comment.id "
        + "where c.url = ?1 and c.project.secretKey = ?2 "
        + "group by c.id order by count(l.comment.id) desc, c.createdDate")
    List<Comment> findAllOrderByCommentLikeCount();

 

 

이러면 작동은 합니다만, 저는 욕심이 있었습니다. 바로 정렬 기준들을 하나로 추상화하는 것이었죠. 최신순과 과거순 정렬을 위해서는 find 계열의 메소드에 Sort 인자 하나만 추가해 주면 되는데, 좋아요 순 하나때문에 추상화 목적을 달성할 수 없다는 것이 꽤나 불-편했습니다.

 

 

    @Getter
    @RequiredArgsConstructor
    public enum SortOption {
        LATEST(Sort.by(Direction.DESC, "id")),
        LIKE(???),
        OTHER(Sort.by(Direction.ASC, "id"));

        private final Sort sort;

        public static Sort getMatchedSort(String sorting) {
            return Arrays.stream(values())
                .filter(sortOption -> sortOption.name().equals(sorting.toUpperCase(Locale.ROOT)))
                .findAny()
                .orElse(OTHER)
                .sort;
        }
    }

    // 실제 사용
    public List<CommentResponse> findAllCommentsByUrlAndProjectKey(String sorting, String url, String projectKey) {
        List<Comment> comments = commentRepository
            .findByUrlAndProjectSecretKey(url, projectKey, SortOption.getMatchedSort(sorting));

        return comments.stream()
            .map(comment -> CommentResponse.of(comment, UserResponse.of(comment.getUser())))
            .collect(Collectors.toList());
    }

 

 

이렇게 새로운 정렬 기준이 추가될 때마다 SortOption 필드에 열거형 상수를 추가만 하고 나머지는 변화를 최소화하고 싶었습니다.

 

 

열심히 고민하던 중 한 가지 새로운 해결책이 떠올랐습니다. 저는 지금까지 두 테이블을 join하여 count를 통해 정렬하는 방법만 생각했었습니다. 그러나 그럴 필요가 없습니다. 왜냐하면 Comment 객체에는 CommentLike 컬렉션을 가지고 있고, 이 컬렉션의 size가 바로 댓글에 눌린 좋아요 수였습니다!!

 

즉, Comment 객체가 갖고 있는 CommentLike 컬렉션의 사이즈를 기준으로 정렬하면 되는 것이었죠. 그런데.. 아쉽게도 'Sort.by("commentLikes.size()")'와 같은 코드는 인식이 되지 않았습니다. size() 메소드는 JDK에서 작동하는 메소드이기 때문이죠.

 

구글링을 몇 시간 시도해 보고 포기하려던 찰나에 한 스택오버플로우 글이 저를 살렸습니다. 요약하자면, @Formula 어노테이션을 통해 런타임 중에 동적으로 특정 댓글의 좋아요 수가 몇 개인지 조회하는 서브 쿼리를 만들어서 그 개수를 Comment 객체의 필드로 갖고 있으라는 것이었습니다.

 

 

@Getter
@NoArgsConstructor
@Entity
public class Comment extends BaseTimeEntity {

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

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private User user;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn
    private Project project;

    @OneToMany(mappedBy = "comment", fetch = LAZY, cascade = ALL, orphanRemoval = true)
    private List<CommentLike> commentLikes = new ArrayList<>();

    private String url;

    private String content;

    @Formula("(select count(*) from comment_like where comment_like.comment_id=id)")
    private int likeCount;
    
    ...
}

 

 

위의 likeCount가 바로 서브 쿼리로 인해 반환된 값을 의미합니다. JPQL이 아닌 순수 SQL 방식으로 코드를 짜면 됩니다. 이러면 런타임 중에 Comment를 조회할 일이 있을 때마다 값을 넣어준다고 하는데 정확히 로드하는 방식은 잘 모르겠습니다.

 

 

@Getter
@RequiredArgsConstructor
public enum SortOption {
    LATEST(Sort.by(Direction.DESC, "id")),
    LIKE(Sort.by(Direction.DESC, "likeCount").and(Sort.by("id"))),
    OTHER(Sort.by(Direction.ASC, "id"));

    private final Sort sort;

    public static Sort getMatchedSort(String sorting) {
        return Arrays.stream(values())
            .filter(sortOption -> sortOption.name().equals(sorting.toUpperCase(Locale.ROOT)))
            .findAny()
            .orElse(OTHER)
            .sort;
    }
}

 

 

아무튼 @Formula를 통해 정렬할 수 있는 기준을 얻을 수 있으므로 위와 같이 완벽하게 추상화가 가능합니다. 꽤 오랜 시간 정렬 기능에 몰두했는데, 다행히 제 스스로 만족스러운 코드가 나와서 뿌듯했습니다.

 

 

정리

참 많이 스트레스 받고 추상화 때려치고 싶었던 순간이 많았습니다. 하지만 이쁜(?) 구조를 위해 열심히 노력한 결과 얻은 개념이 굉장히 많은 것 같습니다. 물론 얕은 지식이라서 공부해야하지만, 무엇을 공부해야하는지 방향을 잡은 것 자체가 큰 소득이라 생각합니다. 앞으로도 원하는 이상을 최대한 지킬 수 있도록 노력해야겠습니다.

추천 글