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
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ repositories {

dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-aop'
annotationProcessor 'org.projectlombok:lombok'
testCompileOnly 'org.projectlombok:lombok'
testAnnotationProcessor 'org.projectlombok:lombok'
Expand Down
92 changes: 92 additions & 0 deletions docs/monitor/ASYNC_MONITORING_STRATEGY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
# 비동기 작업(Async) 모니터링 전략 문서

## 1. 개요 및 최종 결정
본 프로젝트는 **Java 21 가상 스레드(Virtual Thread)** 환경에서 비동기 작업(`@Async`)의 성능(Latency)과 부하(Concurrency)를 효과적으로 추적하기 위해 **Pure Custom AOP** 전략을 최종 채택했습니다.

이 문서는 최종 결정에 이르기까지 검토했던 다양한 대안들과 각각의 트레이드오프 분석 결과를 기록합니다.

---

## 2. 전략 수립 과정 및 트레이드오프 분석 (Trade-off Analysis)

우리는 최적의 모니터링 전략을 찾기 위해 다음 5가지 옵션을 단계별로 검토했습니다.

### Option 1: TaskExecutor 래핑 (Base Strategy)
Spring의 `Executor` 빈을 `ExecutorServiceMetrics.monitor()`로 감싸는 방식.
- **장점**: 설정이 매우 간단함 (`AsyncConfig`만 수정). 스레드 풀 전체의 Active Count 파악 가능.
- **단점**: **"식별 불가능"**. 모든 비동기 작업이 하나의 지표로 합쳐짐. "시스템이 느리다"는 알 수 있지만 "이메일 발송이 느린지, 퀴즈 생성이 느린지"는 알 수 없음.
- **결론**: 상세 모니터링 요구사항 충족 불가로 기각.

### Option 2: @Timed 어노테이션 사용
개별 비동기 메서드에 `@Timed`를 부착하는 방식.
- **장점**: Spring 표준 기능. 원하는 메서드만 골라서 정밀 측정 가능.
- **단점**: **"휴먼 에러 & 번거로움"**. 개발자가 일일이 붙여야 하며, 실수로 누락 시 모니터링 공백 발생.
- **결론**: 유지보수 효율성이 떨어져 기각.

### Option 3: 멀티 Executor 전략 (Bulkhead Pattern)
기능별로 Executor를 물리적으로 분리하는 방식 (예: `mailExecutor`, `quizExecutor`).
- **장점 (가상 스레드 환경)**: 가상 스레드는 비용이 거의 "0"이므로, Executor를 수십 개 만들어도 리소스 낭비가 없음. 완벽한 격리(Bulkhead) 가능.
- **단점**: **"개발 복잡도 증가"**. 개발자가 `@Async("quizExecutor")`처럼 빈 이름을 항상 명시해야 함. 실수로 이름을 안 적으면 Default Executor로 작업이 몰려 격리 효과가 사라짐.
- **결론**: 강력하지만, 모든 비동기 작업에 이름을 지정하는 것은 생산성 저하 우려가 있어 보류.

### Option 4: Hybrid 전략 (래핑 + Custom AOP)
초기에 유력했던 방식. Executor 래핑으로 '전체 부하'를 보고, AOP로 '기능별 응답속도'를 보는 조합.
- **장점**: 인프라 관점(풀 상태)과 앱 관점(메서드 속도) 모두 커버 가능.
- **단점**: **"정보의 파편화 & 중복"**. Active Count를 보려면 Wrapper 지표를, Latency를 보려면 AOP 지표를 봐야 함.

### Option 5: Pure Custom AOP (Final Choice)
**"선택과 집중" 전략**. 불확실한 Active Count 측정보다는 **"확실한 응답 속도(Latency)"** 모니터링에 집중하는 방식.
- **변경 사유**: 초기에는 `LongTaskTimer`로 활성 작업 수를 측정하려 했으나, 0.3초 미만의 짧은 작업들은 프로메테우스의 수집 주기(15초) 사이에서 누락되어 유의미한 데이터가 나오지 않음.
- **결과**: 과감하게 복잡성을 줄이고, `Timer`를 이용한 **"기능별 실행 시간(Latency)"** 추적에 올인.

---

## 3. 최종 아키텍처: Pure Custom AOP

### 3.1 동작 원리
`AsyncMetricsAspect`가 `@Async` 어노테이션이 붙은 모든 메서드를 가로챕니다(Interception).

1. **Before Execution**:
- `Timer.start()`: 실행 시간 측정 시작.
2. **Proceed**: 실제 비동기 비즈니스 로직 실행.
- **CompletableFuture 감지**: 만약 리턴 타입이 `CompletableFuture`라면, 메서드가 즉시 리턴되더라도 타이머를 멈추지 않음.
- **Callback 등록**: `whenComplete`를 통해 "미래에 작업이 끝나는 시점"에 콜백을 예약.
3. **After Execution (Finally)**:
- **일반 메서드**: 즉시 `Timer.stop()`.
- **Future 메서드**: 비동기 작업이 실제로 완료(성공/실패)된 시점에 콜백이 실행되며 `Timer.stop()`. 스레드 블로킹 없이 정확한 소요 시간 측정.

### 3.2 수집 지표 (Metrics)
| 메트릭 이름 | 소스 | 설명 | 주요 태그 |
| :--- | :--- | :--- | :--- |
| `async.execution` | `Timer` | **Latency**. 작업 처리 소요 시간. 성능 저하 판단 기준. | `class`, `method`, `exception` |

---

## 4. 알려진 한계 및 엣지 케이스 (Edge Cases)

이 전략을 운영할 때 주의해야 할 사항들입니다.

### 4.1 Self-Invocation (내부 호출)
- **현상**: 같은 클래스 내에서 `this.asyncMethod()` 호출 시 AOP가 동작하지 않음 (Proxy 우회).
- **영향**: 비동기로 실행되지도 않고(동기 실행), 모니터링도 누락됨.
- **해결**: 비동기 메서드는 반드시 외부 Bean에서 호출하거나, Self-Injection 기법 사용.

### 4.2 태그 카디널리티 (Cardinality Explosion)
- **주의**: 메트릭 태그(`tag`)에는 `userId`, `requestId` 처럼 무제한으로 늘어나는 값을 절대 넣으면 안 됨.
- **원칙**: `ClassName`, `MethodName`, `ExceptionType` 같이 **종류가 유한한(Bounded)** 값만 태그로 사용.

### 4.3 예외 발생 시 통계 왜곡
- **현상**: 로직 에러로 0.001초 만에 예외가 터지면, p95 평균 실행 시간이 비정상적으로 짧아져 보임.
- **해결**: `async.execution` 지표를 볼 때 `exception="none"` 태그를 필터링하여 **성공한 요청의 Latency만** 보는 것을 권장.

### 4.4 좀비 작업 방지 (Timeout 필수)
- **위험**: 만약 `CompletableFuture`가 영원히 완료되지 않으면(무한 대기), `Timer.Sample` 객체도 메모리에 영원히 남습니다 (Memory Leak).
- **해결**: 모든 비동기 로직에는 반드시 **TimeLimit(Timeout)**이 걸려 있어야 합니다. 외부 API 호출 시 타임아웃 설정을 잊지 마세요.
- 예: `.orTimeout(1, TimeUnit.MINUTES)`
- **동작 방식**: 타임아웃 발생 시 `TimeoutException`으로 기록되며 타이머도 정상 종료됩니다.

---

## 5. 결론
우리는 **"개발자 생산성(자동화)"**과 **"관측 가능성(상세 지표)"**의 균형을 위해 Pure Custom AOP를 선택했습니다.
가상 스레드의 장점을 살려 리소스 걱정 없이 비동기 처리를 하되, AOP를 통해 블랙박스가 되지 않도록 모든 작업 현황을 투명하게 모니터링합니다.
62 changes: 62 additions & 0 deletions docs/monitor/GRAFANA_GUIDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Grafana 비동기(Async) 모니터링 대시보드 설정 가이드

`비동기 Custom AOP` 전략에 따라 수집되는 메트릭을 그라파나에서 시각화하는 방법입니다.

> **Tip**: 쿼리 편집기 우측 상단의 **'Code'** 버튼을 누르면 아래 PromQL을 직접 복사/붙여넣기 할 수 있습니다 (가장 빠름).
![alt text](image.png)

---



## 2. 응답 시간 분포 (점 그래프)
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

문서의 섹션 번호가 ## 2.부터 시작하여 일관성이 부족해 보입니다. ## 1.부터 시작하도록 번호를 조정하는 것을 제안합니다.

개별 작업들의 분포를 점(Point)으로 표현하여 패턴을 파악하기 좋습니다 (API 요청 그래프와 유사).

- **패널 타입**: Time series
- **제목**: Async Task Duration Distribution
- **PromQL 쿼리**:
```promql
async_execution_seconds
```
- **[Builder 설정법]**
1. **Metric**: `async_execution_seconds` 선택
2. **Label filters**: (비워둠)
3. **Operations**: (없음)
4. **Graph styles (우측 패널 옵션)**:
- **Line interpolation**: `Off` (선 끄기)
- **Show points**: `Always` (점 켜기)
- **Point size**: `5` ~ `6` (잘 보이게 키움)

---

## 3. 응답 시간 (Latency) - p95
상위 5% 느린 요청들의 응답 속도입니다.

- **패널 타입**: Time series
- **제목**: Async Latency (p95)
- **PromQL 쿼리**:
```promql
avg by (method) (async_execution_seconds{quantile="0.95"})
```
- **[Builder 설정법]**
1. **Metric**: `async_execution_seconds` 선택
2. **Label filters**: `quantile` `=` `0.95` 추가
3. **Operations**: `+ Operation` 클릭 -> `Aggregations` -> `Avg` 선택
4. **Grouping**: `Avg` 옵션에서 `by` -> `method` 선택

---

## 4. 응답 시간 (Latency) - p99
상위 1% 느린 요청들의 응답 속도입니다 (튀는 놈 잡기).

- **패널 타입**: Time series
- **제목**: Async Latency (p99)
- **PromQL 쿼리**:
```promql
avg by (method) (async_execution_seconds{quantile="0.99"})
```
- **[Builder 설정법]**
1. **Metric**: `async_execution_seconds` 선택
2. **Label filters**: `quantile` `=` `0.99` 추가
3. **Operations**: `+ Operation` 클릭 -> `Aggregations` -> `Avg` 선택
4. **Grouping**: `Avg` 옵션에서 `by` -> `method` 선택
Binary file added docs/monitor/image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ReadingChallengeRepository extends JpaRepository<ReadingChallenge, Long>,
ReadingChallengeRepositoryCustom {
Expand All @@ -22,6 +23,9 @@ default List<ReadingChallenge> findAllChallenges(Member member) {

List<ReadingChallenge> findByMemberOrderByCreatedDateDesc(Member member);

@Query("SELECT rc FROM ReadingChallenge rc JOIN FETCH rc.book WHERE rc.member.id = :memberId ORDER BY rc.createdDate DESC")
List<ReadingChallenge> findAllWithBookByMemberId(Long memberId);

default ReadingChallenge findOngoingChallenge(Member member, Book book) {
return findByMemberAndBookAndCompletedFalseAndAbandonedFalse(member, book)
.orElseThrow(() -> new CustomException(ErrorCode.CHALLENGE_NOT_FOUND));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import book.book.member.entity.Member;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface ReadingDiaryRepository extends JpaRepository<ReadingDiary, Long>, ReadingDiaryRepositoryCustom {

Expand All @@ -20,5 +21,8 @@ default ReadingDiary findByIdOrElseThrow(Long id) {

List<ReadingDiary> findAllByMemberId(Long memberId);

@Query("SELECT rd FROM ReadingDiary rd JOIN FETCH rd.book WHERE rd.member.id = :memberId ORDER BY rd.createdDate DESC")
List<ReadingDiary> findTop5ByMemberIdOrderByCreatedDateDescWithBook(Long memberId);
Comment on lines +24 to +25
Copy link
Contributor

Choose a reason for hiding this comment

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

high

메서드 이름에 Top5가 포함되어 있지만, @Query 어노테이션을 사용하면 Spring Data JPA가 메서드 이름의 키워드(Top5)를 해석하여 LIMIT 절을 자동으로 추가하지 않습니다. 이로 인해 의도와 다르게 해당 멤버의 모든 독서 일지를 조회하게 되어 성능 저하를 유발할 수 있습니다.

결과를 5개로 제한하려면 Pageable 파라미터를 추가하고, 서비스 레이어에서 PageRequest.of(0, 5)를 전달하는 방식으로 수정하는 것을 권장합니다.

Suggested change
@Query("SELECT rd FROM ReadingDiary rd JOIN FETCH rd.book WHERE rd.member.id = :memberId ORDER BY rd.createdDate DESC")
List<ReadingDiary> findTop5ByMemberIdOrderByCreatedDateDescWithBook(Long memberId);
@Query("SELECT rd FROM ReadingDiary rd JOIN FETCH rd.book WHERE rd.member.id = :memberId ORDER BY rd.createdDate DESC")
List<ReadingDiary> findTop5ByMemberIdOrderByCreatedDateDescWithBook(Long memberId, org.springframework.data.domain.Pageable pageable);


List<ReadingDiary> findByBookId(Long bookId);
}
38 changes: 21 additions & 17 deletions src/main/java/book/book/challenge/service/ReadingDiaryService.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,15 @@ public class ReadingDiaryService {
private final NaverBlogPostQualityRepository naverBlogPostQualityRepository;
private final ApplicationEventPublisher eventPublisher;

/**
* 맴버의 독서일지 조회
*/
@Transactional
@Transactional(readOnly = true)
public List<ReadingDiary> getRecentDiariesForAnalysis(Long memberId) {
return readingDiaryRepository.findTop5ByMemberIdOrderByCreatedDateDescWithBook(memberId);
}

// 맴버의 독서일지 조회
@Transactional(readOnly = true)
public CursorPageResponse<DiaryResponse.DiaryThumbnail> getDiariesThumbnailByMember(Long requesterId, Long memberId,
Long cursorId, int pageSize) {
Long cursorId, int pageSize) {
Member member = memberRepository.findByIdOrElseThrow(memberId);

// requesterId가 memberId와 같으면 비공개 포함, 다르면 공개만
Expand All @@ -67,7 +70,7 @@ public CursorPageResponse<DiaryResponse.DiaryThumbnail> getDiariesThumbnailByMem

@Transactional
public CursorPageResponse<DiaryResponse.DiaryFeed> getLatestDiariesFeedByMember(Long requesterId, Long memberId,
Long cursor, int size) {
Long cursor, int size) {
boolean includePrivate = requesterId != null && requesterId.equals(memberId);

List<DiaryResponse.DiaryFeed> diaries = readingDiaryRepository.findLatestDiaryFeedsByMember(requesterId,
Expand All @@ -85,14 +88,15 @@ public CursorPageResponse<DiaryResponse.DiaryFeed> getLatestDiariesFeedByMember(
*/
@Transactional
public CursorPageResponse<DiaryResponse.DiaryFeed> getFollowingDiariesFeed(Long requesterId, Long cursor,
int size) {
int size) {
List<DiaryResponse.DiaryFeed> diaries = readingDiaryRepository.findLatestDiaryFeedsByFollowing(
requesterId, cursor, size + 1);

// TODO: fallback, 팔로잉 피드 없을 시 최근 피드를 보여준다(임시용), 고도화 필요
// if(diaries.isEmpty()) {
// diaries = readingDiaryRepository.findLatestDiaryFeeds(requesterId, cursor, size + 1);
// }
// if(diaries.isEmpty()) {
// diaries = readingDiaryRepository.findLatestDiaryFeeds(requesterId, cursor,
// size + 1);
// }
diaries = (List<DiaryResponse.DiaryFeed>) diaryDetailCombiner.combine(diaries);
onImpressed(requesterId, diaries);

Expand All @@ -101,22 +105,23 @@ public CursorPageResponse<DiaryResponse.DiaryFeed> getFollowingDiariesFeed(Long

/**
* 책별 모두의 독서일지 조회
* 책과 관련된 모두의 독서일지는 최신성 반응이 사용자가 둔감하게 반응하므로 레디스에 저장하지 않고 지난 날 기록인 DB DiaryStatistic을 사용
* 책과 관련된 모두의 독서일지는 최신성 반응이 사용자가 둔감하게 반응하므로 레디스에 저장하지 않고 지난 날 기록인 DB
* DiaryStatistic을 사용
*/
@Transactional(readOnly = true)
public CursorPageResponse<DiaryResponse.DiaryThumbnail> getRelatedLatestDiaryThumbnailsByBook(
Long bookId, Long cursorId, int pageSize) {
List<DiaryResponse.DiaryThumbnail> thumbnails =
readingDiaryRepository.findRelatedLatestDiaryThumbnailsByBook(bookId, cursorId, pageSize + 1);
List<DiaryResponse.DiaryThumbnail> thumbnails = readingDiaryRepository
.findRelatedLatestDiaryThumbnailsByBook(bookId, cursorId, pageSize + 1);
return CursorPageResponse.of(thumbnails, pageSize, DiaryResponse.DiaryThumbnail::diaryId);
}

@Transactional(readOnly = true)
public DualCursorPageResponse<RelatedDiaryThumbnailByBook, Double> getRelatedPopularDiaryThumbnailsByBook(
Long bookId, Long cursorId, Double cursorScore, int pageSize) {
List<RelatedDiaryThumbnailByBook> thumbnails =
readingDiaryRepository.findRelatedPopularDiaryThumbnailsByBook(bookId, cursorId, cursorScore,
pageSize + 1);
List<RelatedDiaryThumbnailByBook> thumbnails = readingDiaryRepository.findRelatedPopularDiaryThumbnailsByBook(
bookId, cursorId, cursorScore,
pageSize + 1);
return DualCursorPageResponse.of(thumbnails, pageSize,
RelatedDiaryThumbnailByBook::diaryId,
RelatedDiaryThumbnailByBook::score);
Expand All @@ -135,7 +140,6 @@ public CursorPageResponse<DiaryResponse.DiaryFeed> getLatestDiaryFeedsByBook(
return CursorPageResponse.of(diaries, size, DiaryResponse.DiaryFeed::getDiaryId);
}


@Transactional(readOnly = true)
public DualCursorPageResponse<DiaryResponse.RelatedDiaryFeedByBook, Double> getPopularDiaryFeedsByBook(
Long requesterId, Long bookId, Long cursorId, double cursorScore, int size) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,11 @@ public Map<Long, Boolean> getInProgressChallengeMap(Long memberId, List<Book> bo
book -> ongoingBookIds.contains(book.getId())));
}

@Transactional(readOnly = true)
public List<ReadingChallenge> getAllChallengesForAnalysis(Long memberId) {
return challengeRepository.findAllWithBookByMemberId(memberId);
}

@Transactional(readOnly = true)
public ChallengeSucessDetail getSucessDetail(Long challengeId) {
ReadingChallenge readingChallenge = challengeRepository.findByIdOrElseThrow(challengeId);
Expand Down
58 changes: 58 additions & 0 deletions src/main/java/book/book/common/monitoring/AsyncMetricsAspect.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package book.book.common.monitoring;

import io.micrometer.core.instrument.MeterRegistry;
import io.micrometer.core.instrument.Timer;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;

@Aspect
@Component
@RequiredArgsConstructor
public class AsyncMetricsAspect {

private final MeterRegistry meterRegistry;

@Around("@annotation(org.springframework.scheduling.annotation.Async)")
public Object measureAsyncExecution(ProceedingJoinPoint joinPoint) throws Throwable {
Timer.Sample timerSample = Timer.start(meterRegistry);

Object result;
try {
result = joinPoint.proceed();
} catch (Throwable ex) {
// 메서드 실행 자체가 실패한 경우 (즉시 예외 발생)
recordExecutionTime(timerSample, joinPoint, ex);
throw ex;
}

// 리턴 타입이 CompletableFuture인 경우: 비동기 작업이 진짜 끝날 때까지 기다림
if (result instanceof java.util.concurrent.CompletableFuture) {
((java.util.concurrent.CompletableFuture<?>) result).whenComplete((res, ex) -> {
recordExecutionTime(timerSample, joinPoint, ex);
});
} else {
// void 이거나 다른 리턴 타입인 경우: 메서드 종료 시점 측정 (기존 방식)
recordExecutionTime(timerSample, joinPoint, null);
}

return result;
}

private void recordExecutionTime(Timer.Sample timerSample, ProceedingJoinPoint joinPoint, Throwable ex) {
String exceptionClass = "none";
if (ex != null) {
exceptionClass = ex.getClass().getSimpleName();
}

timerSample.stop(Timer.builder("async.execution")
.tag("class", joinPoint.getSignature().getDeclaringTypeName())
.tag("method", joinPoint.getSignature().getName())
.tag("exception", exceptionClass)
.description("Execution time of @Async methods")
.publishPercentiles(0.95, 0.99)
.register(meterRegistry));
}
}
29 changes: 29 additions & 0 deletions src/main/java/book/book/motive/api/KnowledgeFusionController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package book.book.motive.api;

import book.book.common.response.ResponseForm;
import book.book.motive.dto.InsightResponse;
import book.book.motive.service.KnowledgeFusionService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@Tag(name = "동기부여 (Motive)", description = "(정순원을 위해 만들어봄)사용자 동기부여 및 인사이트 관련 API")
@RestController
@RequestMapping("/api/v1/motive")
@RequiredArgsConstructor
public class KnowledgeFusionController {

private final KnowledgeFusionService knowledgeFusionService;

@Operation(summary = "맞춤형 인사이트 생성", description = "사용자의 독서 활동(일지, 챌린지, 퀴즈)을 분석하여 맞춤형 마인드셋과 액션 플랜을 제공합니다.")
@PostMapping("/insight")
public ResponseForm<InsightResponse> getPersonalizedInsight(
@AuthenticationPrincipal Long memberId) {
InsightResponse response = knowledgeFusionService.getPersonalizedInsight(memberId);
return ResponseForm.ok(response);
}
}
Loading
Loading