Skip to content

Commit 1ae2869

Browse files
committed
성능 병목 개선 및 부하 테스트 안정화
1 parent 5feda39 commit 1ae2869

25 files changed

Lines changed: 1156 additions & 215 deletions

Dockerfile

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ RUN gradle bootJar --no-daemon -x test
1717
# ─────────────────────────────────────────
1818
# Stage 2: Run
1919
# ─────────────────────────────────────────
20-
FROM eclipse-temurin:17-jre-alpine
20+
FROM eclipse-temurin:17-jre-jammy
2121

2222
WORKDIR /app
2323

2424
# 타임존 + curl (healthcheck 용)
25-
RUN apk add --no-cache tzdata curl \
25+
RUN apt-get update \
26+
&& apt-get install -y --no-install-recommends tzdata curl \
2627
&& cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime \
2728
&& echo "Asia/Seoul" > /etc/timezone \
28-
&& apk del tzdata
29+
&& rm -rf /var/lib/apt/lists/*
2930

3031
# 빌드 산출물만 복사
3132
COPY --from=builder /app/build/libs/*.jar app.jar
@@ -35,4 +36,11 @@ EXPOSE 8080
3536
HEALTHCHECK --interval=15s --timeout=5s --retries=5 --start-period=60s \
3637
CMD curl -f http://localhost:8080/actuator/health || exit 1
3738

38-
ENTRYPOINT ["java", "-jar", "-Dspring.profiles.active=prod", "app.jar"]
39+
ENV SPRING_PROFILES_ACTIVE=prod
40+
# 로컬/배포 컨테이너에서 JVM이 호스트 메모리를 과하게 잡지 않도록 heap 상한을 명시합니다.
41+
# JAVA_TOOL_OPTIONS는 java 실행 시 자동 적용되므로 ENTRYPOINT가 바뀌어도 기본 안전장치로 남습니다.
42+
ENV JAVA_TOOL_OPTIONS="-Xms256m -Xmx768m -XX:+UseG1GC -XX:+ExitOnOutOfMemoryError"
43+
# 실제 운영 리소스가 다르면 JAVA_OPTS로 추가 JVM 옵션을 넘깁니다.
44+
ENV JAVA_OPTS=""
45+
46+
ENTRYPOINT ["sh", "-c", "exec java $JAVA_OPTS -Dspring.profiles.active=$SPRING_PROFILES_ACTIVE -jar app.jar"]

src/main/java/com/Rootin/domain/dashboard/service/DashboardService.java

Lines changed: 104 additions & 85 deletions
Large diffs are not rendered by default.

src/main/java/com/Rootin/domain/gamification/repository/PointLogRepository.java

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import com.Rootin.domain.gamification.entity.enums.PointLogReason;
55
import org.springframework.data.domain.Page;
66
import org.springframework.data.jpa.repository.JpaRepository;
7+
import org.springframework.data.jpa.repository.Modifying;
78
import org.springframework.data.jpa.repository.Query;
89
import org.springframework.data.repository.query.Param;
910

@@ -14,6 +15,12 @@
1415

1516
public interface PointLogRepository extends JpaRepository<PointLog, Long> {
1617

18+
interface PointSummaryProjection {
19+
Integer getCurrentPoint();
20+
Long getTotalEarned();
21+
Long getTotalUsed();
22+
}
23+
1724
// 포인트 이력 목록 - 페이징
1825
Page<PointLog> findByUserIdOrderByCreatedAtDesc(Long userId, Pageable pageable);
1926

@@ -38,13 +45,33 @@ Set<PointLogReason> findQuestReasonsByUserIdAndAwardedDate(
3845
@Param("questReasons") Set<PointLogReason> questReasons
3946
);
4047

41-
// MT-02 포인트 현황 - 총 적립 포인트 (양수 합산)
42-
@Query("SELECT COALESCE(SUM(pl.amount), 0) FROM PointLog pl " +
43-
"WHERE pl.user.id = :userId AND pl.amount > 0")
44-
int sumEarnedByUserId(@Param("userId") Long userId);
48+
// MT-01 오늘의 목표 - 동시 요청에서 유니크 제약 충돌을 실패로 만들지 않고 먼저 성공한 1건만 지급 처리
49+
// MySQL 전용 쿼리입니다. INSERT IGNORE는 중복 키에서 0을 반환하므로 호출부가 첫 지급 여부를 안정적으로 구분할 수 있습니다.
50+
// uk_point_log_user_reason_date 유니크 인덱스가 중복 지급의 DB 레벨 방어선입니다.
51+
@Modifying
52+
@Query(value = """
53+
INSERT IGNORE INTO point_log (user_id, reason, amount, awarded_date, created_at)
54+
VALUES (:userId, :reason, :amount, :awardedDate, NOW())
55+
""", nativeQuery = true)
56+
int insertQuestLogIfAbsent(
57+
@Param("userId") Long userId,
58+
@Param("reason") String reason,
59+
@Param("amount") int amount,
60+
@Param("awardedDate") LocalDate awardedDate
61+
);
4562

46-
// MT-02 포인트 현황 - 총 적립 포인트 (음수 합산)
47-
@Query("SELECT COALESCE(SUM(pl.amount), 0) FROM PointLog pl " +
48-
"WHERE pl.user.id = :userId AND pl.amount < 0")
49-
int sumUsedByUserId(@Param("userId") Long userId);
63+
long countByUserIdAndReasonAndAwardedDate(Long userId, PointLogReason reason, LocalDate awardedDate);
64+
65+
// MT-02 포인트 현황 - 현재 포인트와 적립/사용 합계를 한 번에 조회
66+
@Query(value = """
67+
SELECT
68+
u.point AS currentPoint,
69+
COALESCE(SUM(CASE WHEN pl.amount > 0 THEN pl.amount ELSE 0 END), 0) AS totalEarned,
70+
COALESCE(SUM(CASE WHEN pl.amount < 0 THEN -pl.amount ELSE 0 END), 0) AS totalUsed
71+
FROM users u
72+
LEFT JOIN point_log pl ON pl.user_id = u.id
73+
WHERE u.id = :userId
74+
GROUP BY u.id, u.point
75+
""", nativeQuery = true)
76+
java.util.Optional<PointSummaryProjection> summarizeByUserId(@Param("userId") Long userId);
5077
}

src/main/java/com/Rootin/domain/gamification/service/PointService.java

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import com.Rootin.domain.gamification.dto.PointLogResponse;
44
import com.Rootin.domain.gamification.dto.PointSummaryResponse;
55
import com.Rootin.domain.gamification.repository.PointLogRepository;
6-
import com.Rootin.domain.user.repository.UserRepository;
6+
import com.Rootin.domain.gamification.repository.PointLogRepository.PointSummaryProjection;
77
import com.Rootin.global.exception.CustomException;
88
import com.Rootin.global.exception.ErrorCode;
99
import lombok.RequiredArgsConstructor;
@@ -18,14 +18,13 @@
1818
public class PointService {
1919

2020
private final PointLogRepository pointLogRepository;
21-
private final UserRepository userRepository;
2221

2322
public PointSummaryResponse getPointSummary(Long userId) {
24-
int currentPoint = userRepository.findById(userId)
25-
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND))
26-
.getPoint();
27-
int totalEarned = pointLogRepository.sumEarnedByUserId(userId);
28-
int totalUsed = Math.abs(pointLogRepository.sumUsedByUserId(userId));
23+
PointSummaryProjection summary = pointLogRepository.summarizeByUserId(userId)
24+
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
25+
int currentPoint = summary.getCurrentPoint() != null ? summary.getCurrentPoint() : 0;
26+
int totalEarned = toSafeInt(summary.getTotalEarned());
27+
int totalUsed = toSafeInt(summary.getTotalUsed());
2928

3029
return new PointSummaryResponse(currentPoint, totalEarned, totalUsed);
3130
}
@@ -34,4 +33,10 @@ public Page<PointLogResponse> getPointHistory(Long userId, Pageable pageable) {
3433
return pointLogRepository.findByUserIdOrderByCreatedAtDesc(userId, pageable)
3534
.map(PointLogResponse::from);
3635
}
36+
37+
private int toSafeInt(long value) {
38+
if (value > Integer.MAX_VALUE) return Integer.MAX_VALUE;
39+
if (value < Integer.MIN_VALUE) return Integer.MIN_VALUE;
40+
return (int) value;
41+
}
3742
}

src/main/java/com/Rootin/domain/garden/repository/PotRepository.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.Rootin.domain.garden.repository;
22

33
import com.Rootin.domain.garden.entity.Pot;
4+
import com.Rootin.domain.til.entity.PostStatus;
45
import jakarta.persistence.LockModeType;
56
import org.springframework.data.jpa.repository.JpaRepository;
67
import org.springframework.data.jpa.repository.Lock;
@@ -13,10 +14,30 @@
1314

1415
@Repository
1516
public interface PotRepository extends JpaRepository<Pot, Long> {
17+
18+
interface PotTilDistributionProjection {
19+
Long getPotId();
20+
String getPotTitle();
21+
Long getTilCount();
22+
}
1623

1724
// 특정 사용자가 생성한 화분 목록을 조회하기 위한 메소드
1825
List<Pot> findByUserId(Long userId);
1926

27+
@Query("""
28+
SELECT p.id AS potId,
29+
p.title AS potTitle,
30+
COUNT(t) AS tilCount
31+
FROM Pot p
32+
LEFT JOIN Til t ON t.pot = p AND t.user.id = :userId AND t.status = :status
33+
WHERE p.userId = :userId
34+
GROUP BY p.id, p.title
35+
""")
36+
List<PotTilDistributionProjection> findTilDistributionByUserId(
37+
@Param("userId") Long userId,
38+
@Param("status") PostStatus status
39+
);
40+
2041
// 동시 작성 시 화분 경험치/레벨의 유실을 방지하기 위한 비관적 락 조회 메소드
2142
@Lock(LockModeType.PESSIMISTIC_WRITE)
2243
@Query("SELECT p FROM Pot p WHERE p.id = :id")

src/main/java/com/Rootin/domain/garden/repository/WateringLogRepository.java

Lines changed: 85 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,31 @@
1010

1111
import java.time.LocalDateTime;
1212
import java.util.List;
13+
import java.util.Optional;
1314

1415
/**
1516
* WateringLog 엔티티를 관리하기 위한 Spring Data JPA Repository 인터페이스입니다.
1617
*/
1718
@Repository
1819
public interface WateringLogRepository extends JpaRepository<WateringLog, Long> {
1920

21+
interface WateringLogDailyAggregateProjection {
22+
java.sql.Date getWateredDate();
23+
long getTilCount();
24+
long getContentLength();
25+
}
26+
27+
interface WateringLogAggregateProjection {
28+
long getTilCount();
29+
long getContentLength();
30+
}
31+
32+
interface DashboardPersonalOverviewProjection {
33+
long getTotalTilCount();
34+
long getTotalContentLength();
35+
Integer getCurrentPoints();
36+
}
37+
2038
/**
2139
* 특정 화분에 기록된 모든 물주기 이력 목록을 조회합니다.
2240
*
@@ -50,24 +68,79 @@ public interface WateringLogRepository extends JpaRepository<WateringLog, Long>
5068
*/
5169
java.util.Optional<WateringLog> findFirstByUserIdAndPotIdOrderByWateredAtDesc(Long userId, Long potId);
5270

53-
// 개인 통계용 - 사용자 전체 물주기 이력
54-
List<WateringLog> findAllByUserId(Long userId);
71+
@Query(value = """
72+
SELECT watered_at
73+
FROM watering_log
74+
WHERE user_id = :userId
75+
AND pot_id = :potId
76+
ORDER BY watered_at DESC
77+
LIMIT 1
78+
""", nativeQuery = true)
79+
Optional<LocalDateTime> findLatestWateredAtByUserIdAndPotId(
80+
@Param("userId") Long userId,
81+
@Param("potId") Long potId
82+
);
83+
84+
@Query(value = """
85+
SELECT
86+
(SELECT COUNT(*)
87+
FROM posts p
88+
JOIN til t ON t.post_id = p.id
89+
WHERE p.user_id = u.id
90+
AND p.status = :publishedStatus) AS totalTilCount,
91+
(SELECT COALESCE(SUM(w.content_length), 0)
92+
FROM watering_log w
93+
WHERE w.user_id = u.id) AS totalContentLength,
94+
u.point AS currentPoints
95+
FROM users u
96+
WHERE u.id = :userId
97+
""", nativeQuery = true)
98+
Optional<DashboardPersonalOverviewProjection> findPersonalOverviewByUserId(
99+
@Param("userId") Long userId,
100+
@Param("publishedStatus") String publishedStatus
101+
);
102+
103+
@Query(value = """
104+
SELECT DISTINCT DATE(watered_at)
105+
FROM watering_log
106+
WHERE user_id = :userId
107+
""", nativeQuery = true)
108+
List<java.sql.Date> findDistinctWateredDatesByUserId(@Param("userId") Long userId);
55109

56110
// 성장 이력 차트용 - 최근 30건
57111
List<WateringLog> findTop30ByUserIdOrderByWateredAtDesc(Long userId);
58112

59-
// 활동 캘린더용 - 기간별 물주기 이력 (inclusive BETWEEN)
60-
List<WateringLog> findByUserIdAndWateredAtBetween(
61-
Long userId,
62-
LocalDateTime from,
63-
LocalDateTime to
113+
@Query(value = """
114+
SELECT DATE(watered_at) AS wateredDate,
115+
COUNT(*) AS tilCount,
116+
COALESCE(SUM(content_length), 0) AS contentLength
117+
FROM watering_log
118+
WHERE user_id = :userId
119+
AND watered_at >= :from
120+
AND watered_at < :to
121+
GROUP BY DATE(watered_at)
122+
ORDER BY wateredDate
123+
""", nativeQuery = true)
124+
List<WateringLogDailyAggregateProjection> aggregateDailyByUserIdAndWateredAtRange(
125+
@Param("userId") Long userId,
126+
@Param("from") LocalDateTime from,
127+
@Param("to") LocalDateTime to
64128
);
65129

66-
// 퀘스트용 - 반열린 구간 [from, to) 조회. datetime(6) microsecond 누락 방지
67-
List<WateringLog> findByUserIdAndWateredAtGreaterThanEqualAndWateredAtLessThan(
68-
Long userId,
69-
LocalDateTime from,
70-
LocalDateTime to
130+
long countByUserIdAndWateredAtGreaterThanEqualAndWateredAtLessThan(Long userId, LocalDateTime from, LocalDateTime to);
131+
132+
@Query("""
133+
SELECT COUNT(w) AS tilCount,
134+
COALESCE(SUM(w.contentLength), 0) AS contentLength
135+
FROM WateringLog w
136+
WHERE w.userId = :userId
137+
AND w.wateredAt >= :from
138+
AND w.wateredAt < :to
139+
""")
140+
WateringLogAggregateProjection aggregateByUserIdAndWateredAtGreaterThanEqualAndWateredAtLessThan(
141+
@Param("userId") Long userId,
142+
@Param("from") LocalDateTime from,
143+
@Param("to") LocalDateTime to
71144
);
72145

73146
// 식물 성장 단계 날짜 계산용 — 특정 시점 이후 해당 화분의 물주기 이력 (시간순)

src/main/java/com/Rootin/domain/garden/service/GardenDashboardService.java

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import com.Rootin.domain.garden.dto.PlantInfoResponse;
55
import com.Rootin.domain.garden.entity.PlantItem;
66
import com.Rootin.domain.garden.entity.Pot;
7-
import com.Rootin.domain.garden.entity.WateringLog;
87
import com.Rootin.domain.garden.repository.PlantItemRepository;
98
import com.Rootin.domain.garden.repository.PotRepository;
109
import com.Rootin.domain.garden.repository.WateringLogRepository;
@@ -19,6 +18,7 @@
1918
import org.springframework.stereotype.Service;
2019
import org.springframework.transaction.annotation.Transactional;
2120

21+
import java.time.LocalDate;
2222
import java.time.LocalDateTime;
2323
import java.util.List;
2424

@@ -121,13 +121,16 @@ public GardenInfoResponse getGardenDashboard(Long potId, Long userId) {
121121
long totalTilCount = tilRepository.countByUserIdAndPotIdAndStatus(userId, potId, PostStatus.PUBLISHED);
122122

123123
// 10. 특정 유저가 해당 화분에 준 가장 최근 물주기 기록 1건을 조회합니다.
124-
LocalDateTime lastWateredAt = wateringLogRepository.findFirstByUserIdAndPotIdOrderByWateredAtDesc(userId, potId)
125-
.map(WateringLog::getWateredAt)
124+
LocalDateTime lastWateredAt = wateringLogRepository.findLatestWateredAtByUserIdAndPotId(userId, potId)
126125
.orElse(null);
127126

128-
// 11. 유저의 전체 TIL 발행 일자를 기준으로 현재 연속 작성일(스트릭)을 계산합니다.
129-
List<LocalDateTime> publishedTimes = tilRepository.findPublishedAtByUserId(userId, PostStatus.PUBLISHED);
130-
int streakDays = levelCalculator.calculateStreak(publishedTimes);
127+
// 11. 유저의 TIL 발행 날짜만 조회해 현재 연속 작성일(스트릭)을 계산합니다.
128+
// 같은 날짜에 여러 글을 작성해도 스트릭에는 날짜 하나만 필요하므로 DISTINCT 날짜 조회로 힙 사용량을 줄입니다.
129+
List<LocalDate> publishedDates = tilRepository.findDistinctPublishedDatesByUserId(userId, PostStatus.PUBLISHED.name())
130+
.stream()
131+
.map(java.sql.Date::toLocalDate)
132+
.toList();
133+
int streakDays = levelCalculator.calculateStreakFromDates(publishedDates);
131134

132135
// 12. 복합 정보 DTO를 조립 반환합니다.
133136
return new GardenInfoResponse(

src/main/java/com/Rootin/domain/garden/service/LevelCalculator.java

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,12 +253,30 @@ public int calculateStreak(List<LocalDateTime> publishedTimes) {
253253
return 0;
254254
}
255255

256+
return calculateStreakFromDates(publishedTimes.stream()
257+
.filter(Objects::nonNull)
258+
.map(LocalDateTime::toLocalDate)
259+
.toList());
260+
}
261+
262+
/**
263+
* [대시보드 전시용] 이미 날짜 단위로 축약된 발행 일자 목록을 기반으로 연속 작성일(스트릭)을 계산합니다.
264+
* 대시보드/화분 상세처럼 많은 요청이 동시에 들어오는 조회 흐름에서는 DB에서 DISTINCT 날짜만 가져와
265+
* 불필요한 LocalDateTime 객체 생성과 중복 Set 변환 비용을 줄입니다.
266+
*
267+
* @param publishedDates 유저가 작성한 TIL들의 발행 날짜 목록
268+
* @return 오늘을 포함해 현재 유지되고 있는 연속 작성일 수 (최소 0)
269+
*/
270+
public int calculateStreakFromDates(List<LocalDate> publishedDates) {
271+
if (publishedDates == null || publishedDates.isEmpty()) {
272+
return 0;
273+
}
274+
256275
LocalDate today = LocalDate.now();
257276

258277
// 날짜 단위 조회를 빠르게 처리하기 위해 Set으로 변환하여 O(1) 검색 속도를 보장합니다.
259-
Set<LocalDate> dateSet = publishedTimes.stream()
278+
Set<LocalDate> dateSet = publishedDates.stream()
260279
.filter(Objects::nonNull)
261-
.map(LocalDateTime::toLocalDate)
262280
.collect(Collectors.toSet());
263281

264282
// 오늘 쓴 TIL이 존재한다면 오늘부터 역산하고, 없다면 어제부터 과거로 역산합니다.

0 commit comments

Comments
 (0)