Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -113,11 +113,11 @@

## 6. 최종 성능 개선 목표 및 달성 현황 요약

| 구분 | 목표치 (Thresholds) | v1 (기존) | v4 (최종) | 결과 |
| :--- | :--- |:----------| :--- | :--- |
| **p(95) Latency** | **2.0s 미만** | 6.71s | **2.56s** | **미달 (CPU 자원 임계)** |
| **p(99) Latency** | **5.0s 미만** | **7.94s** | **3.98s** | **통과** |
| **Error Rate** | **1.0% 미만** | 0.005% | **0.009%** | **통과** |
| 구분 | 목표치 (Thresholds) | v1 (기존) | v4 (최종) | 결과 |
| :--- | :--- |:--------| :--- | :--- |
| **p(95) Latency** | **2.0s 미만** | 6.71s | **2.56s** | **미달 (CPU 자원 임계)** |
| **p(99) Latency** | **5.0s 미만** | 7.94s | **3.98s** | **통과** |
| **Error Rate** | **1.0% 미만** | 0.005% | **0.009%** | **통과** |

**향후 목표**:
로직, 인프라, 캐싱 최적화를 통해 비약적인 성능 향상을 거두었으나, 1,000 VU 환경에서 단일 인스턴스의 CPU 부하로 인해 p(95) 2.0s 목표에는 미달했습니다. 향후 **인스턴스 확장(Scale-out)** 및 **로드밸런서(ALB)** 적용을 통해 자원 부하를 분산하고 최종 목표 지표를 달성할 예정입니다.
15 changes: 10 additions & 5 deletions src/main/java/com/backend/farmon/config/RedisConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.*;

import java.time.Duration;

@Configuration
public class RedisConfig {

Expand Down Expand Up @@ -81,15 +83,18 @@ public RedisTemplate<String, String> autoSearchLogRedisTemplate() {
}

@Bean
public RedisCacheManager redisCacheManager(RedisConnectionFactory redisConnectionFactory) {
RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(java.time.Duration.ofSeconds(60))
public RedisCacheManager redisCacheManager(RedisConnectionFactory cf) {
RedisCacheConfiguration base = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofSeconds(60)) // 기본 TTL
.disableCachingNullValues()
.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

return RedisCacheManager.builder(redisConnectionFactory)
.cacheDefaults(redisCacheConfiguration)
RedisCacheConfiguration popularExpert = base.entryTtl(Duration.ofMinutes(5));

return RedisCacheManager.builder(cf)
.cacheDefaults(base)
.withCacheConfiguration("home:popularExpertColumn", popularExpert)
.build();
}
}
30 changes: 11 additions & 19 deletions src/main/java/com/backend/farmon/converter/HomeConverter.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import com.backend.farmon.domain.User;
import com.backend.farmon.dto.home.HomePostRow;
import com.backend.farmon.dto.home.HomeResponse;
import com.backend.farmon.dto.home.PopularExpertPostRow;

import java.util.List;
import java.util.Optional;
Expand All @@ -27,33 +28,24 @@ public static HomeResponse.PostListDTO toPostListDTO(List<HomePostRow> rows) {
return new HomeResponse.PostListDTO(list);
}

public static HomeResponse.PopularPostListDTO toPopularPostListDTO(List<Post> postList) {
List<HomeResponse.PopularPostDetailDTO> popularPostDetailDTOList = postList.stream()
.map(HomeConverter::toPopularPostDetailListDTO)
public static HomeResponse.PopularPostListDTO toPopularPostListDTO(List<PopularExpertPostRow> rows) {
List<HomeResponse.PopularPostDetailDTO> popularPostDetailDTOList = rows.stream()
.map(HomeConverter::toPopularPostDetailDTOF)
.toList();

return HomeResponse.PopularPostListDTO.builder()
.popularPostList(popularPostDetailDTOList)
.build();
}

public static HomeResponse.PopularPostDetailDTO toPopularPostDetailListDTO(Post post) {
public static HomeResponse.PopularPostDetailDTO toPopularPostDetailDTOF(PopularExpertPostRow row) {
return HomeResponse.PopularPostDetailDTO.builder()
.popularPostId(post.getId())
.popularPostTitle(post.getPostTitle())
.popularPostContent(post.getPostContent())
.writer(post.getUser().getUserName())
.profileImage(Optional.ofNullable(post.getUser())
.map(User::getExpert)
.map(Expert::getProfileImageUrl)
.orElse(null))
.popularPostImage(
Optional.ofNullable(post.getPostImgs())
.filter(list -> !list.isEmpty()) // 리스트가 비어 있지 않은 경우만 처리
.map(list -> list.get(0)) // 첫 번째 이미지 가져오기
.map(PostImg::getStoredFileName) // 파일명 가져오기
.orElse(null) // 없으면 null 반환
)
.popularPostId(row.getPostId())
.popularPostTitle(row.getTitle())
.popularPostContent(row.getContent())
.writer(row.getWriter())
.profileImage(row.getProfileImageUrl())
.popularPostImage(row.getFirstImageStoredFileName())
.build();
}

Expand Down
2 changes: 1 addition & 1 deletion src/main/java/com/backend/farmon/dto/home/HomePostRow.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,4 @@ public record HomePostRow(
LocalDateTime createdAt,
Long likeCount,
Long commentCount
) {}
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package com.backend.farmon.dto.home;

import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@AllArgsConstructor
@NoArgsConstructor
public class PopularExpertPostRow {
private Long postId;
private String title;
private String content;
private String writer;
private String profileImageUrl;
private String firstImageStoredFileName;
}
Comment on lines +7 to +17
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

캐시 대상 DTO로 적절한 구조입니다.

@NoArgsConstructor(Jackson 역직렬화용)와 @AllArgsConstructor(QueryDSL projection용) 조합이 적절합니다.

불변성을 보장하려면 필드에 final 키워드를 추가하고 @RequiredArgsConstructor 대신 명시적 생성자를 사용하거나, Lombok의 @Value@Jacksonized를 조합하는 방안도 고려해 보세요. 현재 구조도 동작에 문제는 없습니다.

🤖 Prompt for AI Agents
In @src/main/java/com/backend/farmon/dto/home/PopularExpertPostRow.java around
lines 7 - 17, The DTO PopularExpertPostRow should be made immutable: mark all
fields (postId, title, content, writer, profileImageUrl,
firstImageStoredFileName) as final and replace the Lombok constructor
annotations with either (a) an explicit all-args constructor plus a no-args
constructor for Jackson, or (b) use Lombok's @Value combined with @Jacksonized
to get an immutable, Jackson-friendly type; update/remove
@AllArgsConstructor/@NoArgsConstructor and keep the class-level annotations
consistent with your chosen approach (or keep the current combination if you
prefer mutability).

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

import com.backend.farmon.domain.Post;
import com.backend.farmon.dto.home.HomePostRow;
import com.backend.farmon.dto.home.PopularExpertPostRow;
import com.backend.farmon.dto.post.PostType;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -20,7 +21,7 @@ public interface PostRepositoryCustom {
List<HomePostRow> findTopPostsByPostTypeWithCounts(PostType postType, int limit);

// 인기 전문가 칼럼 6개 조회
List<Post> findTop6ExpertColumnPostsByPostId(List<Long> popularPostsIdList);
List<PopularExpertPostRow> findTopExpertColumnRowsByPopularIds(List<Long> popularPostsIdList, int limit);

// 필터링없이 조회
Page<Post> findAllByBoardId(Long boardId, Pageable pageable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
import com.backend.farmon.apiPayload.exception.GeneralException;
import com.backend.farmon.domain.*;
import com.backend.farmon.dto.home.HomePostRow;
import com.backend.farmon.dto.home.PopularExpertPostRow;
import com.backend.farmon.dto.post.PostType;
import com.backend.farmon.repository.BoardRepository.BoardRepository;
import com.querydsl.core.BooleanBuilder;
import com.querydsl.core.types.Projections;
import com.querydsl.core.types.dsl.CaseBuilder;
import com.querydsl.core.types.dsl.Expressions;
import com.querydsl.jpa.JPAExpressions;
import com.querydsl.jpa.impl.JPAQueryFactory;
Expand All @@ -20,8 +22,6 @@

import java.util.*;

import static com.backend.farmon.domain.QPostImg.postImg;

@Slf4j
@Repository
@RequiredArgsConstructor
Expand All @@ -33,8 +33,10 @@ public class PostRepositoryImpl implements PostRepositoryCustom {
private final QBoard board = QBoard.board;
private final QPost originalPost = new QPost("originalPost");
private final QComment comment = QComment.comment;

private final QCrop crop = QCrop.crop;
private final QUser user = QUser.user;
private final QExpert expert = QExpert.expert;
private final QPostImg postImg = QPostImg.postImg;

/**
* 공통 select (원본 게시글 기준) + 좋아요/댓글 count를 한 번에 가져오기 위한 프로젝션
Expand Down Expand Up @@ -142,33 +144,72 @@ public List<HomePostRow> findTopPostsByPostTypeWithCounts(PostType postType, int

// 인기 전문가 칼럼 6개 조회
@Override
public List<Post> findTop6ExpertColumnPostsByPostId(List<Long> popularPostsIdList) {
return queryFactory.select(post)
public List<PopularExpertPostRow> findTopExpertColumnRowsByPopularIds(List<Long> popularPostsIdList, int limit) {
QPostImg pi2 = new QPostImg("pi2");

boolean hasPinned = popularPostsIdList != null && !popularPostsIdList.isEmpty();

var query = queryFactory
.select(Projections.constructor(
PopularExpertPostRow.class,
post.id,
post.postTitle,
post.postContent,
user.userName,
expert.profileImageUrl,
postImg.storedFileName
))
.from(post)
.join(post.board, board).fetchJoin()
.leftJoin(post.postlikes, likeCount)
.where(
board.postType.eq(PostType.EXPERT_COLUMN) // 전문가 칼럼 조건
.and(
popularPostsIdList != null && !popularPostsIdList.isEmpty()
? post.id.in(popularPostsIdList).or(post.id.notIn(popularPostsIdList))
: null
)
)
.groupBy(post)
.orderBy(
// 인기 게시글 우선 정렬
popularPostsIdList != null && !popularPostsIdList.isEmpty()
? Expressions.stringTemplate("CASE WHEN {0} IN ({1}) THEN 1 ELSE 2 END", post.id, Expressions.constant(popularPostsIdList)).asc()
: null,
// popularPostsIdList 내부 정렬
popularPostsIdList != null && !popularPostsIdList.isEmpty()
? Expressions.stringTemplate("FIELD({0}, {1})", post.id, Expressions.constant(popularPostsIdList)).asc()
: null,
likeCount.count().desc(), // 좋아요 개수 내림차순
post.createdAt.desc() // 작성일 내림차순
)
.limit(6) // 6개 제한
.join(post.board, board)
.join(post.user, user)
.leftJoin(user.expert, expert)
.leftJoin(likeCount).on(likeCount.post.id.eq(post.id))
// 첫 이미지 1개만 LEFT JOIN
.leftJoin(postImg).on(postImg.id.eq(
JPAExpressions.select(pi2.id.min())
.from(pi2)
.where(pi2.post.id.eq(post.id))
))
.where(board.postType.eq(PostType.EXPERT_COLUMN))
.groupBy(
post.id,
post.postTitle,
post.postContent,
user.userName,
expert.profileImageUrl,
postImg.storedFileName
);

// QueryDSL orderBy에 null 전달 방지 + pinned 조건부 생성
if (hasPinned) {
// pinned 우선 정렬(1) / 나머지(2)
var pinnedFirstOrderExpr = new CaseBuilder()
.when(post.id.in(popularPostsIdList)).then(1)
.otherwise(2);

// pinned 내부 순서 유지 (MySQL: FIELD)
var pinnedInnerOrderExpr = Expressions.numberTemplate(
Integer.class,
"FIELD({0}, {1})",
post.id,
Expressions.constant(popularPostsIdList)
);
Comment on lines +191 to +196
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Check for database configurations across the project
echo "=== Database Configuration Files ==="
fd -e properties -e yaml | head -20

echo -e "\n=== Searching for datasource and database configs ==="
rg "spring.datasource|spring.jpa|h2|mysql" -t yaml -t properties -A 2

echo -e "\n=== Checking pom.xml for database dependencies ==="
if [ -f "pom.xml" ]; then
  rg "<artifactId>(h2|mysql|postgresql|mariadb|oracle)" pom.xml
fi

echo -e "\n=== Checking build.gradle for database dependencies ==="
if [ -f "build.gradle" ] || [ -f "build.gradle.kts" ]; then
  rg "(h2|mysql|postgresql|mariadb|oracle)" build.gradle* 2>/dev/null || true
fi

Repository: mmije0ng/FarmON_BE_Refactor

Length of output: 452


🏁 Script executed:

#!/bin/bash
# Search more specifically for application configuration files in src directory
echo "=== Application configuration files in src ==="
find src -name "application*.properties" -o -name "application*.yml" -o -name "application*.yaml" | sort

echo -e "\n=== Content of test application properties ==="
find src -path "*test*" -name "application*.properties" -o -path "*test*" -name "application*.yml" -o -path "*test*" -name "application*.yaml" | xargs cat 2>/dev/null || echo "No test configs found"

echo -e "\n=== Main application configurations ==="
find src/main -name "application*.properties" -o -name "application*.yml" -o -name "application*.yaml" | xargs cat 2>/dev/null || echo "No main configs found"

echo -e "\n=== Checking if PostRepositoryImpl is tested ==="
fd -e java | xargs rg -l "PostRepositoryImpl|PostRepository" | grep -i test

echo -e "\n=== Check build.gradle for test profile ==="
cat build.gradle 2>/dev/null | grep -A 5 -B 5 "test\|h2" || echo "Could not read build.gradle"

Repository: mmije0ng/FarmON_BE_Refactor

Length of output: 2073


🏁 Script executed:

#!/bin/bash
# Look at the PostRepositoryImpl file to understand the context better
echo "=== PostRepositoryImpl.java content around line 191-196 ==="
if [ -f "src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java" ]; then
  cat -n "src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java" | sed -n '180,210p'
else
  echo "File not found, searching for it..."
  find . -name "PostRepositoryImpl.java" -type f
fi

echo -e "\n=== Check if there are test files for PostRepository ==="
find . -path "*test*" -name "*PostRepository*Test*.java" -o -path "*test*" -name "*Post*RepositoryTest.java"

echo -e "\n=== Look for TestApplication or test config ==="
find . -path "*test*" -name "TestApplication.java" -o -path "*test*" -name "Test*.java" | head -10

echo -e "\n=== Check for @DataJpaTest or repository test annotations ==="
rg "@DataJpaTest|@SpringBootTest|testcontainers" -t java | head -20

Repository: mmije0ng/FarmON_BE_Refactor

Length of output: 1640


🏁 Script executed:

#!/bin/bash
# Check if there's a default test configuration when no explicit config exists
echo "=== Check for application.yml or application.properties in test resources ==="
ls -la src/test/resources/ 2>/dev/null || echo "test/resources not found"

echo -e "\n=== Look for spring profiles in test ==="
rg "spring.profiles.active|@ActiveProfiles" -t java

echo -e "\n=== Check FarmonApplicationTests.java ==="
cat -n src/test/java/com/backend/farmon/FarmonApplicationTests.java

echo -e "\n=== Search for any test that calls PostRepository methods ==="
rg -r "getPostsWithPopularByCategory|popularPostsIdList" --type=java src/test/

echo -e "\n=== Check Spring Boot default test database behavior ==="
grep -i "test.database\|datasource.url" src/test/resources/*.properties src/test/resources/*.yml 2>/dev/null || echo "No test datasource config found"

echo -e "\n=== Overall test directory structure ==="
find src/test -type f -name "*.java" -o -name "*.properties" -o -name "*.yml" | head -20

Repository: mmije0ng/FarmON_BE_Refactor

Length of output: 815


MySQL FIELD 함수 사용 - H2 테스트 환경과 호환성 문제

프로젝트의 build.gradle에 H2 데이터베이스가 포함되어 있으며, 테스트 환경에서 명시적인 datasource 설정이 없으므로 @SpringBootTest 실행 시 기본적으로 H2 인메모리 데이터베이스가 사용됩니다. MySQL 전용 함수인 FIELD({0}, {1})은 H2에서 작동하지 않아, 향후 이 코드 경로를 테스트하거나 테스트 범위를 확대할 경우 실패하게 됩니다.

  • 즉시 해결: 줄 190의 주석처럼 MySQL 종속성을 명시적으로 표시하고, 추가로 구현 내용을 명확히 하세요.
    // MySQL 전용: FIELD 함수로 pinned ID 순서 유지
    // H2 테스트 환경에서는 호환되지 않음 (현재 미테스트)
  • 장기 해결: DB 독립성이 필요하다면 QueryDSL의 CaseBuilder를 확장하여 순서를 지정하는 방식으로 대체하세요.
🤖 Prompt for AI Agents
In
@src/main/java/com/backend/farmon/repository/PostRepository/PostRepositoryImpl.java
around lines 191 - 196, The usage of MySQL-specific FIELD in
pinnedInnerOrderExpr (created via Expressions.numberTemplate in
PostRepositoryImpl) is incompatible with H2 tests; update the code near the
Expressions.numberTemplate call to add the explicit comment lines noting this is
MySQL-only (e.g., "MySQL 전용: FIELD 함수로 pinned ID 순서 유지" and "H2 테스트 환경에서는 호환되지
않음 (현재 미테스트)") so the dependency/limitation is clear, and if DB-independence is
required replace the FIELD-based ordering with a QueryDSL CaseBuilder-based
ordering implementation that reproduces the pinned ID ordering logic.


query.orderBy(
pinnedFirstOrderExpr.asc(),
pinnedInnerOrderExpr.asc(),
likeCount.id.countDistinct().desc(),
post.createdAt.desc()
);
} else {
query.orderBy(
likeCount.id.countDistinct().desc(),
post.createdAt.desc()
);
}

return query
.limit(limit)
.fetch();
}

Expand Down
Loading
Loading