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

[깃터디/Convention] 컨벤션 조회 api 구현

by J-rain 2024. 5. 11.

💡 .java를 클릭시 관련 커밋으로 이동💡

 

 회고 

비즈니스 로직을 구현할때 그에 맞는 컨트롤러, 서비스 구현도 중요하지만 테스트를 작성하는 과정이 중요하다고 느낀다. CRUD에서 R은 유저에게 보여줘야할 정보들이기 때문에 특히나 테스트가 더욱 중요하다고 느낀다.

    // StudyInfoId로 Convention 전체 가져오기
    List<StudyConventionResponse> findStudyConventionListByStudyInfoId_CursorPaging(Long studyInfoId, Long cursorIdx, Long limit);

페이지네이션중에서 커서 기반 페이지네이션 (Cursor-based Pagination) 를 사용하여 구현했다.

페이지 네이션이란 전체 데이터에서 지정된 갯수만 데이터를 전달하는 방법을 말하며 이를 통해 필요한 데이터만 주고 받으므로 네트워크의 오버헤드를 줄일 수 있다는 장점이 있다!

  • Cursor 개념을 사용한다.
  • Cursor란 사용자에게 응답해준 마지막의 데이터의 식별자 값이 Cursor가 된다.
  • 해당 Cursor를 기준으로 다음 n개의 데이터를 응답해주는 방식이다.

쉽게 말로 비교하면

  • 오프셋 기반 방식
    • 1억번~1억+10번 데이터 주세요. 라고 한다면 → 1억+10번개의 데이터를 읽음
  • 커서 기반 방식
    • 마지막으로 읽은 데이터(1억번)의 다음 데이터(1억+1번) 부터 10개의 데이터 주세요→ 10개의 데이터만 읽음

그러므로 어떤 페이지를 조회하든 항상 원하는 데이터 개수만큼만 읽기 때문에

성능상 이점이 존재한다!! 추가적으로 첫 페이지 진입했을 때 (즉, cursor가 null 값일 때) 와 n번째 페이지에 진입할 경우를 생각하며 구현해야한다 😥

 

StudyConventionRepositoryCustom.java

public interface StudyConventionRepositoryCustom {

    // StudyInfoId로 Convention 전체 가져오기
    List<StudyConventionResponse> findStudyConventionListByStudyInfoId_CursorPaging(Long studyInfoId, Long cursorIdx, Long limit);
}

 

 

StudyConventionRepositoryImpl.java

@Component
@RequiredArgsConstructor
public class StudyConventionRepositoryImpl implements StudyConventionRepositoryCustom {
    private final JPAQueryFactory queryFactory;

    @Override
    public List<StudyConventionResponse> findStudyConventionListByStudyInfoId_CursorPaging(Long studyInfoId, Long cursorIdx, Long limit) {

        JPAQuery<StudyConventionResponse> query = queryFactory
                .select(Projections.constructor(StudyConventionResponse.class,
                        studyConvention.id,
                        studyConvention.studyInfoId,
                        studyConvention.name,
                        studyConvention.description,
                        studyConvention.content,
                        studyConvention.isActive))
                .from(studyConvention)
                .where(studyConvention.studyInfoId.eq(studyInfoId))
                .orderBy(studyConvention.id.desc()); // 컨벤션 Id가 큰값부터 내림차순 (최신항목순)

        if (cursorIdx != null) {
            query = query.where(studyConvention.id.lt(cursorIdx));
        }

        return query.limit(limit)
                .fetch();
    }

}

커서 기반 페이지 네이션을 사용하여QueryDsl로 구현했다.

 

 

StudyConventionController.java

    @ApiResponse(responseCode = "200", description = "컨벤션 조회 성공", content = @Content(schema = @Schema(implementation = StudyConventionResponse.class)))
    @GetMapping("/{studyInfoId}/convention/{conventionId}")
    public JsonResult<?> readStudyConvention(@AuthenticationPrincipal User user,
                                             @PathVariable(name = "studyInfoId") Long studyInfoId,
                                             @PathVariable(name = "conventionId") Long conventionId) {

        studyMemberService.isValidateStudyMember(user, studyInfoId);

        return JsonResult.successOf(studyConventionService.readStudyConvention(conventionId));
    }

    @ApiResponse(responseCode = "200", description = "컨벤션 전체조회 성공", content = @Content(schema = @Schema(implementation = StudyConventionListAndCursorIdxResponse.class)))
    @GetMapping("/{studyInfoId}/convention")
    public JsonResult<?> readStudyConventionList(@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 = "4") Long limit) {

        studyMemberService.isValidateStudyMember(user, studyInfoId);

        return JsonResult.successOf(studyConventionService.readStudyConventionList(studyInfoId, cursorIdx, limit));
    }

컨벤션 조회 Controller 구현 단일 조회와 전체조회를 나눈 이유는 스터디를 소개하는 페이지에는 컨벤션이 하나만 들어가기때문에 단일 조회도 있어야겠다고 생각했다!

 

 

StudyConventionListAndCursorIdxResponse.java

@AllArgsConstructor
@Getter
@Builder
public class StudyConventionListAndCursorIdxResponse {

    private List<StudyConventionResponse> studyConventionList; // 컨벤션 정보

    private Long cursorIdx; // 다음위치 커서

    public void setNextCursorIdx() {
        cursorIdx = studyConventionList == null || studyConventionList.isEmpty() ?
                0L : studyConventionList.get(studyConventionList.size() - 1).getConventionId();
    }

}

프론트에게 전달할 cursorIdx를 포함한 DTO 이다.

 

 

StudyConventionResponse.java

@Getter
@AllArgsConstructor
@Builder
@NoArgsConstructor
public class StudyConventionResponse {

    private Long conventionId; // 컨벤션 Id

    private Long studyInfoId;  // 스터디 Id

    private String name;   // 컨벤션 이름

    private String description; // 컨벤션 설명

    private String content;  // 컨벤션 내용(정규식)

    private boolean active; // 컨벤션 적용 여부

    public static StudyConventionResponse of(StudyConvention studyConvention) {
        return StudyConventionResponse.builder()
                .conventionId(studyConvention.getId())
                .studyInfoId(studyConvention.getStudyInfoId())
                .name(studyConvention.getName())
                .description(studyConvention.getDescription())
                .content(studyConvention.getContent())
                .active(studyConvention.isActive())
                .build();
    }

}

컨벤션을 보여줄 DTO이다.

 

 

StudyConventionService.java

    // 컨벤션 단일 조회
    public StudyConventionResponse readStudyConvention(Long conventionId) {

        // Convention 조회
        StudyConvention studyConvention = studyConventionRepository.findById(conventionId).orElseThrow(() -> {
            log.warn(">>>> {} : {} <<<<", conventionId, ExceptionMessage.CONVENTION_NOT_FOUND.getText());
            return new ConventionException(ExceptionMessage.CONVENTION_NOT_FOUND);
        });

        return StudyConventionResponse.of(studyConvention);
    }

    // 컨벤션 전체 조회
    public StudyConventionListAndCursorIdxResponse readStudyConventionList(Long studyInfoId, Long cursorIdx, Long limit) {

        // 스터디 조회 예외처리
        studyInfoRepository.findById(studyInfoId).orElseThrow(() -> {
            log.warn(">>>> {} : {} <<<<", studyInfoId, ExceptionMessage.STUDY_INFO_NOT_FOUND);
            return new ConventionException(ExceptionMessage.STUDY_INFO_NOT_FOUND);
        });

        limit = Math.min(limit, MAX_LIMIT);

        List<StudyConventionResponse> studyConventionList = studyConventionRepository.findStudyConventionListByStudyInfoId_CursorPaging(studyInfoId, cursorIdx, limit);

        StudyConventionListAndCursorIdxResponse response = StudyConventionListAndCursorIdxResponse.builder()
                .studyConventionList(studyConventionList)
                .build();

        response.setNextCursorIdx();

        return response;

    }

단일 조회와 전체조회에 대한 Service단 api 구현이다.

 


테스트

 

StudyConventionFixture.java

    // 테스트용 StudyConvention
    public static StudyConvention createStudyConventionName(Long studyInfoId, String name) {
        return StudyConvention.builder()
                .studyInfoId(studyInfoId)
                .name(name)
                .description("설명")
                .content("정규식")
                .isActive(true)
                .build();
    }

테스트용 컨벤션 생성 Fixture이다.

 

 

StudyConventionRepositoryTest.java

    @Test
    void 컨벤션_커서_기반_페이지_조회_테스트() {
        //given
        Random random = new Random();
        Long cursorIdx = Math.abs(random.nextLong()) + LIMIT;  // Limit 이상 랜덤값

        StudyConvention studyConvention1 = StudyConventionFixture.createStudyConventionName(1L, "1번째 컨벤션");
        StudyConvention studyConvention2 = StudyConventionFixture.createStudyConventionName(1L, "2번째 컨벤션");
        StudyConvention studyConvention3 = StudyConventionFixture.createStudyConventionName(1L, "3번째 컨벤션");
        StudyConvention studyConvention4 = StudyConventionFixture.createStudyConventionName(1L, "4번째 컨벤션");
        studyConventionRepository.saveAll(List.of(studyConvention1, studyConvention2, studyConvention3, studyConvention4));

        // when
        List<StudyConventionResponse> studyConventionInfoList = studyConventionRepository.findStudyConventionListByStudyInfoId_CursorPaging(1L, cursorIdx, LIMIT);

        // then
        for (StudyConventionResponse convention : studyConventionInfoList) {
            assertTrue(convention.getConventionId() < cursorIdx);
        }
    }

    @Test
    void 커서가_null일_경우_컨벤션_조회_테스트() {
        //given
        StudyConvention studyConvention1 = StudyConventionFixture.createStudyConventionName(1L, "1번째 컨벤션");
        StudyConvention studyConvention2 = StudyConventionFixture.createStudyConventionName(1L, "2번째 컨벤션");
        StudyConvention studyConvention3 = StudyConventionFixture.createStudyConventionName(1L, "3번째 컨벤션");
        StudyConvention studyConvention4 = StudyConventionFixture.createStudyConventionName(1L, "4번째 컨벤션");
        studyConventionRepository.saveAll(List.of(studyConvention1, studyConvention2, studyConvention3, studyConvention4));

        // when
        List<StudyConventionResponse> studyConventionInfoList = studyConventionRepository.findStudyConventionListByStudyInfoId_CursorPaging(1L, null, LIMIT);

        // then
        assertEquals(LIMIT, studyConventionInfoList.size());

    }

커서 기반 페이지네이션 테스트 인데 cursor가 null일경우도 추가하여 테스트했다.

 

 

StudyConventionControllerTest.java

    @Test
    public void Convention_단일_조회_테스트() 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);

        StudyConvention studyConvention = StudyConventionFixture.createStudyDefaultConvention(studyInfo.getId());
        studyConventionRepository.save(studyConvention);

        StudyConventionResponse response = StudyConventionResponse.of(studyConvention);

        when(studyMemberService.isValidateStudyMember(any(User.class), any(Long.class)))
                .thenReturn(UserInfoResponse.of(savedUser));
        when(studyConventionService.readStudyConvention(any(Long.class))).thenReturn(response);

        // when, then
        mockMvc.perform(get("/study/" + studyInfo.getId() + "/convention/" + studyConvention.getId())
                        .contentType(MediaType.APPLICATION_JSON)
                        .header(AUTHORIZATION, createAuthorizationHeader(accessToken, refreshToken)))
                .andExpect(status().isOk())
                .andExpect(jsonPath("$.res_code").value(200))
                .andExpect(jsonPath("$.res_msg").value("OK"))
                .andExpect(jsonPath("$.res_obj.name").value(response.getName()))
                .andExpect(jsonPath("$.res_obj").isNotEmpty())
                .andDo(print());
    }

    @Test
    public void Convention_전체_조회_테스트() 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);

        StudyConvention studyConvention = StudyConventionFixture.createStudyDefaultConvention(studyInfo.getId());
        studyConventionRepository.save(studyConvention);

        StudyConventionListAndCursorIdxResponse response = StudyConventionListAndCursorIdxResponse.builder()
                .studyConventionList(new ArrayList<>())  // 비어 있는 convention 리스트
                .build();
        response.setNextCursorIdx();

        when(studyMemberService.isValidateStudyMember(any(User.class), any(Long.class)))
                .thenReturn(UserInfoResponse.of(savedUser));
        when(studyConventionService.readStudyConventionList(any(Long.class), any(Long.class), any(Long.class))).thenReturn(response);

        // when, then
        mockMvc.perform(get("/study/" + studyInfo.getId() + "/convention")
                        .contentType(MediaType.APPLICATION_JSON)
                        .param("cursorIdx","1")
                        .param("limit","1")
                        .header(AUTHORIZATION, createAuthorizationHeader(accessToken, refreshToken)))

                .andExpect(status().isOk())
                .andExpect(jsonPath("$.res_code").value(200))
                .andExpect(jsonPath("$.res_msg").value("OK"))
                .andExpect(jsonPath("$.res_obj").isNotEmpty())
                .andDo(print());
    }

컨트롤러 테스트이다.

 

 

StudyConventionServiceTest.java

    @Test
    @DisplayName("컨벤션 단일 조회 테스트")
    public void readStudyConvention() {

        //given
        User savedUser = userRepository.save(generateAuthUser());

        StudyInfo studyInfo = StudyInfoFixture.createDefaultPublicStudyInfo(savedUser.getId());
        studyInfoRepository.save(studyInfo);

        StudyMember member = StudyMemberFixture.createDefaultStudyMember(savedUser.getId(), studyInfo.getId());
        studyMemberRepository.save(member);

        StudyConvention studyConvention = StudyConventionFixture.createStudyDefaultConvention(studyInfo.getId());
        studyConventionRepository.save(studyConvention);

        //when
        studyMemberService.isValidateStudyMember(savedUser, studyInfo.getId());
        studyConventionService.readStudyConvention(studyConvention.getId());

        //then
        assertEquals("컨벤션", studyConvention.getName());
        assertEquals("설명", studyConvention.getDescription());
        assertEquals("정규식", studyConvention.getContent());
        assertEquals(studyInfo.getId(), studyConvention.getStudyInfoId());
        assertTrue(studyConvention.isActive());

    }

    @Test
    @DisplayName("컨벤션 전체 조회 테스트")
    public void readStudyConventionList() {
        // given
        Random random = new Random();
        Long cursorIdx = Math.abs(random.nextLong()) + LIMIT;  // Limit 이상 랜덤값

        User savedUser = userRepository.save(generateAuthUser());

        StudyInfo studyInfo = StudyInfoFixture.createDefaultPublicStudyInfo(savedUser.getId());
        studyInfoRepository.save(studyInfo);

        StudyMember member = StudyMemberFixture.createDefaultStudyMember(savedUser.getId(), studyInfo.getId());
        studyMemberRepository.save(member);

        StudyConvention studyConvention1 = StudyConventionFixture.createStudyConventionName(studyInfo.getId(), "1번째 컨벤션");
        StudyConvention studyConvention2 = StudyConventionFixture.createStudyConventionName(studyInfo.getId(), "2번째 컨벤션");
        StudyConvention studyConvention3 = StudyConventionFixture.createStudyConventionName(studyInfo.getId(), "3번째 컨벤션");
        StudyConvention studyConvention4 = StudyConventionFixture.createStudyConventionName(studyInfo.getId(), "4번째 컨벤션");
        studyConventionRepository.saveAll(List.of(studyConvention1, studyConvention2, studyConvention3, studyConvention4));

        //when
        studyMemberService.isValidateStudyMember(savedUser, studyInfo.getId());
        StudyConventionListAndCursorIdxResponse responses = studyConventionService.readStudyConventionList(studyInfo.getId(), cursorIdx, LIMIT);

        //then
        assertNotNull(responses);
        assertEquals(4, responses.getStudyConventionList().size());
        assertEquals("4번째 컨벤션", responses.getStudyConventionList().get(0).getName()); // 최신 컨벤션 확인

    }

컨벤션 단일 조회 Service 테스트와 전체 조회 Service 테스트이다.

댓글