-
Notifications
You must be signed in to change notification settings - Fork 0
[Common][Feat] 비동기 작업(Async) 모니터링 구축 및 전략 수립 #205
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
Merged
Merged
Changes from all commits
Commits
Show all changes
2 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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를 통해 블랙박스가 되지 않도록 모든 작업 현황을 투명하게 모니터링합니다. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,62 @@ | ||
| # Grafana 비동기(Async) 모니터링 대시보드 설정 가이드 | ||
|
|
||
| `비동기 Custom AOP` 전략에 따라 수집되는 메트릭을 그라파나에서 시각화하는 방법입니다. | ||
|
|
||
| > **Tip**: 쿼리 편집기 우측 상단의 **'Code'** 버튼을 누르면 아래 PromQL을 직접 복사/붙여넣기 할 수 있습니다 (가장 빠름). | ||
|  | ||
|
|
||
| --- | ||
|
|
||
|
|
||
|
|
||
| ## 2. 응답 시간 분포 (점 그래프) | ||
| 개별 작업들의 분포를 점(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` 선택 | ||
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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 { | ||||||||||
|
|
||||||||||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 메서드 이름에 결과를 5개로 제한하려면
Suggested change
|
||||||||||
|
|
||||||||||
| List<ReadingDiary> findByBookId(Long bookId); | ||||||||||
| } | ||||||||||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
58 changes: 58 additions & 0 deletions
58
src/main/java/book/book/common/monitoring/AsyncMetricsAspect.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
29
src/main/java/book/book/motive/api/KnowledgeFusionController.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
문서의 섹션 번호가
## 2.부터 시작하여 일관성이 부족해 보입니다.## 1.부터 시작하도록 번호를 조정하는 것을 제안합니다.