⭐ AOP에 적용하기 좋은 예시 — 하드웨어 리소스 JVM 모니터링 도입기

devdo·2026년 5월 14일

SpringBoot

목록 보기
44/46
post-thumbnail

Spring Boot 백엔드에서 "AOP를 언제 써야 하는가" 에 대한 실전 사례. 메모리/스레드 스파이크 추적을 APM 없이 AOP + JDK MX-Bean 만으로 해결한 의사결정과 구현 과정을 정리합니다.


1. 들어가며 — AOP가 정말 필요한 순간

AOP(Aspect-Oriented Programming)는 "여러 메서드에 동일한 부가 로직을 횡단(cross-cutting) 적용해야 할 때" 빛을 발합니다. 다만 남용하면 디버깅이 어려워지므로, 다음 조건을 모두 만족할 때 도입을 고려하는 것이 좋습니다.

조건설명
횡단 관심사트랜잭션, 보안, 로깅, 모니터링처럼 비즈니스 로직과 직교
변경 빈도가 낮다측정 로직 자체가 자주 바뀌지 않음
여러 진입점에 동시 적용단일 메서드면 그냥 inline 작성이 낫다
비침투(non-invasive) 가치가 크다비즈니스 코드를 오염시키지 않아야 함

본 사례는 이 4개 조건을 모두 충족해서 AOP를 선택했습니다.


2. 문제 상황

운영 중인 백엔드(공군 폐쇄망 배포 / Spring Boot 3.5 / Java 17)에서 다음 요구가 들어왔습니다.

  • 대용량 파일 처리, 오토라벨링, MinIO 업로드 등 "메모리/스레드를 많이 쓸 것 같은" 메서드들의 리소스 사용량을 추적하고 싶다.
  • 단, APM 도구(Datadog/NewRelic) 도입 불가 — 폐쇄망.
  • Micrometer + Prometheus 도 운영팀이 별도 인프라 관리 부담을 꺼린다.
  • 이미 운영 중인 Logback JSON + ELK 파이프라인은 그대로 활용하고 싶다.

3. ADR — 의사결정 과정

3-1. 후보 비교

5개의 대안을 놓고 비교했습니다.

대안장점단점채택
A. APM (Datadog 등)강력한 대시보드, 분산 추적폐쇄망 불가, 비용
B. Micrometer + Prometheus업계 표준, 메트릭 풍부별도 인프라(Prometheus/Grafana) 운영 부담
C. Spring Boot Actuator기본 제공엔드포인트 풀링 방식, 메서드 단위 추적 X
D. JFR (Java Flight Recorder)저오버헤드, JVM 네이티브분석에 JMC 필요, 실시간 알림 어려움
E. AOP + JDK MX-Bean + Logback MDC추가 인프라 0, 기존 ELK 재사용, 메서드 단위 정밀메서드 단위 측정만 가능 (system-wide X)

3-2. 채택 사유

E안은 다음 3가지 결정적 장점이 있었습니다.

  1. 인프라 비용 0 — 이미 운영 중인 Logback → Filebeat → Elasticsearch → Kibana 파이프라인 재활용
  2. MDC 구조화 로그 — Kibana에서 spike.heap_delta_mb:>100 같은 쿼리로 즉시 검색/대시보드화 가능
  3. 비침투 — 비즈니스 코드 한 줄도 건드리지 않음. 어노테이션/패키지 매칭만으로 적용

3-3. 임계값 결정

운영 환경 측정값(JVM heap 2GB, 평균 thread 30)을 근거로 다음과 같이 설정했습니다.

  • API 계층: heap_delta ≥ 100MB, heap_ratio ≥ 80%, threads ≥ 50
  • Batch 계층: heap_delta ≥ 200MB, heap_ratio ≥ 85%, threads ≥ 30 (대용량 처리 특성상 더 관대)

4. 구현 — 핵심 코드

4-1. JDK MX-Bean 래퍼 (공용)

public final class ResourceSnapshot {

    private static final MemoryMXBean MEMORY = ManagementFactory.getMemoryMXBean();
    private static final ThreadMXBean THREADS = ManagementFactory.getThreadMXBean();

    public static long heapUsedBytes()  { return MEMORY.getHeapMemoryUsage().getUsed(); }
    public static long heapMaxBytes()   { return MEMORY.getHeapMemoryUsage().getMax(); }
    public static long nonHeapUsedMb()  { return toMb(MEMORY.getNonHeapMemoryUsage().getUsed()); }
    public static int  liveThreadCount(){ return THREADS.getThreadCount(); }

    public static long toMb(long bytes) { return bytes / (1024L * 1024L); }

    /** ZeroDivision 방어: max ≤ 0 이면 0% 반환 */
    public static long heapRatioPct(long used, long max) {
        if (max <= 0) return 0L;
        return (used * 100L) / max;
    }
}

포인트: Aspect 본체에서 MX-Bean을 직접 다루지 않고 static 헬퍼로 분리 — 테스트하기 쉽고, API/Batch 양쪽 Aspect가 공유.

4-2. Aspect — 횡단 측정 로직

@Aspect
@Component
@Slf4j
public class ResourceSpikeAspect {

    private static final long HEAP_DELTA_WARN_MB  = 100L;
    private static final long HEAP_RATIO_WARN_PCT = 80L;
    private static final int  THREAD_WARN_COUNT   = 50;

    // 패키지 매칭 + 어노테이션 매칭을 OR 로 결합
    @Pointcut("execution(* datamatica.step.minio.MinioService.*(..))")
    public void minioOps() {}

    @Pointcut("execution(* datamatica.step.label.*.AutoLabelApplyService.*(..))")
    public void autoLabelOps() {}

    @Pointcut("@annotation(datamatica.step.common.annotation.ResourceMonitor)")
    public void resourceMonitored() {}

    @Around("minioOps() || autoLabelOps() || resourceMonitored()")
    public Object measureResource(ProceedingJoinPoint pjp) throws Throwable {
        long heapBefore = ResourceSnapshot.heapUsedBytes();
        long startNs    = System.nanoTime();

        try {
            return pjp.proceed();
        } finally {
            long heapAfter = ResourceSnapshot.heapUsedBytes();
            long deltaMb   = ResourceSnapshot.toMb(heapAfter - heapBefore);
            long ratioPct  = ResourceSnapshot.heapRatioPct(heapAfter, ResourceSnapshot.heapMaxBytes());
            int  threads   = ResourceSnapshot.liveThreadCount();
            long durationMs = (System.nanoTime() - startNs) / 1_000_000L;
            String target  = pjp.getSignature().toShortString();

            // MDC 적재 → 로그 출력 → MDC 정리 (try-finally 중첩으로 누수 방지)
            MDC.put("spike.target",         target);
            MDC.put("spike.heap_delta_mb",  String.valueOf(deltaMb));
            MDC.put("spike.heap_ratio_pct", String.valueOf(ratioPct));
            MDC.put("spike.threads",        String.valueOf(threads));
            MDC.put("spike.duration_ms",    String.valueOf(durationMs));
            // ... (총 8개 키)

            try {
                if (deltaMb >= HEAP_DELTA_WARN_MB
                        || ratioPct >= HEAP_RATIO_WARN_PCT
                        || threads  >= THREAD_WARN_COUNT) {
                    log.warn("[RESOURCE-SPIKE] target={} heap_delta={}MB heap_ratio={}% threads={} duration={}ms",
                            target, deltaMb, ratioPct, threads, durationMs);
                } else {
                    log.info("[RESOURCE] target={} heap_delta={}MB heap_ratio={}% threads={} duration={}ms",
                            target, deltaMb, ratioPct, threads, durationMs);
                }
            } finally {
                MDC.remove("spike.target");
                MDC.remove("spike.heap_delta_mb");
                // ... (모두 정리)
            }
        }
    }
}

구현상 3가지 핵심 패턴이 있습니다.

  1. 이중 try-finally — 외부 try { proceed } / 내부 try { log } finally { MDC.remove } 구조로 비즈니스 예외 전파 + MDC 누수 방지를 동시에 보장
  2. 임계값 분기 로그 레벨 — 평시 INFO, 스파이크 시 WARN. ELK에서 레벨로 1차 필터링
  3. OR 결합 Pointcut — 패키지 매칭(execution) + 어노테이션 매칭(@annotation) 양쪽 지원하여 신규 메서드는 @ResourceMonitor 하나만 붙이면 끝

4-3. 마커 어노테이션 — opt-in 진입점

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResourceMonitor {
}

호출 측은 단 한 줄로 끝.

@ResourceMonitor
public void processLargeBatch(MultipartFile file) {
    // 비즈니스 로직 그대로
}

4-4. Logback — MDC를 JSON 필드로 노출

<springProfile name="prod">
    <appender name="FILE_JSON" class="ch.qos.logback.core.rolling.RollingFileAppender">
        <encoder class="net.logstash.logback.encoder.LogstashEncoder">
            <includeMdcKeyName>spike.target</includeMdcKeyName>
            <includeMdcKeyName>spike.heap_delta_mb</includeMdcKeyName>
            <includeMdcKeyName>spike.heap_ratio_pct</includeMdcKeyName>
            <!-- ... 8개 -->
        </encoder>
    </appender>

    <!-- prod 기본은 WARN 이지만 Aspect 로거만 INFO 로 우회 -->
    <logger name="datamatica.step.aspect.ResourceSpikeAspect" level="INFO" additivity="false">
        <appender-ref ref="FILE_JSON"/>
    </logger>
</springProfile>

포인트: prod에서 root는 WARN으로 노이즈를 줄이되, Aspect 로거만 INFO로 통과시켜 평시 데이터도 누적 수집 → 추세 분석 가능.


5. 테스트 — AOP는 어떻게 검증하는가

AOP는 pjp.proceed() 호출만 mocking 하면 단위 테스트가 의외로 깔끔합니다.

@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = LENIENT)
class ResourceSpikeAspectTest {

    @Mock ProceedingJoinPoint pjp;
    @Mock Signature signature;
    @InjectMocks ResourceSpikeAspect aspect;

    private ListAppender<ILoggingEvent> appender;

    @BeforeEach
    void setUp() {
        Logger logger = (Logger) LoggerFactory.getLogger(ResourceSpikeAspect.class);
        appender = new ListAppender<>();
        appender.start();
        logger.addAppender(appender);
    }

    @Test
    @DisplayName("정상 실행 시 INFO [RESOURCE] 로그가 1회 기록되고 target을 포함한다")
    void 정상_실행_INFO_로그() throws Throwable {
        when(pjp.getSignature()).thenReturn(signature);
        when(signature.toShortString()).thenReturn("MinioService.upload(..)");
        when(pjp.proceed()).thenReturn("ok");

        aspect.measureResource(pjp);

        assertThat(appender.list)
                .hasSize(1)
                .allSatisfy(event -> {
                    assertThat(event.getLevel()).isEqualTo(Level.INFO);
                    assertThat(event.getFormattedMessage())
                            .startsWith("[RESOURCE]")
                            .contains("MinioService.upload(..)");
                });
    }

    @Test
    @DisplayName("예외 발생 시에도 MDC 8개 키가 모두 제거된다")
    void 예외_발생_MDC_정리() throws Throwable {
        when(pjp.getSignature()).thenReturn(signature);
        when(signature.toShortString()).thenReturn("MinioService.upload(..)");
        when(pjp.proceed()).thenThrow(new RuntimeException());

        try { aspect.measureResource(pjp); } catch (RuntimeException ignored) {}

        assertThat(MDC.get("spike.target")).isNull();
        // ... 8개 모두 null
    }
}

테스트 전략 3가지:

검증 대상방법
비즈니스 결과 전달pjp.proceed() 반환값을 mock 으로 지정 후 동일 객체 반환 검증
MDC 누수 방지실행 후 MDC.get(key) 가 모두 null 임을 검증
로그 출력Logback ListAppender 부착 → event.getFormattedMessage() / getLevel() 검증

주의: 임계값 분기(WARN vs INFO)는 실제 JVM 메모리 상태에 의존하므로 단위 테스트로 강제 트리거하기 어렵습니다. 정상 경로(INFO)만 검증하고, 스파이크 분기는 통합 환경의 실측으로 위임하는 것이 현실적입니다.


6. 결과 — Kibana에서 무엇이 가능해졌는가

JSON 로그가 ELK로 적재되면서 다음과 같은 운영 쿼리가 즉시 가능해졌습니다.

# 1시간 이내 100MB 이상 heap 증분 발생 메서드 Top 10
spike.heap_delta_mb:>100 AND @timestamp:[now-1h TO now]
| stats count by spike.target
| sort -count

# 스레드 폭증 추적
spike.threads:>50

별도 메트릭 인프라 없이 로그 한 줄로 메서드 단위 리소스 프로파일이 만들어진 것입니다.


7. 정리 — AOP 적용 체크리스트

이번 사례에서 얻은 "AOP를 잘 적용한 신호" 를 정리하면:

  • ✅ 비즈니스 코드 0줄 수정 (어노테이션 1줄 추가가 전부)
  • ✅ 측정 로직 단일 지점 — 임계값 조정도 Aspect 한 파일만 수정
  • ✅ MDC 누수 방지 등 횡단 관심사 특유의 리스크가 단위 테스트로 보장됨
  • ✅ 외부 인프라 의존 0 — JDK 기본 API + 기존 Logback 재사용

반대로 AOP를 피해야 하는 신호도 함께 기억하면 좋습니다.

  • ❌ 단일 메서드에만 필요한 로직 → inline 작성
  • ❌ Pointcut이 자주 바뀐다 → 매번 패키지 매칭 수정해야 함
  • ❌ 비즈니스 분기(주문 상태 등)에 따라 동작이 달라진다 → Aspect 안에서 분기 지옥 발생

결론

  • 실제 의사결정: 0004-aop-hardware-resource-monitoring.md
  • 구현체: ResourceSpikeAspect.java, BatchResourceSpikeAspect.java
  • 공용 유틸: ResourceSnapshot.java

한 줄 결론: "비침투적으로, 여러 진입점에, 일관된 부가 로직을, 운영 인프라 변경 없이" — 네 조건을 동시에 만족할 때 AOP는 최고의 도구가 됩니다.

profile
자바 스프링 백엔드 개발자입니다. 배운 것을 기록합니다.

0개의 댓글