커뮤니티 서비스를 구축하고 있다.
커뮤니티에 작성한 글을 최신순으로도 보여주지만, 인기순으로도 정렬하는 것도 필요했고,
이 방식을 구현하는 과정을 정리하였다.
선택한 랭킹 알고리즘
해커스 뉴스 알고리즘을 사용하여 각 게시글에 대한 점수를 계산하고 점수가 높은 순서대로 정렬하는 방법을 선택했다.
레딧 랭킹 알고리즘도 고려했으나, 레딧 시스템과 달리 '싫어요' 기능이 없고, 해커스 뉴스 알고리즘이 더 직관적이어서 선택하게 되었다.
How Hacker News ranking algorithm works
In this post I’ll try to explain how the Hacker News ranking algorithm works and how you can reuse it in your own applications. It’s a very…
medium.com
구현할 플로우 정의
인기순 정렬 + 커서기반 페이지네이션을 적용하기 위해 다음과 같은 방법을 사용하여 구현하기로 했다.
1. 게시글에 score 컬럼을 추가
2. 15분마다 score 업데이트
3. 조회 요청이 올 때 마다 점수를 기반으로 정렬하여 응답하기
이런 플로우로 구현하기로 결정한 이유는 매번 페이지네이션 요청이 있을 때마다 점수를 계산해서 돌려주는 것은 비효율적이라고 생각했기 때문이다. 또한 게시판 커뮤니티는 완전한 실시간을 보장할 필요는 없다.
문제 상황
논의를 통해 결정한 플로우 대로 구현하려고 하니 문제상황을 마주했다.
15분 마다 게시글을 full scan하고, 각 row마다 점수를 계산해 업데이트 해주어야한다.
여기엔 두 가지 문제가 있는데, full scan과 각 row 별 업데이트 이다.
full scan으로 발생할 수 있는 모든 글을 한 번에 조회하여 발생할 수 있는 메모리 초과 오류는 pageable을 통해서 해결할 수 있다. 또 필요한 정보만 불러오도록 최적화하여 더 위험을 줄일 수 있었다.
하지만 각 row별 업데이트는 쉽게 해결되지 않는 문제였다.
public void updateScore() {
LocalDateTime now = LocalDateTime.now();
int pageNumber = 1;
int pageSize = BATCH_SIZE;
while (true) {
Pageable pageable = PageRequest.of(pageNumber - 1, pageSize);
Page<Post> result = postRepository.findAll(pageable);
List<Post> postList = result.getContent()
.stream()
.filter(Post::getInUse)
.toList();
for (Post post : postList
Long id = post.getId();
int emotionCount = post.getEmotions().stream()
.filter(Emotion::getInUse)
.toList()
.size();
Double score = hackersNewsAlgorithm(emotionCount, post.getCreatedDate(), now);
post.setScore(score);
}
if (pageNumber == result.getTotalPages())
break;
pageNumber++;
}
}
만약 위와 같이 post.setScore(score)과 같이 setter로 매번 값을 업데이트해주면
jpa의 dirty Check 때문에 1000개의 글의 점수에 대한 업데이트가 필요하다면 update query가 1000개 발생하게 된다.
해결방법 찾기
bulk insert처럼 bulk update의 방법을 찾아나서기 시작했다.
1. @Modifying 어노테이션
가장 먼저 시도한 방법은 @Modifying 어노테이션을 사용하여 벌크성 수정 쿼리를 시도해보았다.
??? : 영한님께 배웠어요
@Modifying(clearAutomatically = true)
@Query("Update Post p set p.score = :score Where p.id = :id")
void updateScore(@Param("score") Double score, @Param("id") Long id);
하지만 이 방식은 우리의 프로젝트의 경우에 벌크성 수정 쿼리로 작동하지 않았다.
'자바 ORM 표준 JPA 프로그래밍' 책에 나와있는 벌크성 수정 쿼리나 @Modifying 관련해 찾은 수정 쿼리는 모두 조건식에 벌크성 성격이 들어있다.
이게 어떤 이야기냐면 아래의 SQL 문과 같이 어떠한 조건에 만족하는 '모든' row에 업데이트를 하는 경우에만 해당된다는 말이다.
Update Post p set p.score = :score Where p.id < 50
2. batch update
다른 방법들을 탐색하다 아래의 글을 발견했다.
MySQL 환경의 스프링부트에 하이버네이트 배치 설정 해보기 | 우아한형제들 기술블로그
{{item.name}} 안녕하세요. 배민상품시스템팀 권순규 입니다. 저희팀에서 하이버네이트 배치 설정을 통해 대량 insert/update 시의 속도개선을 경험하여 공유드리고자 합니다. 전체 예제 파일은 github
techblog.woowahan.com
application.yml 설정으로 하이버네이트 배치처리를 해주어 업데이트 속도를 향상시키는 방법을 소개하고 있다.
위 글에서 제시한 방법대로 applicataion.yml에 설정을 추가했다.
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
format_sql: true
default_batch_fetch_size: 100
order_updates: true
jdbc:
batch_versioned_data: true
batch_size: 100
datasource:
url: jdbc:mysql://localhost:3306/teengle?rewriteBatchedStatements=true&profileSQL=true&logger=Slf4JLogger&maxQuerySizeToLog=999999
username: username
password: password
driver-class-name: com.mysql.cj.jdbc.Driver
실행 결과 실제 DB에 발생하는 쿼리가 배치처리되어 발생하는 것을 확인할 수 있었다.
하지만 위의 글에서처럼 업데이트 값이 파라미터 형태로 전달되지 않고, update 문이 여럿 연결되어 실행되는 것을 확인할 수 있었다.
update post set content='내용',image_url='Image.png',in_use=1,last_modified_date='2024-01-22 23:43:03.060221',member_id=9901,score=0.2872 where post_id=9901;update post set content='내용',image_url='Image.png',in_use=1,last_modified_date='2024-01-22 23:43:03.06023',member_id=9902,score=0.2872 where post_id=9902; .......
3. 커스텀 쿼리 작성
조금 더 발전된 쿼리를 만들 수 없을까 계속해서 고민했다.
SQL 쿼리를 더 찾아보니, 원하던 방식으로 업데이트를 구현하려면 SQL 언어 차원에서 쿼리를 커스텀하여 작성하는 것이 필요했다.
With절을 이용하는 방법, ON DUPLICATE KEY UPDATE를 이용하는 방법이 있었지만 JPA 환경에서 적용하는 것이 불가해 다음과 같은 쿼리문을 사용해보기로 했습니다.
UPDATE users u
JOIN (
SELECT 1 as id, 'myFirstName1' as firstName
UNION ALL
SELECT 2 as id, 'myFirstName2' as firstName
UNION ALL
SELECT 3 as id, 'myFirstName3' as firstName
) a
ON u.id = a.id SET u.firstName = a.firstName;
참고
Update multiple rows in SQL with different values at once
Did you ever have a problem that you needed to update multiple rows in database, with different values? I did, and it gave me a little…
medium.com
Join문 내부를 동적으로 생성해주기위해서 StringBuilder를 활용하여 NativeQuery를 생성해주었고,
생성 완료된 쿼리를 entityManager를 통해 실행했다.
public void updateScore() {
LocalDateTime now = LocalDateTime.now();
int pageNumber = 1;
int pageSize = BATCH_SIZE;
while (true) {
Pageable pageable = PageRequest.of(pageNumber - 1, pageSize);
Page<Post> result = todayRepository.findAll(pageable);
List<Post> postList = result.getContent()
.stream()
.filter(Post::getInUse)
.toList();
int cnt = 0;
StringBuilder queryBuilder = new StringBuilder();
queryBuilder.append("UPDATE ").append("post").append(" t\n");
queryBuilder.append("JOIN (\n");
for (Post post : todayList) {
Long id = post.getId();
int emotionCount = post.getEmotions().stream()
.filter(Emotion::getInUse)
.toList()
.size();
Double score = hackersNewsAlgorithm(emotionCount, post.getCreatedDate(), now);
queryBuilder.append(String.format("SELECT %d as id, %f as score\n", id, score));
if (cnt++ < postList.size() - 1) {
queryBuilder.append("UNION ALL\n");
}
}
queryBuilder.append(") tmp\n");
queryBuilder.append("ON t.post_id = tmp.id SET t.score = tmp.score");
entityManager.createNativeQuery(queryBuilder.toString())
.executeUpdate();
entityManager.clear();
if (pageNumber == result.getTotalPages())
break;
pageNumber++;
}
}
위와 같은 방식으로 구성하였을때 다음과 같은 형태로 쿼리가 발생한다.
UPDATE
post p
JOIN
( SELECT 3901 as id, 0.000000 as score
UNION ALL
SELECT 3902 as id, 0.000000 as score
UNION ALL
SELECT 3903 as id, 0.000000 as score
(...중략...)
) tmp
ON p.post_id = tmp.id SET p.score = tmp.score
쿼리 최적화, 리팩토링을 통해서 다음과 같이 코드를 완성시켰다.
// postService.java
@Scheduled(cron = "0 */15 * ? * *")
public void updateScore() {
LocalDateTime now = LocalDateTime.now();
log.info("updateTeengleScores");
int pageNumber = 1;
int pageSize = BATCH_SIZE;
while (true) {
Pageable pageable = PageRequest.of(pageNumber - 1, pageSize);
Page<UpdateScore> result = postRepository.findAllActivePost(pageable);
List<UpdateScore> postList = result.getContent().stream().toList();
if (postList.size() == 0) break;
String updateQuery = buildUpdateQuery(postList, now);
entityManager.createNativeQuery(updateQuery)
.executeUpdate();
entityManager.clear();
if (pageNumber >= result.getTotalPages())
break;
pageNumber++;
}
}
public String buildUpdateQuery(List<UpdateScore> postList, LocalDateTime now) {
StringBuilder queryBuilder = new StringBuilder();
queryBuilder.append("UPDATE ").append("post").append(" t\n");
queryBuilder.append("JOIN (\n");
for (Post post : todayList) {
Long id = post.getId();
int emotionCount = post.getEmotions().stream()
.filter(Emotion::getInUse)
.toList()
.size();
Double score = hackersNewsAlgorithm(emotionCount, post.getCreatedDate(), now);
queryBuilder.append(String.format("SELECT %d as id, %f as score\n", id, score));
if (cnt++ < postList.size() - 1) {
queryBuilder.append("UNION ALL\n");
}
}
queryBuilder.append(") tmp\n");
queryBuilder.append("ON t.post_id = tmp.id SET t.score = tmp.score");
return queryBuilder.toString();
}
// UpdateScore.java
public interface UpdateScore {
Long getId();
LocalDateTime getCreatedDate();
}
// PostRepository
@Repository
public interface PostRepository extends JpaRepository<Post, Long> {
@Query("SELECT p.id as id, p.createdDate as createdDate FROM Post p WHERE p.inUse = true")
Page<UpdateScore> findAllActivePost(Pageable pageable);
}
테스트코드를 통한 처리 시간 비교
테스트코드로 10,000건의 게시글 생성 후 데이터를 업데이트 처리를 하는 과정을 실행하여 실행시간을 비교해보았다.
한 건씩 처리하는 경우와 배치 업데이트 하는 경우의 테스트 코드
@Test
@DisplayName("점수 업데이트 : 배치 업데이트")
public void 점수_업데이트_batch_update() {
// given
List<Member> members = new ArrayList<>();
List<Post> posts = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < 10000; i++) {
Member member = new Member("id" + i, "KAKAO");
members.add(member);
PostRequestDto dto = new PostRequestDto();
dto.content = "내용";
S3ImageDto image = new S3ImageDto("Image.png", 1);
Post post = new Post(member, dto, image);
posts.add(post);
}
memberRepository.saveAll(members);
postRepository.saveAll(posts);
System.out.println("update score");
for (Post post : posts) {
Double score = hackersNewsAlgorithm(1, post.getCreatedDate(), now);
post.setScore(score);
}
postRepository.saveAll(todays);
}
쿼리 조작으로 배치업데이트 하는 경우의 테스트 코드
@Test
@DisplayName("점수 업데이트 시간 : bulk update")
public void 점수_업데이트_bulk_update() {
// given
List<Member> members = new ArrayList<>();
List<Post> posts = new ArrayList<>();
for (int i = 0; i < 10000; i++) {
Member member = new Member("id" + i, "KAKAO");
members.add(member);
PostRequestDto dto = new PostRequestDto();
dto.content = "내용";
S3ImageDto image = new S3ImageDto("Image.png", 1);
Post post = new Post(member, dto, image);
posts.add(post);
}
memberRepository.saveAll(members);
postRepository.saveAll(posts);
postService.updateScore(); // 쿼리 조작하는 함수
}
( 10,000명의 member와 10,000건의 게시글 생성에 걸리는 시간 : 약 2sec)
1. 한 건씩 업데이트 하는 경우 - 27sec 594ms 소요
2. batch 처리하여 업데이트 하는 경우 - 12sec 52ms 소요
3. 쿼리문을 작성하여 처리하는 경우 - 6sec 139ms 소요
1건씩 업데이트가 실행되는 경우에 비해 77.72% 개선된 결과를 얻을 수 있었다!!!!!
'Projects' 카테고리의 다른 글
Swap 메모리의 힘은 대단했다 (0) | 2024.03.09 |
---|---|
게시글 추천 시스템 도입을 고려하며 조사한 내용 (0) | 2024.02.04 |
Querydsl을 이용한 커서 기반 페이지네이션 구현기 (0) | 2024.01.20 |
[코드실행기능 개발기 #3] 코드 실행 서버 부하테스트 (1) | 2023.12.17 |
NestJS에 winston으로 로그 남기기 (0) | 2023.12.17 |