일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | |||
5 | 6 | 7 | 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | 23 | 24 | 25 |
26 | 27 | 28 | 29 | 30 | 31 |
- 타임리프 - 기본기능
- jpa 활용2 - api 개발 고급
- 스프링 mvc1 - 스프링 mvc
- 자바의 정석 기초편 ch8
- 자바의 정석 기초편 ch7
- 스프링 db1 - 스프링과 문제 해결
- jpa - 객체지향 쿼리 언어
- 자바의 정석 기초편 ch4
- 2024 정보처리기사 시나공 필기
- 스프링 mvc2 - 검증
- 자바의 정석 기초편 ch12
- 자바의 정석 기초편 ch6
- 자바의 정석 기초편 ch5
- 스프링 고급 - 스프링 aop
- 자바의 정석 기초편 ch2
- 자바의 정석 기초편 ch1
- 스프링 db2 - 데이터 접근 기술
- 자바의 정석 기초편 ch9
- 자바의 정석 기초편 ch14
- 스프링 mvc1 - 서블릿
- 자바의 정석 기초편 ch13
- 스프링 mvc2 - 타임리프
- 자바의 정석 기초편 ch3
- @Aspect
- 코드로 시작하는 자바 첫걸음
- 스프링 mvc2 - 로그인 처리
- 스프링 입문(무료)
- 자바의 정석 기초편 ch11
- 게시글 목록 api
- 2024 정보처리기사 수제비 실기
- Today
- Total
나구리의 개발공부기록
취업을 위한 첫 팀 프로젝트 시작, 게시글 목록 API 만들기1, NoOffset Cursor(커서) 방식 페이징, 검색 기능 포함 본문
취업을 위한 첫 팀 프로젝트 시작, 게시글 목록 API 만들기1, NoOffset Cursor(커서) 방식 페이징, 검색 기능 포함
소소한나구리 2024. 12. 20. 11:441. 처음 제대로된 팀 프로젝트 시작
2023년 10월부터 개발자가 되기위해서 공부를 시작했지만 12월 11일 취업 스터디에 들어가게 되면서 제대로된 팀 프로젝트를 경험하게 되었다.
2024년 분당 폴리택 대학의 10개월 교육 과정을 다녔으나정말 최악의 교육과정이였다
과거의 기술과 똑같은 내용을 2번의 강사에 나눠서 2주씩 1달간 가르치는 비효율 적인 커리큘럼과
(왜 같은 내용을 2번이나 1달 가르쳐서 진도를 나가지도 않고 자바를 객체지향 개념은 알려주지도 않는지는 정말 모르겠다.. 최소 한달이면 기초 자바 문법은 다 훑을 것 같았는데...)
지원금 때문인진 모르지만 AI + 파이썬을 2달간 하면서 백엔드 개발에 필요한 학습이 늦어지고,
결국엔 웹 개발은 5개월이 지난 후에야 순수 웹개발 수업이 진행되었으나 결국 이부분도 제대로된 웹 개발 경험이라기보단 개인 토이 프로젝트만 작업하면서 좀 더 좋은 코드를 짜고 어떻게 개발하는 것이 좋은지에 대한 명확한 배움이 없어 현업 개발자분과 논의 한 끝에 결국 수료하지 않고 중도에 나와서 따로 온라인 강의로 공부 하는 것으로 노선을 변경하여 공부했다.
거의 뭐 악플러가 되어버렸지만 잃은 것만 있지는 않다고 생각한다
절대로 이런 교육과정은 두번다시 하지 않을 것임과 모든 교육이 동일하지 않기에 교육 과정을 잘 살피고 교육을 들어야겠다는 생각과 잘못된 개발자 공부 방법이 무엇인지 알게 된 것!!!
다른데서 경험해 볼 수 없는 엔지니어링 과정(리눅스 기초, 도커, 젠킨스와 깃랩으로 CI, CD 파이프라인 해보기, 쿠버네티스, AWS 교육 경험)은 상당히 재미있었고 이런 부분도 개발자가 되기위한 과정이라는 것을 알게 되었고
교육 기간 중에 틈틈히 정보처리기사도 공부해서 2024년 2회차 필기, 실기와 SQLD 모두 원트만에 합격하였다..만 취업에 도움이 될 지 모르겠다(그래도 그 당시에는 기뻤다)
그나마 약 3개월 동안 김영한님의 스프링과 JPA 로드맵을 모두 수료하고 웹 백엔드 개발에 대해 전반적인 흐름을 알게되었다고 생각하게되어 팀프로젝트를 구해서 참가하게 되었다
짧은 기간 동안 빠르게 강의를 들었기에 수많은 내용이 담긴 이 강의의 모든 내용이 기억나지는 않지만 모든 내용에대해 이해 하고 넘어갔기에 정리하고 반복 학습하여 익숙해진다면 언젠가 내 것이 될 것이라고 믿는다
2. 두 번째 기능 개발 - 게시글 목록 API 만들기
1) 순식간에 지나간 첫 번째 기능 개발
왜 바로 두 번째 기능 개발이 글의 시작이냐........라고 한다면 Route53을 연동하고, 로그인, 회원가입 관련된 기능 개발을 하는 과정도 있었지만 Route 53을 연동할 당시에는 이런 글을 써야겠다라고 생각을 하지 못했다
그리고 로그인 회원가입 관련 기능 개발을 할때는 같이 개발을 진행했는데 스프링 시큐리티와 JWT, 카카오 OAuth로 개발을 하는 거였는데 이부분에대 전혀 알지를 못해서 거의 회의와 공부만 하고.. 내가 직접 한건 저 수많은 작업중에서 카카오 로그아웃 API 개발한 것 밖에 없기 때문이다..
심지어 이 간단한 코드를 작성하는 것도 이게 맞는지, 저게 맞는지 한참 고민하고 알아보면서 하느라 하루 웬종일 걸렸다..
이렇게 개발하는게 맞나 싶은 순간이였지만 그래도 처음 다뤄보는 기술에대해 접하고 공부하고 개발된 뼈대 아키텍처의 구조를 파악하고 프론트엔드와 백엔드와의 API로 개발하는 방식에 대해서 설명을 들으면서 조금씩 알아가는 단계를 거쳐갔다는 것에 의의를 두었다.
2) 뼈대 만들기 그리고 No Offset? Cursor(커서) 기반 페이징?
1일차에는 게시글 ERD를 보고 게시글 도메인을 생성하고 리포지토리, 서비스, 컨트롤러등의 뼈대를 만들면서 구조를 작성했다.
그런데 처음 전체 회의를 할 때 페이징 방식에 있어서 Offset을 사용하게되면 페이징 처리가 늦어진다는 이야기가 생각나서 관련된 정보를 계속 찾아보았고 ChatGPT와 구글 검색을 통해서 Offset은 Offset의 값이 커질수록 성능 저하를 유발할 수 있다는 결과를 얻었다.
Offset의 동작 방식 자체가 Offset에 해당하는 행들을 모두 스캔한 후 Limit에 지정된 행을 반환하기 때문에 Offset 값이 클수록 성능 문제가 심각해진다는 내용 이였으며 몇만건, 몇십만 건에 대해서는 큰 성능 저하는 없으면 몇백 몇천만 건이상이 넘어가면 점차 성능에 무리가 간다는 내용이었다.
그리고 No Offset 방식이 커서 기반 페이징을 칭하는 말이라는 것을 알게 되었다.
그래서 나는 JPA 강의를 들으면서 스프링 데이터 JPA에서 제공하는 Pageable를 활용하여 JPA가 기본을 동작하는 offset방식의 페이징을 활용하는 방식을 배웠으나 이 방식도 직접 구현해보는 것이 좋겠다는 생각이 들어 커서 기반 페이징을 하기로 마음을 먹었다.
물론 실무로 투입되는 상황이었다면 애플리케이션 초기 단계의 개발에서는 빠르게 개발하기 위해 Pageable을 활용하여 개발을 진행하고 추후 모니터링을 통해 유지보수를 하여 수정하는 것이 좋겠다는 생각도 동시에 했다
** 참고한 글
- https://xrabcde.github.io/pagination/
- https://green-bin.tistory.com/23
- 참고한 글들이 더 있지만 대체로 내용이 비슷해서 마지막에 봣던 2개의 글만 게시
3) Querydsl 세팅
나는 여태까지 인텔리제이의 세팅 -> Build Tools -> Gradle 에서 Build and run using과 Run tests using을 Gradle로 사용해서 개발했다
세팅된 프로젝트에 Querydsl이 세팅이 되어있어서 Querydsl를 사용하기위해 RepositoryCustom과 RepositoryCustomImpl을 만들고 쿼리를 작성하려고 하는데 자꾸 Q클래스가 임포트가 안되어서 멘붕이왔다
새로 clean -> build를 통해서 여러변 Q클래스가 생성된 것을 확인했는데도 자꾸 안되어 고민하고 있는 그때! 갓 영한님의 강의가 갑자기 생각이 났다(바로 생각안났다.. )
인텔리 제이의 Gradle 세팅에서 Build and run using과 Run tests using을 IntelliJ로 설정해서 사용할 때와 Gradle로 사용했을때 Q클래스의 생성 경로가 달라 각각의 방법으로 실습했던 그 기억을 말이다!
- https://nagul2.tistory.com/318
그래서 부랴부랴 세팅에 들어가서 세팅을 들어가서 위의 두가지의 설정을 Gradle -> IntelliJ로 바꿨더니 바로 성공하였다..
원래 사실은 Q파일의 생성된 위치만 보고 바로 기억해내야 하는 것이 맞았으나 머리속에 배운 내용이 제대로 박혀있지 않다보니 해결하는데에 시간이 걸렸다.
내 빌드 설정이 Gradle일 때는
Querydsl가 세팅되어있는 상황에서 별도의 Q파일이 생성되는 위치를 지정하지 않았다면 기본적으로 build -> generated -> sources -> annotationProcessor 하위에 Q클래스 파일이 생긴다
해당 위치는 기본적으로 gitignore이므로 별도의 설정없이도 git에 올라가지 않으며 build.gradle에 clean { delete ... }와 같은 설정을 해주지 않아도 clean하면 자동으로 Q클래스도 삭제가 된다
내 빌드 설정이 IntellJ일 때는
Querydsl가 세팅되어있는 상황에서 별도의 Q파일이 생성되는 위치를 지정하지 않았다면 기본적으로 src -> main -> generated 하위에 Q클래스 파일이 생기며 해당 위치는 git에 반영되므로 꼭 해당 경로를 gitIgnore로 설정 해주어야 한다
또한 clean시 자동으로 Q클래스 파일이 삭제되지 않기 때문에 buil.gradle에 아래와 같이 꼭 입력해주어야 clean을 할 때 Q클래스가 삭제된다
clean {
delete file('src/main/generated')
}
솔직히 번거로우니까 다같이 Gradle로 설정하고 작업하자고 하거나 혼자 개발할때는 Gradle로 하는 것을 권장한다
4) 조회 쿼리 초기 개발
전체 게시글 조회에는 제목 + 내용을 검색할 수 있는 기능과 최신 순, 좋아요 순, 조회수 순으로 3가지 조건의 내림차순, 오름차순의 기능을 구현해야 하는데 우선 검색기능은 제외하고 페이징과 필터 기능을 먼저 개발하기로 마음먹고 착수했다.
근데 ERD에는 좋아요 수와 조회수를 담는 필드가 없어서 해당 상황을 공유하고 먼저 Integer로 Entity에 해당 필드들을 추가하고 개발을 진행했는데 이 3가지의 조건을 가지고 동적 쿼리를 만드는데 상당히 시간을 오래잡아 먹었다.
거의 2시간 넘게 고민하고 검색하고 동적 페이징 조건에대해 이해하고..하는데 시간을 보내며 결국 아래처럼 처음으로 쿼리가 나왔다.
Service에서는 단순히 Controller로 위임하게 되고 Controller에서는 스웨거를 통해 API 문서를 작성하였다
코드 더보기
package com.ani.taku_backend.post.repository.impl;
@Repository
@RequiredArgsConstructor
public class PostRepositoryCustomImpl implements PostRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<Post> findPostsWithNoOffset(String filter, Object lastValue, boolean isAsc, int limit) {
QPost post = QPost.post;
BooleanExpression condition = getCondition(filter, lastValue, isAsc, post); // Where 절 조건
OrderSpecifier<?> orderSpecifier = getSpecifier(filter, isAsc, post); // 정렬의 기준
return jpaQueryFactory
.selectFrom(post)
.where(condition)
.orderBy(orderSpecifier)
.limit(limit)
.fetch();
}
private BooleanExpression getCondition(String filter, Object lastValue, boolean isAsc, QPost post) {
if ("likes".equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.likes.gt((Integer) lastValue) : post.likes.lt((Integer) lastValue);
} else if ("views".equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.views.gt((Integer) lastValue) : post.views.lt((Integer) lastValue);
} else if (lastValue != null) {
return isAsc ? post.createdAt.gt((LocalDateTime) lastValue) : post.createdAt.lt((LocalDateTime) lastValue);
}
return null;
}
private OrderSpecifier<?> getSpecifier(String filter, Object lastValue, QPost post) {
if ("likes".equalsIgnoreCase(filter)) {
return post.likes.desc();
} else if ("views".equalsIgnoreCase(filter)) {
return post.views.desc();
}
return post.createdAt.desc();
}
}
처음 자리에 앉아서 어떻게 개발해야하지 고민하고, 검색하고, 알아보고 코드 작성하고.. 논의하고 하는데에 몇시간을 쏟아 부었는지 모르겠다..
다른사람들은 금방 개발할 것 같은데.. 이런 간단하고 당연한기능 개발하는데도 하루종일 걸리는게 맞는건가 엄청 고민하게되었다
5) 문제점 지적과 추가 정보
해당 기능을 개발한 날이 온라인 팀회의가 있는 날이여서, 내 발표 시간에 해당 기능에 대해서 왜 커서 기반 페이징을 택했고 어떻게 개발했는지에 대해서 발표하고 검색기능을 이어서 개발하겠다고 하였는데 여러가지 문제점을 지적 받았다
1. lastValue가 Object로 되어있고 두가지 타입으로 형변환을 하는데 타입 체크가 되지 않아 에러가 발생할 것 같은데, 공통 예외처리로직이 있어도 검증이 필요할 것 같다
2. 그리고 검색 조건에 대한 필터 값을 Enum으로 관리해야 공통으로 쓸 수 있을 것 같다
3. 몇가지의 메서드명이 와닿지 않는데 너무 마이크로한 부분이라 여기까진 이야기 하지 않겠다
크게 위 두가지에대한 문제로 2번은 바로 반영하기로 했고 1번에대해 여러가지 의견이 나왔으나 결국 더 좋은 방법이 있는지 찾아보고 반영하기로 하였다.
그리고 여기서 ERD에 좋아요 수와 조회수 필드가 빠져있는 부분에 대해 정리가 되었다
좋아요 수는 실시간으로 계속 반영되기 때문에 MongoDB에 저장하는 것으로 기획이 되어있었다고 했고, 조회수는 실시간 반영하지 않는것으로 하여 ERD에 추가하는 것으로 결정이 지어지고 하루가 끝났다
6) 1일차에 개발한 기능 테스트와 메서드명 변경
우선 메서드명이 와닿지 않는것같기도해서 전면적으로 수정하였고 findPosts로 s로 사용했던 것을 조회는 우리가 find로 하기로 약속했으므로 findAllPost로 컨트롤러와 서비스 코드는 모두 변경하였고 리포지토리의 조회 메서드는 그대로 유지하였다
그리고 querydsl 내부에서 검색 필터를 조회하는 메서드인 getCondition과 정렬 기준을 제공하는 getSpecifier의 메서드도 변경하려 했으나 어차피 코드가 수정이 될 것 같아서 코드 수정시 변경하기로하고 우선 기능이 제대로 동작하는지 메모리가아닌 local DB에 붙여서 테스트를 진행해보기로 했다
테스트 코드를 어떻게 작성해야할지에 대해서도 엄청 고민과 검색과.. 무수히 많은 시간이 지난 후.. 그 결과 아래처럼 코드가 나왔다
내가 만든 테스트 코드를 진행하기위해 여러 방면에서 고민했지만 명확한 답변을 찾지 못하고 결국 @Test로 테스트 데이터를 집어넣는 메서드를 만들어서 DB에 테스트 데이터를 입력하였고,
데이터를 집어넣으면 시간도 걸리고 렉도 걸리기에 또 테스트가 진행되는 것을 막기위해 해당 데이터를 집어넣은 후에는 해당 테스트는 주석처리하고 별도의 테스트케이스를 실행하는 테스트를 진행하였다
코드 더보기
package com.ani.taku_backend.post.repository;
@SpringBootTest
class PostRepositoryTest {
@Autowired private PostRepository postRepository;
@PersistenceContext
private EntityManager entityManager;
// @Test
@Transactional
@Commit
void saveTestData() {
int totalRecords = 1000000;
int batchSize = 100000;
for (int i = 1; i <= totalRecords; i++) {
int views = ThreadLocalRandom.current().nextInt(1, 300000);
int likes = ThreadLocalRandom.current().nextInt(1, 100000);
LocalDateTime createdTime = LocalDateTime.now().minusSeconds(totalRecords - i);
LocalDateTime updatedTime = createdTime.plusSeconds(ThreadLocalRandom.current().nextInt(0, 60));
// 데이터 생성
Post post = new Post(null, null, null, "title" + i, "content" + i,
createdTime, updatedTime, views, likes);
entityManager.persist(post);
// 배치 처리
if (i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
// 마지막 플러시
entityManager.flush();
entityManager.clear();
}
@Test
void noOffsetPagingTest() {
Long randomId = (long) (Math.random() * 1000000) + 1;
Post findPost = postRepository.findById(randomId).get();
latestFilterPagingTest(findPost);
viewsFilterPagingTest(findPost);
likesFilterPagingTest(findPost);
}
private void likesFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findPostsWithNoOffset("likes", findPost.getLikes(), false, 20, null);
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("resultTime(likes) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId() + " post.getLikes() = " + post.getLikes());
}
}
private void viewsFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findPostsWithNoOffset("views", findPost.getViews(), false, 20, null);
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("resultTime(views) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId() + " post.getViews() = " + post.getViews());
}
}
private void latestFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findPostsWithNoOffset("latest", findPost.getCreatedAt(), false, 20, null);
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("resultTime(latest) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId() + " post.getCreatedAt() = " + post.getCreatedAt());
}
}
}
그런데 엄청난 문제를 발견했는데 나는 말로만 들었지 이렇게 조회에서 성능저하가 발생하는줄 몰랐다.
테스트 데이터가 100만건밖에 안되고 local에서만 통신하는 조회임에도 최신순은 그나마 0.4초 정도로 빠르게 조회되었는데 나머지는 20초 30초 이렇게 조회시간이 걸려버리는 것이다..
멘붕에 빠져버렸다. 이게 맞나 싶었다
7) 기능 개선1
이 문제를 어떻게 개선하면 좋을지 나의 친구 GPT와 논의하고 검색하면서 찾은 결과로는 2가지였다.
일단 문제의 원인은 지금 조회한 쿼리에서 중복값이 나오면 조회된 값을 다시 정렬하기 때문에 발생하는 문제였던 것이였고, 이를 해결하기위해선 index를 도입하는것 그리고 2번째 정렬기준을 도입하는 것이였다.
그래서 index는 어차피 도입할 것으로 예상했기 때문에 2번째 정렬기준을 정하기위해 고민하였다..
뭘로 할까 고민하다가 Id로 두번째 정렬 기준을 정해서 querydsl의 코드를 수정하였다
이 과정에서도 하는 방법을 정확히 몰라 엄청난 시간의 소요를 쏟아 부은 끝에.. 아래처럼 동작하도록 코드를 작성하였다(이때 커밋을 안해서 코드가 안남아있다)
1. 내림차순인지 오름차순인지 결정하는 isAsc 변수의 값과 선택된 정렬 조건이 무엇인지 전달해주는 filter 변수의 값으로 where에 조건을 넣어 가져올 페이지의 데이터를 수집
2. isAsc 변수 값과 where절에 선택된 정렬조건에 따라 내림차순, 오름차순을 결정하고 정렬조건을 입력
3. 마찬가지로 위처럼 동작하지만 무조건 id로 정렬
이렇게 하고 테스트를 해보니 개선은 되었으나 여전히 좋아요 순은 12초 정도로 많은 시간이 걸렸다.
그리고 이런 테스트를 하면서 문득 이런 생각이 들었는데 어차피 두번째 정렬 조건이 글의 id값이면.. 이게 최신순, 오래된 순정렬이라는 것인데 애초에 LocalDateTime이 아니라 글의 id값으로 정렬을 해도 됬겠다는 생각이 들었다.
그리고 LocalDateTime으로 프론트엔드에서 값을 넘기면.. 애초에 타입체크를 떠나서 저 형식 자체를 넘기는거 자체가 값도 너무 길기도하고 해당 값을 받아서 사용한다는 것 자체가 단순한 정수 타입보다 오류가 발생할 여지가 많을 것 같아 좋지 않다는 생각이 들었다.
그래서 어차피 글의 등록시간이나 글이 등록되어 남겨진 primary key나 게시글의 순서는 동일하니까 모든 타입을 Long으로 변경하고 Long이 아니면 예외를 던져서 클라이언트에서 단일 타입으로 넘기도록 하는것이 좋을것 같다는 뇌피셜을 반영하기위해 수정 작업을 했다
그래서 일단 몽고 DB는 세팅이 안되었으므로 RDB에서 필드를 추가하고 나중에 코드를 고치자는 마음으로 Id, 조회 수, 좋아요 수 필드를 Long 타입으로 모두 변경하였다.
(추상화를 할까도했지만.. 지금 내실력으로 추상화하기에는 시간이 너무 오래걸릴것 같고 MongDB관련 코드를 아예 몰라서 세팅되면 그냥 코드를 고쳐야겠다고 생각했다)
그리고 공통 예외처리를 하는 핸들러 클래스에 타입 불일치 예외를 생성하였다
코드 더보기
package com.ani.taku_backend.common.exception;
@ControllerAdvice
public class GlobalExceptionHandler {
// ... 나머지 기존에 작성되어있던 공통 예외들은 생략
/**
* 파라미터 타입 불일치 예외
* ex - ?id = abc 이면 해당 예외가 반환됨
*/
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ResponseEntity<MainResponse<Void>> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException ex) {
String parameterName = ex.getName();
String requiredType = ex.getRequiredType() != null ? ex.getRequiredType().getSimpleName() : "올바른 타입";
String errorMessage = String.format("'%s' 파라미터의 값이 잘못되었습니다. (%s 타입이 필요합니다.)", parameterName, requiredType);
return ResponseEntity.badRequest().body(
new MainResponse<>(
ApiConstants.Status.ERROR,
errorMessage
)
);
}
}
8) 기능 개선2
그리고 우선 하드코딩된 정렬 기준을 Enum으로 사용하기 위해 정렬 기준을 모두 모아둔 Enum을 만들었고, 나중에 개발할 상품의 정렬 기준도 포함시켜서 개발하였다.
주석을 달아두었지만 너무 글이 길어져 여기서는 주석을 제거했다.
package com.ani.taku_backend.common.enums;
public enum SortFilterType {
LATEST("latest"),
VIEWS("views"),
LIKES("likes"),
PRICE_DESC("price_desc"),
PRICE_ASC("price_asc");
private final String value;
SortFilterType(String value) {
this.value = value;
}
public String getValue() {
return value;
}
}
그리고 Id를 기준으로 최신순, 오래된 순 정렬을 반영하는 조건을 수정하는 코드를 만들어보니 애초에 lastValue 파라미터를 Long 으로 받아버려서 형변환 하는 코드도 없어져 가독성이 매우 좋아였다
또한 최신순과 오래된순 정렬은 애초에 Id로 정렬하므로 중복값이 없어 2번째 정렬조건이 없어도 되어 코드도 줄어들게 되었다.
(이부분을 개발했을 때도 커밋하지 않아서 이단계에서의 코드가 없다)
이제 제목과 내용에서 검색하는 기능도 개발하기 위해서 keyword라는 파라미터를 추가로 정의하였고 Service에서 검증을하는 코드를 만들었다.
정규식을 이용하여 keyword의 양옆의 공백뿐만 아니라 중간의 공백도 제거된 단어로 DB의 데이터를 조회할 수 있도록 검증을 하였고, 빈문열이나 공백만 있는 문자열은 null로 반환되어 조회조건이 없어지도록 검증하였다.
package com.ani.taku_backend.post.service;
@Service
@Slf4j
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<Post> findAllPost(String filter, Long lastValue, boolean isAsc, int limit, String keyword) {
if (keyword != null && keyword.trim().isEmpty()) {
keyword = null;
} else if (keyword != null){
keyword = keyword.replaceAll("\\s+", "");
}
return postRepository.findAllPostWithNoOffset(filter, lastValue, isAsc, limit, keyword);
}
}
해당 keyword를 제목과 내용에서 포함하는 게시글을 조회되도록 하는 코드를 완성하여 최종적으로 페이징과 정렬, 검색기능이 모인 조회 쿼리가 완성되었다!
기능은 훨씬 많아졌지만 형변환 등의 코드가 사라지고 필요없는 조건문들을 삭제하면서 코드가 최적화 되었다고 생각한다.
코드 더보기
package com.ani.taku_backend.post.repository.impl;
@Repository
@RequiredArgsConstructor
public class PostRepositoryCustomImpl implements PostRepositoryCustom {
private final JPAQueryFactory jpaQueryFactory;
@Override
public List<Post> findAllPostWithNoOffset(String filter, Long lastValue, boolean isAsc, int limit, String keyword) {
QPost post = QPost.post;
BooleanExpression bySortFilter = getSortFilter(filter, lastValue, isAsc, post); // 정렬 필터
BooleanExpression byKeyword = getKeyword(keyword, post); // 키워드 검색
OrderSpecifier<?> mainSort = getMainSort(filter, isAsc, post); // 첫번째 정렬 기준
OrderSpecifier<?> subSort = getSubSort(isAsc, post); // 두번째 정렬 기준
return jpaQueryFactory
.selectFrom(post)
.where(bySortFilter, byKeyword)
.orderBy(mainSort, subSort)
.limit(limit)
.fetch();
}
private BooleanExpression getKeyword(String keyword, QPost post) {
if (keyword != null) {
return post.title.contains(keyword).or(post.content.contains(keyword));
} else {
return null;
}
}
/**
* 정렬 필터 선택
* - isAsc -> true, 오름 차순
*/
private BooleanExpression getSortFilter(String filter, Long lastValue, boolean isAsc, QPost post) {
if (SortFilterType.LIKES.getValue().equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.likes.gt(lastValue) : post.likes.lt(lastValue);
} else if (SortFilterType.VIEWS.getValue().equalsIgnoreCase(filter) && lastValue != null) {
return isAsc ? post.views.gt(lastValue) : post.views.lt(lastValue);
} else if (lastValue != null) {
return isAsc ? post.id.gt(lastValue) : post.id.lt(lastValue);
}
return null;
}
private OrderSpecifier<?> getMainSort(String filter, boolean isAsc, QPost post) {
if (SortFilterType.LIKES.getValue().equalsIgnoreCase(filter)) {
return isAsc ? post.likes.asc() : post.likes.desc();
} else if (SortFilterType.VIEWS.getValue().equalsIgnoreCase(filter)) {
return isAsc ? post.views.asc() : post.views.desc();
}
return isAsc ? post.id.asc() : post.id.desc();
}
private OrderSpecifier<?> getSubSort(boolean isAsc, QPost post) {
return isAsc ? post.id.asc() : post.id.desc();
}
}
9) 인덱스 포함된 테스트와 DTO 반환
제대로된 테스트를 진행해보기 위해 테스트 코드를 수정하고, DB에 Index를 생성하여 조회화 검색 테스트를 진행해보았다.
데이터는 100만건 그대로이지만 제목과 내용을 각각 20만건씩 다르게 입력되도록 테스트를 작성하였다.
코드 더보기
package com.ani.taku_backend.post.repository;
@SpringBootTest
class PostRepositoryTest {
@Autowired private PostRepository postRepository;
@PersistenceContext
private EntityManager entityManager;
// @Test
@Transactional
@Commit
void saveTestData() {
int totalRecords = 200000;
int batchSize = 100000;
for (int i = 1; i <= totalRecords; i++) {
Long views = ThreadLocalRandom.current().nextLong(1, 300000);
Long likes = ThreadLocalRandom.current().nextLong(1, 100000);
LocalDateTime createdTime = LocalDateTime.now().minusSeconds(totalRecords - i);
LocalDateTime updatedTime = createdTime.plusSeconds(ThreadLocalRandom.current().nextInt(0, 60));
// 데이터 생성
Post post1 = new Post(null, null, null, "title" + i, "content" + i,
createdTime, updatedTime, views, likes);
Post post2 = new Post(null, null, null, "제목" + i, "내용" + i,
createdTime, updatedTime, views, likes);
Post post3 = new Post(null, null, null, "사랑" + i, "평화" + i,
createdTime, updatedTime, views, likes);
Post post4 = new Post(null, null, null, "진실" + i, "거짓" + i,
createdTime, updatedTime, views, likes);
Post post5 = new Post(null, null, null, "very" + i, "good" + i,
createdTime, updatedTime, views, likes);
entityManager.persist(post1);
entityManager.persist(post2);
entityManager.persist(post3);
entityManager.persist(post4);
entityManager.persist(post5);
// 배치 처리
if (i % batchSize == 0) {
entityManager.flush();
entityManager.clear();
}
}
// 마지막 플러시
entityManager.flush();
entityManager.clear();
}
@Test
void noOffsetPagingTest() {
Long randomId = (long) (Math.random() * 1000000) + 1;
Post findPost = postRepository.findById(randomId).get();
latestFilterPagingTest(findPost);
viewsFilterPagingTest(findPost);
likesFilterPagingTest(findPost);
}
private void likesFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findAllPostWithNoOffset("likes", findPost.getLikes(), false, 20, "제목");
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("lastValue = " + findPost.getLikes());
System.out.println("resultTime(likes) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId() + " post.getLikes() = " + post.getLikes());
}
}
private void viewsFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findAllPostWithNoOffset("views", findPost.getViews(), false, 20, "거짓");
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("lastValue = " + findPost.getViews());
System.out.println("resultTime(views) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId() + " post.getViews() = " + post.getViews());
}
}
private void latestFilterPagingTest(Post findPost) {
long startTime = System.nanoTime();
List<Post> result = postRepository.findAllPostWithNoOffset("latest", findPost.getId(), false, 20, "사랑");
long resultTime = (System.nanoTime() - startTime) / 1000000;
System.out.println("lastValue = " + findPost.getId());
System.out.println("resultTime(latest) = " + resultTime + "ms");
for (Post post : result) {
System.out.println("post.getId() = " + post.getId());
}
}
}
테스트 결과는!!
1. 인덱스 없이 조회 시: 총 12.929초
- 최신순: 0.213초
- 조회수 순: 0.454초, 중복값으로 인한 조회 성능 저하
- 좋아요 순: 12.262초, 위와 동일
2. 인덱스 생성 후 조회 시: 총 0.112초
- 최신순: 0.098초
- 조회수 순: 0.006초, 중복값으로 인한 조회 성능 저하
- 좋아요 순: 0.008초, 위와 동일
조회에서의 인덱스의 위력을 제대로 경험해본 순간이였다. 나름대로 쿼리를 최적화한다고 노력했는데 DB의 index 한방에 백만건의 데이터에서도 0.01초만의 조회가 되는 성능을 보여주었다.
지금은 DTO가 없이 controller에서 바로 반환되어 필요없는 정보들이 클라이언트에 전송되는 문제가 있다는 것을 코드를 보면서 깨달았다!
그래서 부랴부랴 DTO를 만들고 Service 단에서 DTO로 변환되어 컨트롤러에 넘기도록하는 코드를 생성했고! 컨트롤러에서도 DTO로 클라이언트에 넘겨주는 코드를 만듦으로 인하여 중간 개발이 완료되었고 PR을 요청하였다!
package com.ani.taku_backend.post.service;
@Service
@Slf4j
@RequiredArgsConstructor
public class PostService {
private final PostRepository postRepository;
public List<PostDTO> findAllPost(String filter, Long lastValue, boolean isAsc, int limit, String keyword) {
/**
* 검증 로직
* - 공백만 있는 keyword null 처리
* - 공백 제거(양옆, 중간)
*/
if (keyword != null && keyword.trim().isEmpty()) {
keyword = null;
} else if (keyword != null){
keyword = keyword.replaceAll("\\s+", "");
}
List<Post> allPost = postRepository.findAllPostWithNoOffset(filter, lastValue, isAsc, limit, keyword);
return allPost.stream().map(post -> new PostDTO(
post.getId(),
post.getUser().getUserId(),
post.getCategory().getId(),
post.getTitle(),
post.getContent(),
post.getCreatedAt(),
post.getUpdatedAt(),
post.getViews(),
post.getLikes()
)).toList();
}
}
10) 아직 남은 기능과 정리
원래 해당 조회는 이미지와 필드와 연관관계가 있어 게시글의 목록에도 이미지가 하나씩 보여야하는데 해당 기능은 제외하고 개발하였다.
이미지 Entity를 다른 사람이 개발 중인데, 내가 괜히 Entity 만들어서 개발하는 것보다는 먼저 1차적으로 PR을 올려서 merge하고 그분이 만든 Entity를 이어받아서 추가로 개발하는 것이 좋다고 생각했다
이미지와 관련된 기능을 추가 개발하면 전반적인 모든 계층에 손을대서 수정해야 한다.
** 이부분은 뭐가 맞는지 몰라서 혹시 이런 상황에서는 어떻게 개발하는 것이 더좋은지 팁이 있다면 공유를 부탁드립니다.
그리고 몽고 DB와 연결이 되면 좋아요 수도 mongo DB에서 가져오는 것으로 코드를 수정해야하고 아직 중요한건 PR이 승인이 안났다..!
문제점을 더 지적받고 고쳐야할 문제가 많을 수 있다..
그리고 글 쓰면서 getSortFilter의 메서드도 getPage로 이름을 변경하는것이 맞겠다는 생각도 들어 다음 작업때 수정해야 할 것 같다
이거 회고쓰는데도 바로 어제와 엊그제 일인데 기억을 더듬으며 작성하는데 4시간 30분이나 걸렸다..;; 나는 느림보 거북이다..
지금까지 2틀간 이 API를 개발하는데 검색하여 알아보고 고민하고 수정하고 작성하는 시간까지 포함하여 온전히 하루에 10시간 이상씩 걸린거같다..
그렇다.. 이런 간단한걸 20시간이 걸린것이다 나는 ㅠㅠ
개발 방법에 문제가있는지 계속 고민이 필요한 것 같다.
merge가 되면 브렌치 따서 추가 작업을 이어나가기 위해 일단.. 오늘은 결제해놓은 갓 영한 선생님의 자바 강의를 수강해야겠다
'프로젝트, 개발 공부 회고 > 덕후프로젝트' 카테고리의 다른 글
게시글 CRUD API 수정 및 개발 완료, 테스트 진행과 최종 PR (3) | 2024.12.27 |
---|---|
게시글 CUD API 추가 개발 (1) | 2024.12.27 |
게시글 목록 API 만들기2, 이미지 연관관계 매핑 (0) | 2024.12.22 |