💡 .java를 클릭시 관련 커밋으로 이동💡
회고
비즈니스 로직을 구현할때 그에 맞는 컨트롤러, 서비스 구현도 중요하지만 테스트를 작성하는 과정이 중요하다고 느낀다. CRUD에서 R은 유저에게 보여줘야할 정보들이기 때문에 특히나 테스트가 더욱 중요하다고 느낀다.
@Override
public List<StudyTodoResponse> findStudyTodoListByStudyInfoId(Long studyInfoId, Long cursorIdx, Long limit) {
// Querydsl 쿼리 생성
JPAQuery<StudyTodoResponse> query = queryFactory
.select(Projections.constructor(StudyTodoResponse.class,
studyTodo.id,
studyTodo.studyInfoId,
studyTodo.title,
studyTodo.detail,
studyTodo.todoLink,
studyTodo.todoDate))
.from(studyTodo)
.where(studyTodo.studyInfoId.eq(studyInfoId))
.orderBy(studyTodo.id.desc()); // to do Id가 큰값부터 내림차순 (최신항목순)
// cursorIdx가 null이 아닐때 해당 ID 이하의 데이터를 조회
if (cursorIdx != null) {
query = query.where(studyTodo.id.lt(cursorIdx));
}
// 정해진 limit만큼 데이터를 가져온다
return query.limit(limit)
.fetch();
}
페이지네이션중에서 커서 기반 페이지네이션 (Cursor-based Pagination) 를 사용하여 구현했다.
페이지 네이션이란 전체 데이터에서 지정된 갯수만 데이터를 전달하는 방법을 말하며 이를 통해 필요한 데이터만 주고 받으므로 네트워크의 오버헤드를 줄일 수 있다는 장점이 있다!
- Cursor 개념을 사용한다.
- Cursor란 사용자에게 응답해준 마지막의 데이터의 식별자 값이 Cursor가 된다.
- 해당 Cursor를 기준으로 다음 n개의 데이터를 응답해주는 방식이다.
쉽게 말로 비교하면
- 오프셋 기반 방식
- 1억번~1억+10번 데이터 주세요. 라고 한다면 → 1억+10번개의 데이터를 읽음
- 커서 기반 방식
- 마지막으로 읽은 데이터(1억번)의 다음 데이터(1억+1번) 부터 10개의 데이터 주세요→ 10개의 데이터만 읽음
그러므로 어떤 페이지를 조회하든 항상 원하는 데이터 개수만큼만 읽기 때문에
성능상 이점이 존재한다!! 추가적으로 첫 페이지 진입했을 때 (즉, cursor가 null 값일 때) 와 n번째 페이지에 진입할 경우를 생각하며 구현해야한다 😥
@Component
@RequiredArgsConstructor
public class StudyTodoRepositoryImpl implements StudyTodoRepositoryCustom {
private final JPAQueryFactory queryFactory;
@Override
public List<StudyTodoResponse> findStudyTodoListByStudyInfoId(Long studyInfoId, Long cursorIdx, Long limit) {
// Querydsl 쿼리 생성
JPAQuery<StudyTodoResponse> query = queryFactory
.select(Projections.constructor(StudyTodoResponse.class,
studyTodo.id,
studyTodo.studyInfoId,
studyTodo.title,
studyTodo.detail,
studyTodo.todoLink,
studyTodo.todoDate))
.from(studyTodo)
.where(studyTodo.studyInfoId.eq(studyInfoId))
.orderBy(studyTodo.id.desc()); // to do Id가 큰값부터 내림차순 (최신항목순)
// cursorIdx가 null이 아닐때 해당 ID 이하의 데이터를 조회
if (cursorIdx != null) {
query = query.where(studyTodo.id.lt(cursorIdx));
}
// 정해진 limit만큼 데이터를 가져온다
return query.limit(limit)
.fetch();
}
}
todoId가 큰값부터 내림차순(최신항목순)으로 limit대로 가져옴
StudyTodoListAndCursorIdxResponse.java
@AllArgsConstructor
@Getter
@Builder
public class StudyTodoListAndCursorIdxResponse {
private List<StudyTodoResponse> todoList; // To do 정보
private Long cursorIdx; // 다음위치 커서
public void setNextCursorIdx() {
cursorIdx = todoList == null || todoList.isEmpty() ?
0L : todoList.get(todoList.size() - 1).getId();
}
}
todo정보 리스트와 프론트에 전달할 다음 cursorIdx DTO 설정
@Getter
@Builder
@AllArgsConstructor
public class StudyTodoResponse {
private Long id; // to doid
private Long studyInfoId; // 스터티 Id
@Size(max = 20, message = "제목 20자 이내")
private String title; // To do 이름
@Size(max = 50, message = "설명 50자 이내")
private String detail; // To do 설명
private String todoLink; // To do 링크
private LocalDate todoDate; // To do 날짜
}
유저에게 보여줄 todo 정보 DTO 설정
// Todo 전체조회
public StudyTodoListAndCursorIdxResponse readStudyTodoList(Long studyInfoId, Long cursorIdx, Long limit) {
// 스터디 조회 예외처리
studyInfoRepository.findById(studyInfoId).orElseThrow(() -> {
log.warn(">>>> {} : {} <<<<", studyInfoId, ExceptionMessage.STUDY_INFO_NOT_FOUND);
return new TodoException(ExceptionMessage.STUDY_INFO_NOT_FOUND);
});
limit = Math.min(limit, MAX_LIMIT);
List<StudyTodoResponse> studyTodoList = studyTodoRepository.findStudyTodoListByStudyInfoId_CursorPaging(studyInfoId, cursorIdx, limit);
StudyTodoListAndCursorIdxResponse response = StudyTodoListAndCursorIdxResponse.builder()
.todoList(studyTodoList)
.build();
// 다음 페이지 조회를 위한 cursorIdx 설정
response.setNextCursorIdx();
return response;
}
Todo 전체조회 Service
// Todo 전체조회
@ApiResponse(responseCode = "200", description = "Todo 전체조회 성공", content = @Content(schema = @Schema(implementation = StudyTodoListAndCursorIdxResponse.class)))
@GetMapping("/{studyInfoId}/todo")
public JsonResult<?> readStudyTodo(@AuthenticationPrincipal User user,
@PathVariable(name = "studyInfoId") Long studyInfoId,
@Min(value = 0, message = "Cursor index cannot be negative") @RequestParam(name = "cursorIdx") Long cursorIdx,
@Min(value = 1, message = "Limit cannot be less than 1") @RequestParam(name = "limit", defaultValue = "3") Long limit) {
// 스터디 멤버인지 검증
studyMemberService.isValidateStudyMember(user, studyInfoId);
return JsonResult.successOf(studyTodoService.readStudyTodoList(studyInfoId, cursorIdx, limit));
Todo 전체조회 Controller
@Min(value = 0, message = "Cursor index cannot be negative") 으로 값이 음수가 될 수 없음을 검증
@Min(value = 1, message = "Limit cannot be less than 1")는 최소 한 개 이상의 항목이 요청되어야 함을 보장
// 스터디 멤버인지 검증
public void isValidateStudyMember(User userPrincipal, Long studyInfoId) {
// platformId와 platformType을 이용하여 User 객체 조회
User user = userRepository.findByPlatformIdAndPlatformType(userPrincipal.getPlatformId(), userPrincipal.getPlatformType()).orElseThrow(() -> {
log.warn(">>>> {},{} : {} <<<<", userPrincipal.getPlatformId(), userPrincipal.getPlatformType(), ExceptionMessage.USER_NOT_FOUND);
return new UserException(ExceptionMessage.USER_NOT_FOUND);
});
// 스터디 멤버인지확인
if (!studyMemberRepository.existsStudyMemberByUserIdAndStudyInfoId(user.getId(), studyInfoId)) {
throw new MemberException(ExceptionMessage.STUDY_NOT_MEMBER);
}
}
해당 스터디의 멤버인지 검증 메서드 추가
테스트
@Test
public void Todo_전체조회_테스트() throws Exception {
// given
User savedUser = userRepository.save(generateAuthUser());
Map<String, String> map = TokenUtil.createTokenMap(savedUser);
String accessToken = jwtService.generateAccessToken(map, savedUser);
String refreshToken = jwtService.generateRefreshToken(map, savedUser);
StudyInfo studyInfo = StudyInfoFixture.createDefaultPublicStudyInfo(savedUser.getId());
studyInfoRepository.save(studyInfo);
StudyTodoListAndCursorIdxResponse response = StudyTodoListAndCursorIdxResponse.builder()
.todoList(new ArrayList<>()) // 비어 있는 Todo 리스트
.build();
response.setNextCursorIdx();
doNothing().when(studyMemberService).isValidateStudyLeader(any(User.class), any(Long.class));
when(studyTodoService.readStudyTodoList(any(Long.class), any(Long.class), any(Long.class))).thenReturn(response);
// when
mockMvc.perform(get("/study/" + studyInfo.getId() + "/todo")
.contentType(MediaType.APPLICATION_JSON)
.header(AUTHORIZATION, createAuthorizationHeader(accessToken, refreshToken))
.param("cursorIdx", "1")
.param("limit", "3"))
// then
.andExpect(status().isOk())
.andExpect(jsonPath("$.res_code").value(200))
.andExpect(jsonPath("$.res_msg").value("OK"))
.andExpect(jsonPath("$.res_obj").isNotEmpty())
.andDo(print());
}
컨트롤러 동작 검증 ( HTTP 요청을 올바르게 처리하고 적절한 응답을 반환하는지 확인)
//테스트용 studyTodo List 생성
public static StudyTodo createStudyTodoList(Long studyInfoId, String title, String detail, String todoLink, LocalDate todoDate) {
return StudyTodo.builder()
.studyInfoId(studyInfoId)
.title(title)
.detail(detail)
.todoLink(todoLink)
.todoDate(todoDate)
.build();
}
// 테스트용 studyTodo List 제목+스터디Id
// StudyTodo 객체를 생성하는 메서드에 상세 설명을 추가하여 오버로드
public static StudyTodo createStudyTodoWithTitle(Long studyInfoId, String title) {
return StudyTodo.builder()
.studyInfoId(studyInfoId)
.title(title) // 테스트를 위한 기본 값 설정
.detail(expectedDetail) // 파라미터로 전달받은 상세 설명 사용
.todoLink(expectedTodoLink) // 테스트를 위한 기본 값 설정
.todoDate(expectedTodoDate) // 현재 날짜로 설정
.build();
}
테스트용 객체 사전 설정(Fixture 설정)
@Test
@DisplayName("Todo 전체조회 테스트")
void readTodoList_Success() {
// given
Random random = new Random();
Long cursorIdx = Math.abs(random.nextLong()) + Limit; // Limit 이상 랜덤값
User leader = userRepository.save(generateAuthUser());
StudyInfo studyInfo = StudyInfoFixture.createDefaultPublicStudyInfo(leader.getId());
studyInfoRepository.save(studyInfo);
// 스터디장 To do 생성
StudyTodo studyTodo1 = StudyTodoFixture.createStudyTodo(studyInfo.getId());
StudyTodo studyTodo2 = StudyTodoFixture.createStudyTodo(studyInfo.getId());
StudyTodo studyTodo3 = StudyTodoFixture.createStudyTodo(studyInfo.getId());
StudyTodo studyTodo4 = StudyTodoFixture.createStudyTodo(studyInfo.getId());
studyTodoRepository.saveAll(List.of(studyTodo1, studyTodo2, studyTodo3, studyTodo4));
// when
StudyTodoListAndCursorIdxResponse responses = studyTodoService.readStudyTodoList(studyInfo.getId(), cursorIdx, Limit);
// then
assertNotNull(responses);
assertEquals(3, responses.getTodoList().size());
assertEquals(studyTodo1.getTitle(), responses.getTodoList().get(0).getTitle());
assertEquals(studyTodo2.getTitle(), responses.getTodoList().get(1).getTitle());
}
@Test
@DisplayName("Todo 전체조회 커서 기반 페이징 로직 검증")
void readTodoList_CursorPaging_Success() {
// given
User leader = userRepository.save(generateAuthUser());
StudyInfo studyInfo = StudyInfoFixture.createDefaultPublicStudyInfo(leader.getId());
studyInfoRepository.save(studyInfo);
// 7개의 스터디 To do 생성 및 저장
List<StudyTodo> createdTodos = new ArrayList<>();
for (int i = 0; i < 7; i++) {
createdTodos.add(StudyTodoFixture.createStudyTodo(studyInfo.getId()));
}
studyTodoRepository.saveAll(createdTodos);
// when
StudyTodoListAndCursorIdxResponse firstPageResponse = studyTodoService.readStudyTodoList(studyInfo.getId(), CursorIdx, Limit);
// then
assertNotNull(firstPageResponse);
assertEquals(3, firstPageResponse.getTodoList().size()); // 3개만 가져와야함
// when
// 새로운 커서 인덱스를 사용하여 다음 페이지 조회
Long newCursorIdx = firstPageResponse.getCursorIdx();
StudyTodoListAndCursorIdxResponse secondPageResponse = studyTodoService.readStudyTodoList(studyInfo.getId(), newCursorIdx, Limit);
// then
// 두 번째 페이지의 데이터 검증
assertNotNull(secondPageResponse);
assertEquals(3, secondPageResponse.getTodoList().size());
// when
// 새로운 커서 인덱스를 사용하여 다음 페이지 조회
Long newCursorIdx2 = secondPageResponse.getCursorIdx();
StudyTodoListAndCursorIdxResponse thirdPageResponse = studyTodoService.readStudyTodoList(studyInfo.getId(), newCursorIdx2, Limit);
// then
// 세 번째 페이지의 데이터 검증
assertNotNull(thirdPageResponse);
assertEquals(1, thirdPageResponse.getTodoList().size());
}
@Test
@DisplayName("다른 스터디의 Todo와 섞여있을 때, 특정 스터디의 Todo만 조회 확인 테스트")
void readTodoList_difStudy() {
// given
User leader1 = userRepository.save(generateAuthUser());
StudyInfo studyInfo1 = StudyInfoFixture.createDefaultPublicStudyInfo(leader1.getId());
studyInfoRepository.save(studyInfo1);
User leader2 = userRepository.save(generateGoogleUser());
StudyInfo studyInfo2 = StudyInfoFixture.createDefaultPublicStudyInfo(leader2.getId());
studyInfoRepository.save(studyInfo2);
// 1번 스터디와 2번 스터디의 To do 생성 저장
List<StudyTodo> createdStudy1Todos = new ArrayList<>();
List<StudyTodo> createdStudy2Todos = new ArrayList<>();
for (int i = 0; i < 5; i++) {
createdStudy1Todos.add(StudyTodoFixture.createStudyTodoWithTitle(studyInfo1.getId(), "1번 투두 제목" + i));
createdStudy2Todos.add(StudyTodoFixture.createStudyTodoWithTitle(studyInfo2.getId(), "2번 투두 제목" + i));
}
// 두 리스트 합치기
List<StudyTodo> allTodos = new ArrayList<>();
allTodos.addAll(createdStudy1Todos);
allTodos.addAll(createdStudy2Todos);
// 합친 리스트 저장
studyTodoRepository.saveAll(allTodos);
// when
StudyTodoListAndCursorIdxResponse responseForStudy1 = studyTodoService.readStudyTodoList(studyInfo1.getId(), CursorIdx, Limit);
// then
assertNotNull(responseForStudy1);
assertEquals(Limit.intValue(), responseForStudy1.getTodoList().size());
responseForStudy1.getTodoList().forEach(todo ->
assertTrue(todo.getTitle().contains("1번 투두 제목"), "모든 투두 항목은 '1번 투두 제목' 을 포함해야 한다"));
// when
StudyTodoListAndCursorIdxResponse responseForStudy2 = studyTodoService.readStudyTodoList(studyInfo2.getId(), CursorIdx, Limit);
// then
assertNotNull(responseForStudy2);
assertEquals(Limit.intValue(), responseForStudy2.getTodoList().size());
responseForStudy2.getTodoList().forEach(todo ->
assertTrue(todo.getTitle().contains("2번 투두 제목"), "모든 투두 항목은 '2번 투두 제목' 을 포함해야 한다"));
}
Todo 전체조회 테스트 ,
Todo 전체조회 커서 기반 페이징 로직 검증 테스트,
다른 스터디의 Todo와 섞여있을 때, 특정 스터디의 Todo만 조회 확인 테스트
'Project > 깃터디 (gitudy)' 카테고리의 다른 글
[깃터디/Todo] Todo 삭제 api 구현 (0) | 2024.05.11 |
---|---|
[깃터디/Todo] Todo 수정 api 구현 (0) | 2024.05.11 |
[깃터디/Todo] Todo 등록 api 구현 (0) | 2024.05.11 |
[깃터디/Auth] 닉네임 중복검사 api 구현 (0) | 2024.05.10 |
[깃터디/Auth] 회원 정보 조회 구현 (0) | 2024.05.10 |
댓글