Scrum을 검색하면서 페이지네이션을 도입해야 했습니다.
검색 조건은 아래의 2가지 조건이 존재한다고 가정했습니다.
1. 스크럼 제목으로 검색
2. 리더의 닉네임으로 검색
둘 중 하나의 값만 들어와야 하며, 둘 다 공백 or Null로 들어오는 경우 클라이언트 에러를 리턴합니다.
가장 먼저 고민했던 점은 검색어를 어떤 방식으로 전달하냐의 문제가 발생했습니다.
1. 검색어를 통한 조회니깐 GET을 통해 url에 쿼리스트링으로 전달하자.
2. POST로 검색 타입, 데이터를 DTO로 받아서 처리하기.
처음에는 당연히 검색 조회니깐 1번을 선택하려고 했습니다. 그런데 2번도 불가능하지는 않는 방법이라 생각했고, 왜 사용을 안하는지 궁금했습니다.
REST API에서 POST 메서드는 데이터의 생성, 변화를 일으키는 경우 사용했었습니다. 사실 메서드의 의미를 꼭 지키지는 않아도 되지만 지켜야 Restful한 API를 설계할 수 있기에 여태 의미를 지켰습니다.
그러면 RestAPI의 의미를 헤치고 POST로 검색 요청을 해서 검색 조건, 데이터를 RequestBody로 받아도 되지 않나? 라는 생각을 가지게 되었습니다.
물론 POST를 사용해서 검색 API를 구현해도 됩니다. 그러나 GET을 사용하는 이유는 GET이 제공하는 캐싱 기능을 활용하기 위해 사용합니다.
GET 메서드는 결과 값을 캐싱해서 브라우저에 저장해놓게 됩니다. 이후 동일한 요청이 들어오고 값이 변하지 않는다면 캐싱된 값을 그대로 전달해주기에 리소스를 조금이라도 아낄 수 있게 됩니다!
근데 여기서 또 한가지 고민이 생겼는데 결국 GET을 이용해서 검색 결과를 캐싱해놓고 2시간 뒤에 다시 검색을 요청하면 현재 캐싱된 값 vs 검색 값을 비교해서 동일한지 판단해야하지 않나? 라는 생각이 들었습니다.
그러면 결국 DB에서 조회를 통해 값을 한번 가져와서 비교해야 하는 거 아닌가? 이러면 리소스가 들어가는 건데 캐싱을 이용하는 게 맞나? 라는 의문이 들었습니다.
아직 위 고민은 명확한 답이 나오지 않았습니다. 추후에 답이 나오면 정리하겠습니다.
Controller
@GetMapping("team/{team_id}/scrum/search")
public ResponseEntity<ApiResponse<?>> findScrumList(
@CookieValue(name = "accessToken", required = false) String accessToken,
@RequestParam(name = "type")
@NotBlank(message = "검색 조건은 필수입니다.")
@Size(max = 10, message = "type은 최대 10자까지 가능합니다.") String type,
@RequestParam(name = "key")
@NotBlank(message = "검색어는 필수입니다.")
@Size(max = 10, message = "검색어는 최대 10자까지 가능합니다.") String key,
@RequestParam(defaultValue = "0", name = "page") int page,
@PathVariable(name = "team_id") @NotNull(message = "팀 아이디는 필수입니다.") Long teamId){
if(!"leaderName".equals(type) && !"title".equals(type)){
return ResponseEntity.status(404).body(ApiResponse.createClientError("검색 조건이 잘못되었습니다."));
}
Pageable pageable = PageRequest.of(page, 6);
ScrumPageResponseDTO responseDTO = scrumService.searchScrum(accessToken, type, key, teamId, pageable);
return ResponseEntity.status(200).body(ApiResponse.createSuccess(responseDTO, "검색 스크럼 목록"));
}
type으로 어떤 검색 조건이 들어오는지, key는 검색 요청 값이 들어오는지를 받습니다.
type은 프론트에서 드롭메뉴로 구현 예정이라 검색 조건이 leaderName(리더 닉네임), title(스크럼 제목)으로 고정입니다.
그래서 그 외의 값들은 전부 404 에러를 던져주었습니다.
또한 Pageable 인터페이스를 사용하였습니다.
Spring에서 제공하는 인터페이스로 구현체인 PageRequest를 사용했습니다.
현재 검색하려고 하는 page를 넘겨주고, 하나의 페이지에 담을 사이즈를 6개로 정했습니다.
QueryDsl
@Override
public Page<Scrum> searchScrumWithPagination(String type, String key, Pageable pageable) {
BooleanExpression searchCondition = createSearchCondition(type, key);
List<Scrum> scrums = queryFactory
.selectFrom(scrum)
.where(searchCondition)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
long total = queryFactory
.selectFrom(scrum)
.where(searchCondition)
.fetchCount();
return new PageImpl<>(scrums, pageable, total);
}
// 제목 판단(검색)
private BooleanExpression titleContains(String title){
if(title == null || title.trim().isEmpty()){
return null;
}
return scrum.name.containsIgnoreCase(title);
}
// 리더 닉네임 판단(검색)
private BooleanExpression leaderNameContains(String leaderName){
if(leaderName == null || leaderName.trim().isEmpty()){
return null;
}
return scrum.user.nickname.containsIgnoreCase(leaderName);
}
// 검색 조건 쿼리 생성
private BooleanExpression createSearchCondition(String type, String key){
if("leaderName".equals(type)){
return leaderNameContains(key);
}else if("title".equals(type)){
return titleContains(key);
}
return null;
}
우선 BooleanExpression을 사용했습니다.
WHERE 조건에 BooleanExpression을 넣어두고 해당 값이 Null이면 조건이 제거된다는 유용한 기능을 위해 사용하였습니다.
createSearchCondition을 통해서 해당 조건에 맞는 쿼리를 생성해주었습니다.
List<Scrum> scrums = queryFactory
.selectFrom(scrum)
.where(searchCondition)
.offset(pageable.getOffset())
.limit(pageable.getPageSize())
.fetch();
그 결과를 WHERE절에 넣고 조건에 맞는 스크럼을 찾아주었습니다.
이때 offset과 limit이 사용됩니다.
offset : 어떤 정보부터 검색을 시작하는지에 대한 기준(?)
limit : 검색할 데이터의 사이즈를 정의
저같은 경우는 검색할 페이지와 6개라는 데이터 사이즈를 정해주었기에 그 데이터를 기준으로 검색 데이터를 리턴합니다.
long total = queryFactory
.selectFrom(scrum)
.where(searchCondition)
.fetchCount();
return new PageImpl<>(scrums, pageable, total);
이후 일치하는 총 데이터의 개수를 파악하기 위해 total을 구한 다음에 PageImpl이라는 구현체를 생성해서 리턴시킵니다.
테스트 결과
1. 타입이 없는 경우
2. 타입을 잘못 입력하는 경우
3. 키 값이 없는 경우
4. 길이를 초과하는 경우
5. 정상 데이터 리턴
현재 페이지, 토탈 페이지, 토탈 데이터 개수, 6개의 데이터 리스트를 담아서 리턴에 성공합니다.
해당 검색 API를 구현하면서 고민이 하나 생겼습니다.
저렇게 쿼리스트링으로 데이터를 보내면 SQLInjection 공격에 취약하지 않을까? 라는 생각도 들었습니다.
임시로 사이즈 제한을 두었지만, 혹시나 단순 조건으로도 쿼리 조작이 가능한지에 대한 경우는 조금 생각해봐야 할 것 같습니다.
'프로젝트 > 씈크럼 프로젝트' 카테고리의 다른 글
내가 원하는 컬럼만 업데이트 쿼리가 나가고싶은데..(feat, DynamicUpdate) (0) | 2024.02.22 |
---|---|
공통으로 가지는 생성시간, 수정시간을 상속으로 이용해보자. (0) | 2024.02.19 |
QueryDSL 도입기 (0) | 2024.02.17 |
쿼리 메서드 사용으로 인한 성능 개선 (0) | 2024.02.09 |