💡 .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() 메서드를 사용해서 구현했다!!
// 스터디 가입신청 목록 조회
@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 이다.
@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 이다.
// 스터디 가입신청 목록 조회 메서드
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 구현이다. 신청 목록이 없을 경우도 예외처리!
테스트
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());
}
컨트롤러 동작 테스트이다.
@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 테스트
- 가입신청 목록 조회 성공 테스트
- 가입신청 목록 커서 기반 페이징 로직 검증 테스트
- 가입신청 목록 테스트 - 멤버가 섞여있을때
- 가입신청 목록 테스트 - 가입신청이 없는경우
이렇게 나누어 테스트했다.
'Project > 깃터디 (gitudy)' 카테고리의 다른 글
[깃터디/Convention] 컨벤션 등록 api 구현 (0) | 2024.05.11 |
---|---|
[깃터디/Member] 스터디에 속한 스터디원 조회 api 구현 (0) | 2024.05.11 |
[깃터디/Member] 스터디원 강퇴, 스터디원 탈퇴 api 구현 (0) | 2024.05.11 |
[깃터디/Member] 스터디장 가입 신청 승인/거부 api 구현 (0) | 2024.05.11 |
[깃터디/Member] 스터디 가입 취소 api 구현 (0) | 2024.05.11 |
댓글