몇 달 전 진행한 프로젝트에서 사용자가 설정한 조건에 맞는 게시글을 가지고 와야되는 기능이 있었습니다. 예전에 구현했던 기능이지만, 생각난 김에 한 번 정리해보겠습니다.
본격적으로 들어가기에 앞서,,,
이 글은 그냥 기록용입니다 !!! 정답이 아닙니다 ,,!!
단순히 보이는 기능이지만, 검색 조건은 다음과 같았습니다.
- [키워드 검색] 사용자가 입력한 키워드에 맞는 게시글이 나올 것 (키워드 입력이 없을 경우, 모든 게시글이 나올 것)
- [좋아요 여부] 자신이 좋아요를 누른 게시글만 나올 것
- [작성 여부] 자신이 작성한 게시글만 나올 것
- [페이지네이션] 사용자가 원하는 페이지에 대한 게시글이 나올 것
- [정렬] 작성 날짜, 좋아요, 조회수에 따른 게시글을 정렬할 것
저는 params의 인자로 keyword, like, my, page, size, sort를 두어 검색 조건에 따른 결과가 나올 수 있도록 했습니다.
- [키워드 검색] keyword
- [좋아요 여부] like
true: 사용자가 좋아요 한 게시글
false: 모든 게시글(default) - [작성 여부] my
true: 사용자가 작성한 게시글
false: 모든 게시글(default) - [페이지네이션] page (사용자가 조회할 페이지 번호)
- [페이지네이션] size (페이지네이션을 할 개수)
- [정렬] sort
createdAt(작성날짜), likes(좋아요), hit(조회수)
이 때, 변수들을 RequestParam으로 각각 입력 받는다면, Controller의 변수가 많아져 관리하기 힘들 것이라고 생각했습니다. 따라서 저는 TacticPostRequestParam이라는 ModelAttribute변수를 만들었습니다.
@GetMapping("")
public ResponseEntity<TacticPostListCntResponse> getTacticPostList(@RequestHeader("Member-id") Long memberId,
@ModelAttribute TacticPostRequestParam tacticPostRequestParam) {
return ResponseEntity.ok(tacticBoardService.getTacticPostList(memberId, tacticPostRequestParam));
}
TacticPostRequestParam은 다음과 같습니다.
@Getter
public class TacticPostRequestParam {
private String sort;
private Integer page;
private Integer size;
private String keyword;
private Boolean my;
private Boolean like;
public TacticPostRequestParam(String sort, Integer page, Integer size, String keyword, Boolean my, Boolean like) {
this.sort = sort == null ? "createdAt" : sort;
this.page = page;
this.size = size;
this.keyword = keyword == null ? "" : keyword;
this.my = my == null ? false : my;
this.like = like == null ? false : like;
}
}
TacticPostRequestParam 생성자에서 삼항연산자를 통해 변수의 값이 null 값일 때, 초기값이 설정될 수 있도록 했습니다.
또한 제가 구현할 부분의 데이터베이스는 다음과 같이 설계되어 있었습니다.
만약, 데이터를 불러올 때 조건이 조건이 많지 않았더라면 단순하게 구현을 할 수 있었을 것 입니다.
하지만, 데이터를 가지고 오기 위해 관계가 설정된 다른 테이블의 컬럼이 필요한 경우도 있었고, 생각보다 까다로워서 어떻게 구현을 해야될지 고민을 하게 되었습니다.
제가 고민한 것은 다음과 같습니다.
- 첫 번째, 정렬, 검색, 페이지네이션이 있을 때, 어떤 순서로 데이터를 가지고 오는 것이 좋을까?
- 두 번째, 검색 조건이 여러 개일 때는 어떻게 처리해야될까?
- 세 번째, 좋아요 순으로 게시글을 정렬해야 되는데, 모든 게시글에 대한 좋아요 개수는 어떻게 구하고 정렬을 하는게 좋을까?
| 첫 번째 고민은 제가 예상하는 구현하기 빠를 것 같은 순서대로 했습니다.
그 당시에는 기능 구현에 급급했기 때문에 그렇게 했고, 소규모 프로젝트였기 때문에 어떻게 하든 비슷한 결과를 가지고 을 것이라고 생각했습니다. 하지만 프로젝트가 성격과 구현 방법에 따라서 시스템의 성능에 영향을 미칠 수 있을 것이라고 생각하기 때문에, 서비스 별로 적합한 방법을 찾아서 구현 방법을 고안해야된다고 생각합니다.
| 두 번째 고민은 Specification을 통해 해결하였습니다.
처음에는 검색 구현을 위해 쿼리문의 where을 통해 구현하는 것을 생각했습니다. 하지만, 쿼리문으로 여러 개의 조건을 모두 설정하기에는 무리가 있을 것이라고 판단하였습니다. 그러다가 검색을 통해 JPA에 Specification을 알게 되었습니다. 쿼리문이 아닌 Java를 작성하여 검색을 구현할 수 있다고 하여, Specification을 사용하였습니다.
Specification 사용 방법은 다음과 같습니다.
Specification을 사용하기 위해서는 Specification을 사용하려는 entity repository에 JpaSpecificationExecutor을 상속받아야합니다.
@Repository
public interface TacticPostRepository extends JpaRepository<TacticPost, Long>, JpaSpecificationExecutor<TacticPost> {
@Modifying
@Query("UPDATE TacticPost t set t.hit = t.hit + 1 WHERE t.id = :tacticPostId")
void updateHit(Long tacticPostId);
Page<TacticPost> findByMemberId(Long userId, Pageable pageable);
boolean existsById(Long tacticPostId);
}
그리고 Specification을 통해 제가 필요로 하는 검색에 대한 것들을 함수로 만들었습니다.
public class TacticPostSpecification {
public static Specification<TacticPost> findByKeyword(String keyword) {
return ((root, query, criteriaBuilder) -> criteriaBuilder.like(root.get("title"), "%" + keyword + "%"));
}
public static Specification<TacticPost> findByMy(Long memberId) {
return ((root, query, criteriaBuilder) -> criteriaBuilder.equal(root.get("memberId"), memberId));
}
public static Specification<TacticPost> findByLike(Long memberId) {
return ((root, query, criteriaBuilder) -> {
root.join("tacticPostLikes", JoinType.LEFT);
return criteriaBuilder.equal(root.get("tacticPostLikes").get("memberId"), memberId);
});
}
}
좋아요한 게시글을 검색하기 경우에는 게시글 좋아요 테이블과 조인 연산을 해야되기 때문에 root.join("tacticPostLikes, JoinType.LEFT)를 했습니다.
@Override
public TacticPostListCntResponse getTacticPostList(Long memberId, TacticPostRequestParam tacticPostRequestParam) {
Pageable pageable = PageRequest.of(
tacticPostRequestParam.getPage(),
tacticPostRequestParam.getSize(),
Sort.by(Sort.Direction.DESC, tacticPostRequestParam.getSort()));
Specification<TacticPost> spec = (root, query, criteriaBuilder) -> null;
if(tacticPostRequestParam.getKeyword() != null) spec = spec.and(TacticPostSpecification.findByKeyword(tacticPostRequestParam.getKeyword()));
if(tacticPostRequestParam.getMy()) spec = spec.and(TacticPostSpecification.findByMy(memberId));
if(tacticPostRequestParam.getLike()) spec = spec.and(TacticPostSpecification.findByLike(memberId));
long totalCount = tacticPostRepository.count(spec);
Page<TacticPost> findTacticPostList = tacticPostRepository.findAll(spec, pageable);
List<TacticPostListResponse> tacticPostListResponse = findTacticPostList.stream()
.map(findTacticPost -> {
boolean isLike = !tacticPostLikeRepository.findByMemberIdAndTacticPostId(memberId, findTacticPost.getId()).isEmpty();
return new TacticPostListResponse(findTacticPost, isLike);
}).collect(Collectors.toList());
return new TacticPostListCntResponse(tacticPostListResponse, totalCount);
}
해당 기능의 서비스 로직입니다. JPA의 Pageable는 페이지네이션 뿐만 아니라 정렬까지 지원합니다. 저는 Pagealbe을 통해 게시글을 정렬했습니다.
검색을 위해 Specification을 초기화 했습니다. 그리고 각 변수의 값에 따라 검색 조건을 and 메서드를 통해 추가해주었습니다.
검색 조건을 모두 추가한 뒤, findAll의 매개변수로 Specification 변수를 추가해서 제가 원하는 검색 결과를 얻을 수 있었습니다.
| 세 번째 고민은 @formula를 통해 해결하였습니다.
정렬 조건 중, 게시글에 대한 좋아요 개수에 대하여 정렬을 하기 위해서는 각 게시글마다 좋아요 개수가 필요합니다. 좋아요 개수는 게시글 테이블에 존재하지 않고, 별도의 좋아요 테이블을 관리했기 때문에, 게시글의 좋아요 개수를 구하기 위해서는 게시글 번호와 좋아요 테이블의 게시글 번호 컬럼과 조인 연산을 하여 count를 하는 것을 생각하였습니다.
하지만 이렇게 된다면, 데이터가 많아질수록 연산량이 많아지고, 성능에 좋지 않을 것이라고 판단하여 다른 방법을 찾아보았습니다. 어떻게하면 해당 게시글의 좋아요 개수를 테이블의 조인 연산을 하지 않으면서 알 수 있을까 찾아보다가 formula 어노테이션을 알게 되었습니다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class TacticPost extends BaseEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long memberId;
private Long tacticId;
private String title;
private String content;
private String optionName;
private String tacticPythonCode;
private String tacticJsonCode;
private Double testReturns;
private Double contestReturns;
private String imgPath;
@Column(columnDefinition = "BigInteger default 0")
private Long hit;
@OneToMany(mappedBy = "tacticPost", cascade = CascadeType.ALL)
private List<TacticPostLike> tacticPostLikes = new ArrayList<>();
@Formula("(select count(*) from tactic_post_like where tactic_post_like.tactic_post_id=id)")
private long likes;
}
likes 속성은 실제 TacticPost테이블에는 없는 속성입니다. 하지만 Formula를 통해서, TacticPost테이블과 연관되어 있는 TacticPostLike 테이블의 속성을 통해 TacticPost의 좋아요 수를 계산할 수 있었습니다. 게시글에 대한 좋아요 수는 실제 데이터베이스에는 존재하지 않지만, 엔티티 내에서는 실제 컬럼처럼 사용할 수 있게 되었습니다.
마무리
단순한 검색 조회 기능이지만, 구현 과정에서 모르는 것이 많아 정보를 찾아보며 새로운 것을 많이 알게 된 시간이었습니다. 그리고 지금 글을 쓰면서 다시 코드를 보았을 때, 개선해야되는 부분이 많은 것 같아서 시간이 된다면 리팩토링을 해보는게 좋을 것 같다는 생각이 드네요..!
글을 쓰기 위해 검색을 했는데, JpaSpecification은 JPA의 Criteria로 이루어져있고, 조금만 복잡해져도, 사용하기 어려워져서 실무에서는 잘 사용하지 않는다고 합니다. 그리고 김영한님은 모든 프로젝트에서 JPA Criteria(JPASpecificationExecutor 포함) 사용을 금지하고, QueryDSL을 사용한다고 합니다.
Formula어노테이션은 불필요한 데이터 로드를 방지하여 성능을 개선할 수 있지만, 불필요한 쿼리가 실행될 수 있고 부작용을 발생시킬 수 있어, 신중하게 사용해야된다고 합니다.
사실, 진행 중인 프로젝트에서 QueryDSL 설정을 할 때, 오류가 발생해서 QueryDSL을 사용하지 않고, JpaSpecification을 사용한 것도 있습니다. 한 번 Specification을 통해 기존에 구현 했던 것을 QueryDSL로 구현하면서 직접적으로 무슨 차이가 있는지 확인해 봐야겠습니다.
또한 Formula에 대한 정보를 찾아보는데, JPA에 대한 개념이 부족해 정확하게 이해가 안되는 부분이 많았습니다. 다시 한번 JPA에 대해 학습하고, 해당 정보를 찾아봐야겠습니다..
관련 코드는 해당 깃허브에 있습니다.
아직 배우는 과정이어서 부족한 점이 많지만, 올려봅니다!
쓰고 보니까, 두서없이 글을 쓴 것 같습니다. 이상한 부분이나 잘못된 부분에 대해서 말씀해주시면 감사하겠습니다.
'Programming > Spring' 카테고리의 다른 글
[Spring] Spring Cloud Load Balancer 설정 오류 해결 과정 (0) | 2024.01.19 |
---|---|
[Spring] Slf4j 사용 시, cannot find symbol variable log 에러 (0) | 2024.01.04 |