
Spring Boot 백엔드에서 "AOP를 언제 써야 하는가" 에 대한 실전 사례. 메모리/스레드 스파이크 추적을 APM 없이 AOP + JDK MX-Bean 만으로 해결한 의사결정과 구현 과정을 정리합니다.
AOP(Aspect-Oriented Programming)는 "여러 메서드에 동일한 부가 로직을 횡단(cross-cutting) 적용해야 할 때" 빛을 발합니다. 다만 남용하면 디버깅이 어려워지므로, 다음 조건을 모두 만족할 때 도입을 고려하는 것이 좋습니다.
| 조건 | 설명 |
|---|---|
| 횡단 관심사다 | 트랜잭션, 보안, 로깅, 모니터링처럼 비즈니스 로직과 직교 |
| 변경 빈도가 낮다 | 측정 로직 자체가 자주 바뀌지 않음 |
| 여러 진입점에 동시 적용 | 단일 메서드면 그냥 inline 작성이 낫다 |
| 비침투(non-invasive) 가치가 크다 | 비즈니스 코드를 오염시키지 않아야 함 |
본 사례는 이 4개 조건을 모두 충족해서 AOP를 선택했습니다.
운영 중인 백엔드(공군 폐쇄망 배포 / Spring Boot 3.5 / Java 17)에서 다음 요구가 들어왔습니다.
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) | ✅ |
E안은 다음 3가지 결정적 장점이 있었습니다.
spike.heap_delta_mb:>100 같은 쿼리로 즉시 검색/대시보드화 가능운영 환경 측정값(JVM heap 2GB, 평균 thread 30)을 근거로 다음과 같이 설정했습니다.
heap_delta ≥ 100MB, heap_ratio ≥ 80%, threads ≥ 50heap_delta ≥ 200MB, heap_ratio ≥ 85%, threads ≥ 30 (대용량 처리 특성상 더 관대)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가 공유.
@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가지 핵심 패턴이 있습니다.
try { proceed } / 내부 try { log } finally { MDC.remove } 구조로 비즈니스 예외 전파 + MDC 누수 방지를 동시에 보장INFO, 스파이크 시 WARN. ELK에서 레벨로 1차 필터링execution) + 어노테이션 매칭(@annotation) 양쪽 지원하여 신규 메서드는 @ResourceMonitor 하나만 붙이면 끝@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface ResourceMonitor {
}
호출 측은 단 한 줄로 끝.
@ResourceMonitor
public void processLargeBatch(MultipartFile file) {
// 비즈니스 로직 그대로
}
<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로 통과시켜 평시 데이터도 누적 수집 → 추세 분석 가능.
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)만 검증하고, 스파이크 분기는 통합 환경의 실측으로 위임하는 것이 현실적입니다.
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
별도 메트릭 인프라 없이 로그 한 줄로 메서드 단위 리소스 프로파일이 만들어진 것입니다.
이번 사례에서 얻은 "AOP를 잘 적용한 신호" 를 정리하면:
반대로 AOP를 피해야 하는 신호도 함께 기억하면 좋습니다.
한 줄 결론: "비침투적으로, 여러 진입점에, 일관된 부가 로직을, 운영 인프라 변경 없이" — 네 조건을 동시에 만족할 때 AOP는 최고의 도구가 됩니다.