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

[깃터디/Member] 스터디 가입 신청 목록 조회 api 구현

by J-rain 2024. 5. 11.
💡 .java를 클릭시 관련 커밋으로 이동💡

 

 회고 

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

    // StudyInfoId를 통해 승인 대기중인 멤버들의 가입신청 목록을 가져온다.
    List<StudyMemberApplyResponse> findStudyApplyListByStudyInfoId_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번째 페이지에 진입할 경우를 생각하며 구현해야한다

 

 

StudyMemberRepositoryImpl.java

    @Override
    public List<StudyMemberApplyResponse> findStudyApplyListByStudyInfoId_CursorPaging(Long studyInfoId, Long cursorIdx, Long limit) {

        JPAQuery<StudyMemberApplyResponse> query = queryFactory
                .select(Projections.constructor(StudyMemberApplyResponse.class,
                        studyMember.id,
                        user.id.as("userId"),
                        user.name,
                        user.githubId,
                        user.socialInfo,
                        user.profileImageUrl,
                        user.score,
                        user.point))
                .from(studyMember)
                .join(user).on(studyMember.userId.eq(user.id))
                .where(studyMember.studyInfoId.eq(studyInfoId)
                        .and(studyMember.status.eq(StudyMemberStatus.STUDY_WAITING)))
                .orderBy(studyMember.id.asc());

        if (cursorIdx != null) {
            query = query.where(studyMember.id.gt(cursorIdx)); // 먼저 신청한순 (마지막 항목기준 Id 값이 큰값들 조회)
        }

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

    }

기존조회 로직은 우선 최신 항목을 보여주기 위해서 id.des() 정렬를 했던점과 query = query.where(studyMember.id.gt(cursorIdx)) 이부분이 다른데

  • .gt() 메서드는 "greater than"의 약자로, 주어진 값보다 큰 값을 찾을 때 사용된다. 예를 들어, **studyMember.id.gt(cursorIdx)**는 'studyMember의 id가 cursorIdx보다 큰 모든 경우'를 선택
  • 반면에, .lt() 메서드는 "less than"의 약자로, 주어진 값보다 작은 값을 찾을 때 사용된다. 따라서 studyMember.id.lt(cursorIdx)는 'studyMember의 id가 cursorIdx보다 작은 모든 경우'를 선택

간단히 말해서, .gt()는 주어진 값보다 큰 경우를, .lt()는 주어진 값보다 작은 경우를 필터링하기 위해 사용

가입신청은 먼저 가입신청한 유저가 제일 상단에 보여야 겠다고 생각했기에 .gt() 메서드를 사용해서 구현했다!!

 

 

StudyMemberController.java

// 스터디 가입신청 목록 조회
    @ApiResponse(responseCode = "200", description = "스터디 가입신청 목록 조회 성공", content = @Content(schema = @Schema(implementation = StudyMemberApplyListAndCursorIdxResponse.class)))
    @GetMapping("/{studyInfoId}/apply")
    public JsonResult<?> applyListStudyMember(@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.isValidateStudyLeader(user, studyInfoId);

        return JsonResult.successOf(studyMemberService.applyListStudyMember(studyInfoId, cursorIdx, limit));
    }

가입신청 목록 컨트롤러 구현

 

StudyMemberApplyListAndCursorIdxResponse.java

@Builder
@Getter
@AllArgsConstructor
public class StudyMemberApplyListAndCursorIdxResponse {

    private List<StudyMemberApplyResponse> applyList; // 신청 정보

    private Long cursorIdx;

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

    }
}

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

 

 

StudyMemberApplyResponse.java

@Getter
@Builder
@AllArgsConstructor
public class StudyMemberApplyResponse {

    private Long id;  // 스터디 member id

    private Long userId;   // userId

    private String name;  // 이름

    private String githubId; // 깃허브Id

    private SocialInfo socialInfo;  // 소셜 정보

    private String profileImageUrl;  // 프로필 이미지

    private int score;  // 개인 활동점수

    private int point;  // 포인트

}

가입신청한 유저의 정보를 보여줄 DTO 이다.

 

 

StudyMemberService.java

    // 스터디 가입신청 목록 조회 메서드
    public StudyMemberApplyListAndCursorIdxResponse applyListStudyMember(Long studyInfoId, Long cursorIdx, Long limit) {

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

        limit = Math.min(limit, MAX_LIMIT);

        // 대기중인 멤버들의 신청목록 조회
        List<StudyMemberApplyResponse> applyList  = studyMemberRepository.findStudyApplyListByStudyInfoId_CursorPaging(studyInfoId, cursorIdx, limit);

        // 대기중인 멤버가 없는 경우(가입 신청x) 예외처리
        if (applyList.isEmpty()) {
            log.warn(">>>> {} : {} <<<<", studyInfoId, ExceptionMessage.STUDY_NOT_APPLY_LIST);
            throw new MemberException(ExceptionMessage.STUDY_NOT_APPLY_LIST);
        }

        StudyMemberApplyListAndCursorIdxResponse response = StudyMemberApplyListAndCursorIdxResponse.builder()
                .applyList(applyList)
                .build();

        response.setNextCursorIdx();

        return response;
    }

가입신청 목록 Service 구현이다. 신청 목록이 없을 경우도 예외처리!

 


테스트

 

UserFixture.java

 public static User generateDefaultUser(String platformId, String name) {

        SocialInfo socialInfo = SocialInfo.builder()
                .blogLink("블로그 링크")
                .githubLink("깃허브 링크")
                .linkedInLink("링크드인 링크")
                .build();

        return User.builder()
                .platformId(platformId)
                .name(name)
                .platformType(GOOGLE)
                .role(USER)
                .githubId("깃허브아이디")
                .socialInfo(socialInfo)
                .profileImageUrl("이미지")
                .build();
    }

User 생성 Fixture이다.

 

 

StudyMemberControllerTest.java

    @Test
    public void 스터디_가입_신청_목록_조회_테스트() 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);

        StudyMemberApplyListAndCursorIdxResponse response = StudyMemberApplyListAndCursorIdxResponse.builder()
                .applyList(new ArrayList<>()) // 비어 있는 가입 리스트
                .build();

        when(studyMemberService.isValidateStudyLeader(any(User.class), any(Long.class))).thenReturn(UserInfoResponse.of(savedUser));
        when(studyMemberService.applyListStudyMember(any(Long.class), any(Long.class), any(Long.class))).thenReturn(response);

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

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

컨트롤러 동작 테스트이다.

 

 

StudyMemberServiceTest.java

    @Test
    @DisplayName("스터디 가입신청 목록 조회 테스트")
    void applyListStudyMember() {
        // given
        User leader = UserFixture.generateAuthUser();
        User user1 = UserFixture.generateDefaultUser("1", "Lee");
        User user2 = UserFixture.generateDefaultUser("2", "Koo");
        User user3 = UserFixture.generateDefaultUser("3", "Tak");
        User user4 = UserFixture.generateDefaultUser("4", "Joo");
        userRepository.saveAll(List.of(leader, user1, user2, user3, user4));

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

        StudyMember waitStudyMember1 = StudyMemberFixture.createStudyMemberWaiting(user1.getId(), studyInfo.getId());
        StudyMember waitStudyMember2 = StudyMemberFixture.createStudyMemberWaiting(user2.getId(), studyInfo.getId());
        StudyMember waitStudyMember3 = StudyMemberFixture.createStudyMemberWaiting(user3.getId(), studyInfo.getId());
        StudyMember waitStudyMember4 = StudyMemberFixture.createStudyMemberWaiting(user4.getId(), studyInfo.getId());
        studyMemberRepository.saveAll(List.of(waitStudyMember1, waitStudyMember2, waitStudyMember3, waitStudyMember4));

        // when
        StudyMemberApplyListAndCursorIdxResponse responses = studyMemberService.applyListStudyMember(studyInfo.getId(), CursorIdx, Limit);

        // then
        assertNotNull(responses);
        assertEquals(3, responses.getApplyList().size());
        assertEquals("Lee", responses.getApplyList().get(0).getName());
        assertEquals("Tak", responses.getApplyList().get(2).getName());
    }

    @Test
    @DisplayName("가입신청 목록 커서 기반 페이징 로직 검증")
    void applyList_CursorPaging_Success() {
        // given
        User leader = UserFixture.generateAuthUser();
        User user1 = UserFixture.generateDefaultUser("1", "Lee");
        User user2 = UserFixture.generateDefaultUser("2", "Koo");
        User user3 = UserFixture.generateDefaultUser("3", "Tak");
        User user4 = UserFixture.generateDefaultUser("4", "Joo");
        userRepository.saveAll(List.of(leader, user1, user2, user3, user4));

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

        StudyMember waitStudyMember1 = StudyMemberFixture.createStudyMemberWaiting(user1.getId(), studyInfo.getId());
        StudyMember waitStudyMember2 = StudyMemberFixture.createStudyMemberWaiting(user2.getId(), studyInfo.getId());
        StudyMember waitStudyMember3 = StudyMemberFixture.createStudyMemberWaiting(user3.getId(), studyInfo.getId());
        StudyMember waitStudyMember4 = StudyMemberFixture.createStudyMemberWaiting(user4.getId(), studyInfo.getId());
        studyMemberRepository.saveAll(List.of(waitStudyMember1, waitStudyMember2, waitStudyMember3, waitStudyMember4));

        // when1
        StudyMemberApplyListAndCursorIdxResponse firstPageResponse = studyMemberService.applyListStudyMember(studyInfo.getId(), CursorIdx, Limit);

        // then1
        assertNotNull(firstPageResponse);
        assertEquals(3, firstPageResponse.getApplyList().size());
        assertEquals("Lee", firstPageResponse.getApplyList().get(0).getName());

        // when2
        Long newCursorIdx = firstPageResponse.getCursorIdx();
        StudyMemberApplyListAndCursorIdxResponse secondPageResponse = studyMemberService.applyListStudyMember(studyInfo.getId(), newCursorIdx, Limit);

        // then2
        assertNotNull(secondPageResponse);
        assertEquals(1, secondPageResponse.getApplyList().size());
        assertEquals("Joo", secondPageResponse.getApplyList().get(0).getName());
    }

    @Test
    @DisplayName("가입신청 목록 테스트- 멤버가 섞여있을때")
    void applyList_MemberMix() {
        // given
        User leader = UserFixture.generateAuthUser();
        User user1 = UserFixture.generateDefaultUser("1", "Lee");
        User user2 = UserFixture.generateDefaultUser("2", "Koo");
        User user3 = UserFixture.generateDefaultUser("3", "Tak");
        User user4 = UserFixture.generateDefaultUser("4", "Joo");
        userRepository.saveAll(List.of(leader, user1, user2, user3, user4));

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

        StudyMember waitStudyMember1 = StudyMemberFixture.createStudyMemberWaiting(user1.getId(), studyInfo.getId());
        StudyMember resignedStudyMember2 = StudyMemberFixture.createStudyMemberResigned(user2.getId(), studyInfo.getId());  // 강퇴된 멤버
        StudyMember waitStudyMember3 = StudyMemberFixture.createStudyMemberWaiting(user3.getId(), studyInfo.getId());
        StudyMember refusedStudyMember4 = StudyMemberFixture.createStudyMemberResigned(user4.getId(), studyInfo.getId());  // 거부된 멤버
        studyMemberRepository.saveAll(List.of(waitStudyMember1, resignedStudyMember2, waitStudyMember3, refusedStudyMember4));

        // when
        StudyMemberApplyListAndCursorIdxResponse responses = studyMemberService.applyListStudyMember(studyInfo.getId(), CursorIdx, Limit);

        // then
        assertNotNull(responses);
        assertEquals(2, responses.getApplyList().size());
        assertEquals("Lee", responses.getApplyList().get(0).getName());
        assertEquals("Tak", responses.getApplyList().get(1).getName());
    }

    @Test
    @DisplayName("가입신청 목록 테스트 - 가입신청이 없는경우")
    void applyList_empty() {
        // given
        User leader = UserFixture.generateAuthUser();
        User user1 = UserFixture.generateDefaultUser("1", "Lee");
        User user2 = UserFixture.generateDefaultUser("2", "Koo");
        User user3 = UserFixture.generateDefaultUser("3", "Tak");
        User user4 = UserFixture.generateDefaultUser("4", "Joo");
        userRepository.saveAll(List.of(leader, user1, user2, user3, user4));

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

        // 전부 활동중인 멤버로 생성
        StudyMember activeStudyMember1 = StudyMemberFixture.createDefaultStudyMember(user1.getId(), studyInfo.getId());
        StudyMember activeStudyMember2 = StudyMemberFixture.createDefaultStudyMember(user2.getId(), studyInfo.getId());
        StudyMember activeStudyMember3 = StudyMemberFixture.createDefaultStudyMember(user3.getId(), studyInfo.getId());
        StudyMember activeStudyMember4 = StudyMemberFixture.createDefaultStudyMember(user4.getId(), studyInfo.getId());
        studyMemberRepository.saveAll(List.of(activeStudyMember1, activeStudyMember2, activeStudyMember3, activeStudyMember4));

        // then
        MemberException em = assertThrows(MemberException.class, () -> {
            studyMemberService.applyListStudyMember(studyInfo.getId(), CursorIdx, Limit);
        });

        assertEquals(ExceptionMessage.STUDY_NOT_APPLY_LIST.getText(), em.getMessage());
    }

가입신청 목록 Service 테스트

  • 가입신청 목록 조회 성공 테스트
  • 가입신청 목록 커서 기반 페이징 로직 검증 테스트
  • 가입신청 목록 테스트 - 멤버가 섞여있을때
  • 가입신청 목록 테스트 - 가입신청이 없는경우

이렇게 나누어 테스트했다.

댓글