Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

지난 주의 인기 프로젝트 조회 성능 개선 #258

Merged
merged 19 commits into from
Oct 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
1f51829
chore: prod 대신 main 브랜치로 변경
uijin-j Oct 2, 2024
fdf12b4
chore: 설정 정보 변경 반영
uijin-j Oct 5, 2024
1ce82db
chore: 레디스 설정 정보 변경 반영
uijin-j Oct 6, 2024
d79ef5f
chore: 레디스 설정 정보 변경사항 반영
uijin-j Oct 6, 2024
d2426c8
rename: getAllPopularThisWeek → getWeeklyPopular 메서드명 변경
uijin-j Oct 8, 2024
d0284fc
refactor: 책임 분리를 위해 인기(통계) 관련 쿼리를 따로 분리 (PopularProjectRepository)
uijin-j Oct 8, 2024
6492f79
docs: UI에 의존적인 설명 변경 ('배너용' 프로젝트 응답 → 인기 프로젝트 응답)
uijin-j Oct 8, 2024
aee43cc
refactor: public 메서드가 private 메서드보다 먼저 나오도록 변경
uijin-j Oct 8, 2024
c1d2b9d
refactor: 유틸 메서드 사용 시 클래스명을 명시해 가독성 향상
uijin-j Oct 8, 2024
e1319cb
test: 주석 추가 및 좋아요가 많은 순서대로 인기 프로젝트가 조회되는 테스트 케이스 추가
uijin-j Oct 8, 2024
1494665
chore: 더미 데이터 추가 및 수정
uijin-j Oct 8, 2024
063cd64
rename: List를 바로 반환하는 것이 아닌 객체를 반환하도록 ProjectBannerResponse 변경
uijin-j Oct 8, 2024
36fb27a
feat: DateUtils 클래스에 getLastDayOfWeek() 메서드 추가
uijin-j Oct 8, 2024
b084f35
chore: RedisConfig 수정
uijin-j Oct 8, 2024
28f9674
refactore: 인기 프로젝트 조회 기능에 캐시 적용
uijin-j Oct 8, 2024
c53368a
chore: 더미 데이터 추가 및 수정
uijin-j Oct 8, 2024
de963fe
refactor: 불필요한 로그 제거
uijin-j Oct 8, 2024
9c71cc3
style: 불필요한 공백 제거
uijin-j Oct 8, 2024
71daaca
test: 단위 테스트가 끝날 때마다 캐시를 비우도록 변경
uijin-j Oct 8, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/cd.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ name: SidePeek CD with Gradle
on:
push:
branches:
- prod
- main
- dev

jobs:
Expand Down
2 changes: 1 addition & 1 deletion sidepeek_backend_secret
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute;
import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest;
Expand Down Expand Up @@ -79,7 +78,7 @@ ResponseEntity<CursorPaginationResponse<ProjectListResponse>> getByCondition(
@ApiResponse(responseCode = "200", description = OK_DESCRIPTION,
useReturnTypeSchema = true)
})
ResponseEntity<List<ProjectBannerResponse>> getAllPopularThisWeek();
ResponseEntity<ProjectBannerResponse> getWeeklyPopular();

@Operation(summary = "프로젝트 수정", description = "작성자와 등록된 프로젝트 회원 멤버만 수정 가능, 로그인 필수")
@ApiResponses({
Expand Down
9 changes: 7 additions & 2 deletions src/main/java/sixgaezzang/sidepeek/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ public RedisConnectionFactory redisConnectionFactory() {
}

@Bean
public RedisTemplate<?, ?> redisTemplate(RedisConnectionFactory redisConnectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory redisConnectionFactory
) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(redisConnectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new StringRedisSerializer());

return template;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
Expand Down Expand Up @@ -72,10 +71,13 @@ public ResponseEntity<CursorPaginationResponse<ProjectListResponse>> getByCondit
return ResponseEntity.ok().body(responses);
}

/**
* 지난 주 인기 프로젝트 조회 API
*/
@Override
@GetMapping("/weekly")
public ResponseEntity<List<ProjectBannerResponse>> getAllPopularThisWeek() {
List<ProjectBannerResponse> responses = projectService.findAllPopularLastWeek();
public ResponseEntity<ProjectBannerResponse> getWeeklyPopular() {
ProjectBannerResponse responses = projectService.findAllPopularLastWeek();

return ResponseEntity.ok(responses);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,29 +1,18 @@
package sixgaezzang.sidepeek.projects.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.Builder;
import sixgaezzang.sidepeek.projects.domain.Project;

@Schema(description = "배너용 프로젝트 응답")
@Schema(description = "인기 프로젝트 리스트 응답")
@Builder
public record ProjectBannerResponse(
@Schema(description = "프로젝트 식별자", example = "1")
Long id,
@Schema(description = "프로젝트 제목", example = "사이드픽👀")
String name,
@Schema(description = "프로젝트 부제목, 없으면 빈 문자열 반환", example = "요즘 사이드 플젝 뭐함? 사이드픽 \uD83D\uDC40")
String subName,
@Schema(description = "프로젝트 썸네일 이미지 URL, 없으면 빈 문자열 반환", example = "https://sidepeek.image/imageeUrl")
String thumbnailUrl
List<ProjectSummary> projects
) {

public static ProjectBannerResponse from(Project project) {
public static ProjectBannerResponse from(List<ProjectSummary> projects) {
return ProjectBannerResponse.builder()
.id(project.getId())
.name(project.getName())
.subName(project.getSubName())
.thumbnailUrl(project.getThumbnailUrl())
.projects(projects)
.build();
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package sixgaezzang.sidepeek.projects.dto.response;

import io.swagger.v3.oas.annotations.media.Schema;
import sixgaezzang.sidepeek.projects.domain.Project;

@Schema(description = "인기 프로젝트 정보")
public record ProjectSummary(
@Schema(description = "프로젝트 식별자", example = "1")
Long id,
@Schema(description = "프로젝트 제목", example = "사이드픽👀")
String name,
@Schema(description = "프로젝트 부제목, 없으면 빈 문자열 반환", example = "요즘 사이드 플젝 뭐함? 사이드픽 \uD83D\uDC40")
String subName,
@Schema(description = "프로젝트 썸네일 이미지 URL, 없으면 빈 문자열 반환", example = "https://sidepeek.image/imageeUrl")
String thumbnailUrl
) {

public static ProjectSummary from(Project project) {
return new ProjectSummary(
project.getId(),
project.getName(),
project.getSubName(),
project.getThumbnailUrl()
);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package sixgaezzang.sidepeek.projects.repository.project;

import java.time.LocalDate;
import java.util.List;
import sixgaezzang.sidepeek.projects.dto.response.ProjectSummary;

public interface PopularProjectRepository {

List<ProjectSummary> findRankBetweenPeriod(LocalDate startDate, LocalDate endDate,
int count);
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,10 @@
package sixgaezzang.sidepeek.projects.repository.project;

import java.time.LocalDate;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest;
import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse;
import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse;
import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse;
import sixgaezzang.sidepeek.users.domain.User;

Expand All @@ -16,9 +14,6 @@ CursorPaginationResponse<ProjectListResponse> findByCondition(
List<Long> likedProjectIds,
FindProjectRequest request);

List<ProjectBannerResponse> findAllPopularOfPeriod(LocalDate startDate, LocalDate endDate,
int count);

Page<ProjectListResponse> findAllByUserJoined(List<Long> likedProjectIds, User user,
Pageable pageable);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,11 @@

import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.DateTemplate;
import com.querydsl.core.types.dsl.EntityPathBase;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQuery;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.Arrays;
import java.util.List;
import java.util.Objects;
Expand All @@ -27,7 +25,6 @@
import sixgaezzang.sidepeek.projects.dto.request.FindProjectRequest;
import sixgaezzang.sidepeek.projects.dto.request.SortType;
import sixgaezzang.sidepeek.projects.dto.response.CursorPaginationResponse;
import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse;
import sixgaezzang.sidepeek.projects.dto.response.ProjectListResponse;
import sixgaezzang.sidepeek.users.domain.User;

Expand Down Expand Up @@ -116,6 +113,24 @@ public Page<ProjectListResponse> findAllByUserCommented(List<Long> likedProjectI
likedProjectIds);
}

public BooleanExpression getSkillCondition(List<String> skillNames) {
if (Objects.isNull(skillNames) || skillNames.isEmpty()) {
return null;
}

// 프로젝트 ID 서브쿼리를 생성하여 스킬을 모두 포함하는 프로젝트를 찾기
JPAQuery<Long> projectHasSkillsSubQuery = queryFactory
.select(projectSkill.project.id)
.from(projectSkill)
.join(projectSkill.skill)
.where(projectSkill.skill.name.in(skillNames))
.groupBy(projectSkill.project.id)
.having(projectSkill.project.id.count().eq(Expressions.constant(skillNames.size())));

// 프로젝트 ID 서브쿼리와 매칭되는 프로젝트를 찾는 조건을 반환합니다.
return project.id.in(projectHasSkillsSubQuery);
}

private Page<ProjectListResponse> findPageByCondition(EntityPathBase<?> from,
QProject join, BooleanExpression condition, Pageable pageable,
List<Long> likedProjectIds) {
Expand Down Expand Up @@ -175,27 +190,6 @@ private List<ProjectListResponse> toProjectListResponseList(List<Long> likedProj
.toList();
}

@Override
public List<ProjectBannerResponse> findAllPopularOfPeriod(LocalDate startDate,
LocalDate endDate, int count) {
DateTemplate<LocalDate> createdAt = Expressions.dateTemplate(
LocalDate.class, "DATE_FORMAT({0}, {1})", like.createdAt, "%Y-%m-%d");

List<Project> projects = queryFactory
.select(project)
.from(like)
.join(like.project, project)
.where(createdAt.between(startDate, endDate))
.groupBy(project)
.orderBy(like.count().desc())
.limit(count)
.fetch();

return projects.stream()
.map(ProjectBannerResponse::from)
.toList();
}

private BooleanExpression getCursorCondition(SortType sort, Long lastProjectId,
Long lastOrderCount) {
if (lastProjectId == null && lastOrderCount == null) { // 첫 번째 페이지
Expand Down Expand Up @@ -226,24 +220,6 @@ private BooleanExpression getSearchCondition(String search) {
.or(member.nickname.likeIgnoreCase(keyword));
}

public BooleanExpression getSkillCondition(List<String> skillNames) {
if (Objects.isNull(skillNames) || skillNames.isEmpty()) {
return null;
}

// 프로젝트 ID 서브쿼리를 생성하여 스킬을 모두 포함하는 프로젝트를 찾기
JPAQuery<Long> projectHasSkillsSubQuery = queryFactory
.select(projectSkill.project.id)
.from(projectSkill)
.join(projectSkill.skill)
.where(projectSkill.skill.name.in(skillNames))
.groupBy(projectSkill.project.id)
.having(projectSkill.project.id.count().eq(Expressions.constant(skillNames.size())));

// 프로젝트 ID 서브쿼리와 매칭되는 프로젝트를 찾는 조건을 반환합니다.
return project.id.in(projectHasSkillsSubQuery);
}

private OrderSpecifier<?> getOrderSpecifier(SortType sort) {
switch (sort) {
case like:
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package sixgaezzang.sidepeek.projects.repository.project;

import static sixgaezzang.sidepeek.like.domain.QLike.like;
import static sixgaezzang.sidepeek.projects.domain.QProject.project;

import com.querydsl.core.types.dsl.DateTemplate;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
import jakarta.persistence.EntityManager;
import java.time.LocalDate;
import java.util.List;
import org.springframework.stereotype.Repository;
import sixgaezzang.sidepeek.projects.domain.Project;
import sixgaezzang.sidepeek.projects.dto.response.ProjectSummary;

@Repository
public class QuerydslPopularProjectRepository implements PopularProjectRepository {

private final JPAQueryFactory queryFactory;

public QuerydslPopularProjectRepository(EntityManager em) {
this.queryFactory = new JPAQueryFactory(em);
}

/**
* 특정 기간 동안 인기 프로젝트를 조회합니다.
* - 기준
* 1. 해당 기간 동안 받은 좋아요 수
* 2. 전체 좋아요 수
* 3. 최신 순
*/
@Override
public List<ProjectSummary> findRankBetweenPeriod(
LocalDate startDate,
LocalDate endDate,
int count
) {
DateTemplate<LocalDate> createdAt = Expressions.dateTemplate(
LocalDate.class, "DATE_FORMAT({0}, {1})", like.createdAt, "%Y-%m-%d");

List<Project> projects = queryFactory
.select(project)
.from(like)
.join(like.project, project)
.where(createdAt.between(startDate, endDate))
.groupBy(project)
.orderBy(like.count().desc())
.limit(count)
.fetch();

return projects.stream()
.map(ProjectSummary::from)
.toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
package sixgaezzang.sidepeek.projects.service;

import static org.apache.commons.lang3.StringUtils.isBlank;

import com.fasterxml.jackson.databind.ObjectMapper;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ValueOperations;
import org.springframework.stereotype.Service;
import sixgaezzang.sidepeek.projects.dto.response.ProjectBannerResponse;

@Service
@RequiredArgsConstructor
@Slf4j
public class PopularProjectCacheService {

private static final String WEEKLY_POPULAR_CACHE_KEY = "popularProjectsLastWeek";
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;

// 캐시에서 인기 프로젝트를 조회
public Optional<ProjectBannerResponse> getPopularProjects() {
String value = redisTemplate.opsForValue().get(WEEKLY_POPULAR_CACHE_KEY);

if (isBlank(value)) {
return Optional.empty();
}

try {
return Optional.of(objectMapper.readValue(value.trim(), ProjectBannerResponse.class));
} catch (Exception e) {
throw new RuntimeException("캐시에서 데이터를 가져오는데 실패하였습니다.");
}
}

// 캐시에 인기 프로젝트를 저장하고 TTL 설정
public void putPopularProjects(ProjectBannerResponse data, LocalDateTime expireTime) {
ValueOperations<String, String> valueOps = redisTemplate.opsForValue();

try {
String value = objectMapper.writeValueAsString(data);
valueOps.set(WEEKLY_POPULAR_CACHE_KEY, value, calculateTTL(expireTime));
} catch (Exception e) {
throw new RuntimeException("캐시에 데이터를 저장하는데 실패하였습니다.");
}
}

private long calculateTTL(LocalDateTime expireTime) {
LocalDateTime now = LocalDateTime.now(); // 현재 시각

// 현재 시각과 lastDayOfWeek 자정 사이의 차이를 계산
Duration duration = Duration.between(now, expireTime);

return duration.getSeconds();
}
}
Loading
Loading