💡 .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 이다.
@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이다.
// 컨벤션 단일 조회
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 구현이다.
테스트
// 테스트용 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 테스트이다.
'Project > 깃터디 (gitudy)' 카테고리의 다른 글
[깃터디/Convention] 컨벤션 삭제 api 구현 (0) | 2024.05.11 |
---|---|
[깃터디/Convention] 컨벤션 수정 api 구현 (0) | 2024.05.11 |
[깃터디/Convention] 컨벤션 등록 api 구현 (0) | 2024.05.11 |
[깃터디/Member] 스터디에 속한 스터디원 조회 api 구현 (0) | 2024.05.11 |
[깃터디/Member] 스터디 가입 신청 목록 조회 api 구현 (0) | 2024.05.11 |
댓글