다른 일반적인 게시판 포트폴리오들과 차별성을 주기 위해 구현한 부분으로 maturi프로젝트를 진행하며 가장 강조 하고 싶은 부분입니다.
여러 조건들과 페이징을 동적 쿼리문을 통해 하나의 쿼리문으로 처리하였습니다.
목차
- front단에서 처리한 검색, 정렬 조건 처리
- controller단에서 처리한 검색, 정렬 조건 처리
- service단에서 처리한 검색, 정렬 조건 처리
- repository단에서 처리한 동적 쿼리 문
1. front단에서 처리한 검색, 정렬 조건 처리
게시글목록을 불러오는 작업은 ajax로 데이터를 받아왔습니다. 먼저 선택한 조건에 맞도록 데이터를 ajax요청을 보내야하기 때문에 데이터를 세팅해주는 작업이 필요합니다.
검색 조건을 세팅하는 javascript코드 일부분
function SearchCondSetting(event){
console.log("무슨 동작입니까?",event);//click, load, scroll
생량...
let lastArticleInput = document.querySelector('input[name="lastArticleId"]');
let lastArticle;
if(lastArticleInput.value === "" || lastArticleInput.value === null){
lastArticle == null;
}else{
lastArticle = lastArticleInput.value;
}
if(event === "click" || event ==="load"){
lastArticle = null;
}
let obj = {
'radioCond': radioCond,//검색에 필요한 값
'latitude': latitude,//검색에 필요한 값
'longitude': longitude,//검색에 필요한 값
'category': category,//검색에 필요한 값
'content': content,//검색에 필요한 값
'writer': writer,//검색에 필요한 값
'tag': tag,//검색에 필요한 값
'restaurantName': restaurantName,//검색에 필요한 값
'all': all,//검색에 필요한 값
'keyword': searchKeyWordInput.value,//검색에 필요한 값
'lastArticleId': lastArticle,//페이징에 필요한 값
'size': 5, // 페이징에 필요한 값
'event' : event//페이징에 필요한 값
};
console.log("javascript에서 setting한 검색, 페이징 조건",obj);
return obj;
}
생략한 부분은 document.queryselector를 사용하여 요소가 선택 되었는지, 검색창에 값이 입력되었는지 확인 후 변수에 값을 넣을지 넣지 않을지를 작업하였습니다.
이 때 주의깊게 볼 것이 정렬을 할 때, no-offset방식으로 페이징을 구현하여서 마지막 게시글의 id(lastArticleId)를 전달해주었습니다. 그리고 매개변수로 click(검색), load(페이지 로드), scroll(스크롤) 어떤 이벤트에서 발생하였는지를 확인해서 검색or페이지 로드일 경우에는 null값을 전달하도록 처리하였습니다.
정렬 조건을 세팅하는 javascript코드 일부분
function orderCondSetting(event){
let orderLabel = document.querySelector('label[for="order"]').innerText;
생략...
let orderBy;
switch(orderLabel){
case "최신순":
orderBy = "createdDateDesc";
break;
case "오래된순":
orderBy = "createdDateAsc";
break;
case "조회수순":
orderBy = "viewsDesc";
break;
case "좋아요순":
orderBy = "likeCountDesc";
break;
case "댓글순":
orderBy = "commentCountDesc";
break;
}
let orderCond = {
'orderBy': orderBy,//검색에 필요한 값
'views': views,//검색에 필요한 값
'commentCount': commentCount,//검색에 필요한 값
'likeCount': likeCount//검색에 필요한 값
}
console.log("정렬 동적처리 orderBy:",orderCond);
return orderCond;
}
최신순, 오래된순은 마지막 게시글의 id값을 기준으로 정렬을 하였고, 조회수순, 좋아요순, 댓글순도 마지막 게시글의 조회수, 좋아요, 댓글 값을 기준으로 전달을 하였습니다. 이때 조회수순, 좋아요순, 댓글순은 추가적인 조건을 쿼리문에 작성하였는데 repository부분에서 자세하게 설명하겠습니다.
세팅된 조건들을 ajax요청 javascript코드
function searchArticleAjax(obj,orderCond){
console.log("페이징 처리 ajax요청");
const url = '/api/articles?radioCond='+_fnToNull(obj['radioCond'])
+'&latitude='+_fnToNull(obj['latitude'])
+'&longitude='+_fnToNull(obj['longitude'])
+'&category='+_fnToNull(obj['category'])
+'&content='+_fnToNull(obj['content'])
+'&writer='+_fnToNull(obj['writer'])
+'&tag='+_fnToNull(obj['tag'])
+'&restaurantName='+_fnToNull(obj['restaurantName'])
+'&all='+_fnToNull(obj['all'])
+'&keyword='+_fnToNull(obj['keyword'])
+'&lastArticleId='+_fnToNull(obj['lastArticleId'])
+'&size='+_fnToNull(obj['size'])
+'&event='+_fnToNull(obj['event'])
+'&orderBy='+_fnToNull(orderCond['orderBy'])
+'&views='+_fnToNull(orderCond['views'])
+'&commentCount='+_fnToNull(orderCond['commentCount'])
+'&likeCount='+_fnToNull(orderCond['likeCount']);
console.log("ajax요청 url",url);
fetch(url)
.then((response) => {
생략...
}
fetch메서드를 사용하여 GET방식으로 게시글 목록을 가져오도록 요청을 하였습니다.
게시글 목록을 받아서 출력하는 부분코드는 click 이벤트나 load이벤트일 경우에는 innerHTML을 사용해서 처음부터 다시 게시글을 5개를 출력시켰고, scroll이벤트일 경우에는 기존 게시글의 뒤에 appendChild메서드를 이용해서 추가하는 식으로 작업하였습니다.
프론트엔드 뷰 템플릿(react, vue)를 사용하지 않고 javascript로 구현하다보니 다소 코드가 길어졌습니다.
코드가 너무 길어서 구글 드라이브 링크를 달아 놓겠습니다.
front단에서 처리한 검색, 정렬 조건 처리 javascript코드
https://drive.google.com/file/d/1T-x8Y4vescz_VMdNLLcQ5K63cBXKn37C/view?usp=share_link
2. controller단에서 처리한 검색, 정렬 조건 처리
게시글 목록을 가져오는 controller단 소스코드
@GetMapping("")//게시글 출력 페이징, 검색
public ResponseEntity<ArticlePagingResponse> searchArticlePaging(@Login Long memberId,
@ModelAttribute ArticleSearchRequest articleSearchRequest,//검색조건에 필요한 값들
@ModelAttribute ArticlePagingRequest articlePagingRequest,//페이징에 필요한 값들
@ModelAttribute ArticleOrderCond articleOrderCond){//정렬 조건에 필요한 값들
log.info("[ArticleAPIController] articleSearchRequest(검색 조건) = {}",articleSearchRequest);
log.info("[ArticleAPIController] articlePagingRequest(페이징 정보) = {}",articlePagingRequest);
log.info("[ArticleAPIController] articleOrderCond(정렬 조건) = {}",articleOrderCond);
ArticlePagingResponse articles = articleService.articleSearch(articleSearchRequest,articlePagingRequest,articleOrderCond,memberId);
log.info("articles의 페이징, 검색 정보 결과 = {}",articles);
if(articles == null){
return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
}
return ResponseEntity.status(HttpStatus.OK).body(articles);
}
재사용성을 높이기 위해 검색조건에 필요한 값(ArticleSearchRequest), 페이징에 필요한 값(ArticlePagingRequest), 정렬 조건에 필요한 값(ArticleOrderCond) 3가지로 DTO를 나누어서 받도록 설계 하였습니다.
3. service단에서 처리한 검색, 정렬 조건 처리
service단에서 처리한 검색, 정렬 조건처리 소스코드
public ArticlePagingResponse articleSearch(ArticleSearchRequest searchRequest,
ArticlePagingRequest pagingRequest,
ArticleOrderCond articleOrderCond,
Long memberId) {
생략...
(프론트단에서 조건을 전달할 때 빈 값이나, 잘못된 문자열이 왔을 경우의 예외처리)
// 1. 검색조건중 DB에서 필요한 정보를 가져옴
ArticleSearchCond cond = getSearchCond(searchRequest, memberId);
// 2. 동적 쿼리문을 실행
ArticlePagingResponse<Article> result = articleQRepository.searchDynamicQueryAndPaging(pagingRequest.getLastArticleId(),cond,articleOrderCond,pagingRequest.getSize());
// 3. DB에서 가져온 값을 View단에 보여주기 위한 값으로 변환
List<ArticleViewDTO> articleViewDTOS = new ArrayList<>();
for (Article article : result.getContent()) {
ArticleViewDTO articleViewDTO = getArticleViewDTO(article,memberId);
articleViewDTOS.add(articleViewDTO);
}
result.setContent(articleViewDTOS);
result.setEvent(pagingRequest.getEvent());
if (articleViewDTOS.size() == 0 ){//검색조건에 맞는 게시글이 하나도 없을 때
result.setLastArticleId(null);
} else{
result.setLastArticleId(articleViewDTOS.get(articleViewDTOS.size()-1).getId());
}
return result;
}
크게 3가지 작업으로 나뉩니다.
1. 검색 조건중 DB에서 필요한 정보를 가져오기
팔로우한 유저가 작성한 게시글, 관심 지역에 해당하는 게시글, 좋아요 누른 게시글을 가져올 경우 해당 조건에 맞는 게시글을 가져오는 작업이 필요하기 때문에 해당 게시글을 가져오는 작업이 필요합니다.
차단한 유저의 게시글은 출력하지 않도록 하기위해 차단한 유저의 게시글도 찾는 작업을 통해 차단한 게시글에 대한 조건도 처리하였습니다.
2. 동적 쿼리문을 실행
service단에서 처리한 검색, 정렬 조건처리 소스코드에서 searchDynamicQueryAndPaging메서드를 통해 받은 값의 리턴 타입이 ArticlePageingResponse<Article> 타입으로 리턴을 받는다는 점입니다.
ArticlePageingResponse<?>클래스 소스코드
@ToString
@Builder
@NoArgsConstructor
@AllArgsConstructor
@Getter
@Setter
public class ArticlePagingResponse<T> {
private List<T> content = new ArrayList<>();
boolean hasNext;
private String event;
private Long lastArticleId;
public ArticlePagingResponse(List<T> content, boolean hasNext) {
this.content = content;
this.hasNext = hasNext;
}
//메인페이지에서 사용할 게시글 DTO
public void setContent(List<ArticleViewDTO> articleViewDTOS) {
this.content = (List<T>) articleViewDTOS;
}
//마이페이지에서 사용할 게시글 DTO
public void setContentForMyPage(List<ArticleMyPageViewDTO> articleMyPageViewDTOS){
this.content = (List<T>) articleMyPageViewDTOS;
}
}
Spring에서 제공하는 페이징용 클래스가 있지만 해당 클래스는 사용하지 않는 데이터도 많고, click,load,scroll 이벤트에 따라 프론트단에서 다르게 로직을 동작하도록 하기 위해 페이징용 클래스를 따로 개발하였습니다.
content변수에는 repository단에서 받은 객체를 DTO로 변환후 다시 담을 수 있도록 T 로 제네릭처리를 해주었습니다.
hasNext는 다음 페이징에 값이 있는지를 담는 변수이고, lastArticleId는 마지막 게시글의 id를 담는 변수 입니다.
3. DB에서 가져온 값을 View단에 보여주기 위한 값으로 변환
태그명앞에 #을 붙히고, 좋아요 갯수를 가져오고, 로그인한 유저가 팔로우한 유저인지 확인하기 위한 변수를 선언(더보기 버튼에서 사용), 댓글 갯수 등의 정보를 출력해야 하므로 DB에서 가져온 값을 화면에 출력할 DTO 변환합니다.
4. repository단에서 처리한 동적 쿼리 문
querydsl을 사용하여 동적으로 쿼리문을 처리하였습니다. JPAQueryFactory클래스를 사용하여 querydsl를 작성하면 order by절의 동적 쿼리문을 처리할 때 코드 체이닝 분리가 되지 않기 때문에 JPAQuery를 사용하여 querydsl코드를 작성하였습니다.
JPAQuery를 사용하면 체이닝을 분리할 수 있어 order by절의 동적쿼리문을 처리할 때 가독성을 높혀서 코드를 작성할 수 있습니다.
체이닝을 분리하여 가독성을 높혀 작성한 동적 쿼리문
public ArticlePagingResponse<Article> searchDynamicQueryAndPaging(Long lastArticleId,
ArticleSearchCond searchCond,
ArticleOrderCond orderCond,
int size) {
JPAQuery<Article> query = new JPAQuery<>(em);
생략...
query.select(article)
.from(article)
.join(article.member,member)//article.member는 Article테이블에 있는 member_id, member는 Member테이블에 있는 id라고 생각
.join(article.restaurant, restaurant)//article.restaurant는 Article테이블에 있는 restaurant_id, restaurant는 Restaurant테이블에 있는 id
.fetchJoin()
.where(//게시글 필터링
생략...
);
//정렬 동적 처리
switch(orderCond.getOrderBy()){
case OrderConst.CREATED_DATE_DESC://최신 순으로 정렬
query.where(articleIdLt(lastArticleId))// no-offset 페이징 처리
.orderBy(article.id.desc());
break;
생략...
}
//페이징 처리
List<Article> results = query
.limit(size + 1)
.fetch();//size를 DB에서 받는 것보다 프론트에서 받는게 더 유연할 것같음.fetch();
생략...
}
전체 코드는 너무 길어서 구글 드라이브 링크로 전체 소스코드를 남기겠습니다.
동적쿼리문 전체 소스코드
https://drive.google.com/file/d/14_wDKR5Ic7MYKsO4VnW7RrtuiNXujjpT/view?usp=sharing
동적 쿼리문은 크게 검색조건, 정렬 조건, 페이징 3가지로 나눌 수 있습니다.
1. 검색 조건 동적 처리
querydsl에서 동적쿼리문 처리는 where 문에 null값이 들어갈 경우, 자동으로 null값을 제외하고 조건을 처리하여 where문에 여러 조건이있더라도 조건처리를 편하게 할 수 있습니다.
BooleanBuilder keywordCond = new BooleanBuilder();
keywordCond.or(contentLike(searchCond.getContent()))//글 내용 keyword검색
.or(nickNameLike(searchCond.getWriter()))//작성자(닉네임) keyword검색
.or(nameLike(searchCond.getWriter()))//작성자(이름) keyword검색
.or(tagArticleIn(searchCond.getArticlesByTagValue()))//태그 keyword검색
.or(restaurantNameLike(searchCond.getRestaurantName()));//음식점명 keyword검색
BooleanBuilder statusCond = new BooleanBuilder();
statusCond.or(statusEq(ArticleStatus.NORMAL))// 상태가 NORMAL 게시글들만 출력
.or(statusEq(ArticleStatus.REPORT));// 상태가 REPORT 게시글들만 출력
query.select(article)
.from(article)
.join(article.member,member)//article.member는 Article테이블에 있는 member_id, member는 Member테이블에 있는 id라고 생각
.join(article.restaurant, restaurant)//article.restaurant는 Article테이블에 있는 restaurant_id, restaurant는 Restaurant테이블에 있는 id
.fetchJoin()
.where(//게시글 필터링
blockMemberIdNotIn(searchCond.getBlockedMemberIds()),//차단회원의 게시글 필터링
statusCond//상태조건 필터링
)
.where(// 검색조건들
followMembersIn(searchCond.getFollowMembers()),//팔로우한 유저로 검색
sidoEq(searchCond.getSido()),//시도로 검색
sigoonEq(searchCond.getSigoon()),//시군으로 검색
dongEq(searchCond.getDong()),//동으로 검색
latitudeBetween(searchCond.getLatitude()),//위도로 검색
longitudeBetween(searchCond.getLongitude()),//경도로 검색
categoryEq(searchCond.getCategory()),//음식점 카테고리로 검색
likeArticleIn(searchCond.getLikeArticles()),//좋아요누른 게시판 검색
keywordCond//keyword조건 검색
);
where문에 들어간 메서드들은 null 매개변수로 null값이 들어갔을 경우 null을 리턴하고, null이 아닐경우에만 조건식을 리턴하도록 코드가 작성되어있습니다.
or조건의 경우 or메서드로 체이닝을 채주어야하는데 이 때 null처리 때문에 BooleanBuilder객채에 or메서드를 체이닝 하는 식으로 코드를 작성하였습니다.
2. 정렬 조건 동적 처리
where문과 다르게 orderBy절은 querydsl에서 null처리를 해주지 않기 때문에 switch문을 사용해서 경우의 수에 따라 다르게 코드를 다르게 작성하는 식으로 정렬 조건을 처리하였습니다.
switch(orderCond.getOrderBy()){
case OrderConst.CREATED_DATE_DESC://최신 순으로 정렬
query.where(articleIdLt(lastArticleId))// no-offset 페이징 처리
.orderBy(article.id.desc());
break;
case OrderConst.CREATED_DATE_ASC://오래된 순으로 정렬
query.where(articleIdGt(lastArticleId))
.orderBy(article.id.asc());
break;
case OrderConst.VIEWS_DESC://조회수 순으로 정렬
if (orderCond.getViews() != null) {
orderBuilder.or(
articleViewLt(orderCond.getViews())
).or(
articleViewsEq(orderCond.getViews())
.and(articleIdLoe(lastArticleId))
);
} else {
orderBuilder.and(articleIdLoe(lastArticleId));
}
query.where(orderBuilder)
.orderBy(article.views.desc(),article.id.desc());
break;
case OrderConst.LIKE_COUNT_DESC://좋아요 갯수 순으로 정렬
if (orderCond.getLikeCount() != null) {
orderBuilder.or(
likeArticleCountLt(orderCond.getLikeCount())
).or(
likeArticleCountEq(orderCond.getLikeCount())
.and(articleIdLoe(lastArticleId))
);
} else {
orderBuilder.and(articleIdLoe(lastArticleId));
}
query.leftJoin(article.likes, likeArticle)
.groupBy(article.id)
.having(orderBuilder)
.orderBy(likeArticle.count().desc(),article.id.desc());
break;
case OrderConst.COMMENT_COUNT_DESC://댓글 갯수 순으로 정렬
if (orderCond.getCommentCount() != null) {
orderBuilder.or(
commentCountLt(orderCond.getCommentCount())
).or(
commentCountEq(orderCond.getCommentCount())
.and(articleIdLoe(lastArticleId))
);
} else {
orderBuilder.and(articleIdLoe(lastArticleId));
}
query.leftJoin(article.comments, comment)
.groupBy(article.id)
.having(orderBuilder)
.orderBy(comment.count().desc(),article.id.desc());
break;
default:
throw new IllegalStateException("OrderConst에 정의되어있는 orderBy값 외의 다른 값이 들어왔습니다.");
}
최신 순, 오래된 순은 auto_increment되는 id값만 where조건절에 사용하고, 정렬만하면 되었습니다.
하지만 조회수, 댓글 갯수, 좋아요 갯수는 각각 조회수, 댓글, 좋아요 갯수로 정렬하고, 조회수, 댓글 갯수가 같을 경우에 id값을 기준으로 where조건절을 추가하여 게시글을 가져오도록 코드를 작성하였습니다.
댓글순, 좋아요순은 group by절을 사용하였기 때문에 where조건절 대신, having 절에 조건을 작성해서 정렬을 하였습니다.
1. 최신 순 : 마지막 게시글의 id보다 작은 게시글
SQL으로 표현한 최신순 조건처리
WHERE id < {이전 페이지의 마지막 게시글의 id} ORDER BY id DECS;
2. 오래된 순 : 마지막 게시글의 id 보다 큰 게시글
SQL으로 표현한 오래된 순 조건 처리
WHERE id > {이전 페이지의 마지막 게시글의 id} ORDER BY id ASC;
3. 조회수 순 : 마지막 게시글의 조회수보다 작거나 마지막 게시글의 조회수가 같으면서 id가 작은 게시글
SQL으로 표현한 조회수순 조건처리
WHERE (price < {이전 페이지의 마지막 게시글의 조회수})
OR (price = {이전 페이지의 마지막 게시글의 조회수}
AND id < {이전 페이지의 마지막 게시글의 ID})
ORDER BY views DESC, id DESC
4. 댓글 순 : 마지막 게시글의 댓글수보다 작거나 마지막 게시글의 댓글수와 같으면서 id가 작은 게시글
SQL으로 표현한 댓글 순 조건처리
WHERE (price < {이전 페이지의 마지막 게시글의 조회수})
OR (price = {이전 페이지의 마지막 게시글의 조회수}
AND id < {이전 페이지의 마지막 게시글의 ID})
ORDER BY views DESC, id DESC
5. 좋아요 순 : 마지막 게 글의 좋아요수보다 작거나 마지막 게시글의 좋아요수와 같으면서 id가 작은 게시글
SQL으로 표현한 좋아요 순 조건처리
article a LEFT JOIN comment c ON a.id = c.article_id
GROUP BY a.id
HAVING ( c.count() < {이전 페이지의 마지막 게시글의 댓글수})
OR (c.count() = {이전 페이지의 마지막 게시글의 댓글수}}
AND id < {이전 페이지의 마지막 게시글 ID})
ORDER BY c.count() DESC, a.id DESC
3. 페이징 처리
List<Article> results = query
.limit(size + 1)
.fetch();//size를 DB에서 받는 것보다 프론트에서 받는게 더 유연할 것같음.fetch();
log.info("실행된 쿼리문 = {} ",query.toString());
boolean hasNext = false;
if (results.size() > size) {//결과가 6개이면 size(5)보다 크므로 다음 페이지가 있다는 의미
hasNext = true;
results.remove(size - 1);//다음 페이지 확인을 위하 게시글을 하나더 가져왔으므로 확인 후 삭제
}
return new ArticlePagingResponse<>(results,hasNext);
limt절을 보면 size + 1 하여 쿼리문을 검색 합니다. 이 때 결과가 6개이면 다음 페이가 있다는 의미 이므로 hasNext변수에 true를 넣고 그렇지 않은 경 false 값을 주어 다음 값이 있는지 없는지를 확인합니다.
이렇게 조건들을 처리한 데이터를 프론트단에 출력하는 코드는 front단에 설명하는 쪽에 전체 코드로 링크를 걸어두었습니다.
react, vue없이 javascript로 html코드를 추가하다보니 코드가 난잡해서져 어려움이 많았습니다. 특히 if조건절 처리할 때 어려움이 많았지만 성공적으로 구현하였습니다.
javascript로 html추가하는 소스코드 일부분
let articleHtml = ``;
articleHtml += `<li class="article-wrap article-wrap${article.id}" data-articleid=${article.id}>
<!-- 글쓴이 정보 -->
<div class="writerInfo">
<a class="writerProfileImg" href="/members/${article.memberId}">`
if(article.profileImg == null || article.profileImg === ""){
articleHtml += `<img src="/img/profileImg/default_profile.png" alt="프로필 이미지">`
}else if(article.profileImg.includes("http")){
articleHtml += `<img src="${article.profileImg}" alt="프로필 이미지">`
}else{
articleHtml += `<img src="/upload/${article.profileImg}" alt="프로필 이미지">`
}
articleHtml +=`</a>
<div class="user-info">
<a class="writer-name" href="/members/${article.memberId}">
<span class="writerNickName">${article.nickName}</span>
(<span class="writerName">${article.name}</span>)
</a>
<span class="writtenAt">${article.date}</span>
</div>
</div>
<!-- 글 본문 -->
<div class="contentWrap contentWrap${article.id}" onclick="location.href='/articles/${article.id}'">`
if(article.image.length ===1){
articleHtml+= `<ul class="bImg img-length-1">`
}else if(article.image.length ===2){
articleHtml+= `<ul class="bImg img-length-2">`
}else if(article.image.length ===3){
articleHtml+= `<ul class="bImg img-length-3">`
}else {
articleHtml+= `<ul class="bImg img-length-4">`
}
// 이미지 처리
'Project > Maturi' 카테고리의 다른 글
[MATURI] 게시글 작성/수정 상세설명 (0) | 2023.05.08 |
---|---|
[MATURI] 검색 조건들 상세설명(카테고리, 팔로우, 관심지역...) (0) | 2023.05.08 |
[MATURI] 사이드 네비게이션 상세설명 (1) | 2023.05.08 |
[MATURI] 회원가입 기능 상세설명 (1) | 2023.05.08 |
[MATURI] 로그인 기능 상세 설명 (1) | 2023.05.07 |