본문 바로가기
Project/깃터디 (gitudy)

[깃터디/Todo] Todo 조회 api 구현

by J-rain 2024. 5. 11.

💡 .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번째 페이지에 진입할 경우를 생각하며 구현해야한다 😥

 

StudyTodoRepositoryImpl.java

@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 설정

 

 

StudyTodoResponse.java

@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 설정

 

 

StudyTodoService.java

    // 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

 

 

StudyTodoController.java

    // 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")는 최소 한 개 이상의 항목이 요청되어야 함을 보장

 

 

StudyMemberService.java

    // 스터디 멤버인지 검증
    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);
        }
    }

해당 스터디의 멤버인지 검증 메서드 추가

 


테스트

 

StudyTodoControllerTest.java

    @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 요청을 올바르게 처리하고 적절한 응답을 반환하는지 확인)

 

 

StudyTodoFixture.java

 //테스트용 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 설정)

 

 

StudyTodoServiceTest.java

    @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만 조회 확인 테스트

댓글