Spring

유스케이스 코드와 메트릭 코드 분리하기 | Spring AOP, Extract Class

Luti 2026. 8. 18. 22:43

 

 

 

 

# 00. 배경

 

## 00-1. 쿠폰 생성 유스케이스

 

아래 코드는 평범한(?) 쿠폰 시스템의 쿠폰 생성 유스케이스 흐름입니다.

@Component
@Transactional(readOnly = true)
public class CouponFacade {

	private final CouponService couponService;
	private final CouponPolicyService couponPolicyService;
	private final CampaignService campaignService;
	private final PointService pointService;

	public CouponFacade(CouponService couponService, CouponPolicyService couponPolicyService,
						CampaignService campaignService, PointService pointService) {
		this.couponService = couponService;
		this.couponPolicyService = couponPolicyService;
		this.campaignService = campaignService;
		this.pointService = pointService;
	}

	@Transactional
	public int createCoupons(CreateCouponsCommand command) {
		CouponPolicy policy = couponPolicyService.getById(command.getPolicyId());

		if (!policy.getCampaign().getId().equals(command.getCampaignId())) {
			throw new BusinessException(COUPON_POLICY_CAMPAIGN_MISMATCH);
		}

		couponService.createBatch(policy, policy.getQuantity());
		return policy.getQuantity();
	}
}

 

흐름은 다음과 같습니다.

1. 쿠폰 생성 요청에 대한 쿠폰 정책을 조회한다.
2. 조회한 쿠폰 정책에 매핑되어 있는 캠페인의 id와 요청 객체의 캠페인 id를 검증한다.
3. 검증에 이상 없을 시 쿠폰 생성 작업을 진행한다.

 

 

 

## 00-2. 메트릭 집계

 

위 쿠폰 생성 흐름에서 모니터링 및 성능 개선을 위해 지표를 남기고자 했습니다.

Micrometer를 활용한 메트릭 설계는 아래와 같습니다.

 

이름: coupon.generation.duration
종류: Timer
태그: campaignId, policyId
의미: 정책 1건 기준 쿠폰 생성 API 처리시간
이름: coupon.generation.count
종류: Counter
태그: campaignId, policyId
의미: 실제 생성된 쿠폰 개수 누적
이름: coupon.generation.in_progress
종류: Gauge
태그: campaignId, policyId
의미: 생성 진행 중 여부 / Grafana 대시보드에서 annotation을 활용하여 정책별 정확한 쿠폰 생성 구간 표시 용도
이름: coupon.generation.progress
종류: Gauge
태그: campaignId, policyId
의미: 지금까지 생성된 개수 / 어드민 대시보드 정책별 실시간 진행률 표시 용도
이름: coupon.generation.result
종류: Counter
태그: campaignId, policyId, result=success|fail
의미: 생성 성공/실패 집계

 

 

## 00-3. 여러 책임을 한 번에 지게 되는 쿠폰 생성 클래스

 

앞서 설계한대로 메트릭 집계 코드를 추가한 쿠폰 생성 클래스가 아래와 같습니다.

@Component
@Transactional(readOnly = true)
public class CouponFacade {

	private final CouponService couponService;
	private final CouponPolicyService couponPolicyService;
	private final CampaignService campaignService;
	private final PointService pointService;
	private final MeterRegistry meterRegistry;
	private final ConcurrentHashMap<String, AtomicInteger> generationInProgressGauge = new ConcurrentHashMap<>();
	private final ConcurrentHashMap<String, AtomicInteger> generationProgressGauge = new ConcurrentHashMap<>();

	public CouponFacade(CouponService couponService, CouponPolicyService couponPolicyService,
						CampaignService campaignService, PointService pointService, MeterRegistry meterRegistry) {
		this.couponService = couponService;
		this.couponPolicyService = couponPolicyService;
		this.campaignService = campaignService;
		this.pointService = pointService;
		this.meterRegistry = meterRegistry;
	}

	@Transactional
	public int createCoupons(CreateCouponsCommand command) {
		return meterRegistry
			.timer("coupon.generation.duration", "campaignId", String.valueOf(command.getCampaignId()), "policyId",
				   String.valueOf(command.getPolicyId()))
			.record(() -> {
				try {
					CouponPolicy policy = couponPolicyService.getById(command.getPolicyId());

					if (!policy.getCampaign().getId().equals(command.getCampaignId())) {
						throw new BusinessException(COUPON_POLICY_CAMPAIGN_MISMATCH);
					}

					AtomicInteger inProgress = generationInProgressGauge(command.getCampaignId(), command.getPolicyId());
					AtomicInteger progress = generationProgressGauge(command.getCampaignId(), command.getPolicyId());
					inProgress.set(1);
					progress.set(0);
					try {
						couponService.createBatch(policy, policy.getQuantity(), progress::set);

						meterRegistry
							.counter("coupon.generation.count", "campaignId", String.valueOf(command.getCampaignId()),
									 "policyId", String.valueOf(command.getPolicyId()))
							.increment(policy.getQuantity());

						meterRegistry
							.counter("coupon.generation.result", "campaignId", String.valueOf(command.getCampaignId()),
									 "policyId", String.valueOf(command.getPolicyId()), "result", "success")
							.increment();

						return policy.getQuantity();
					} finally {
						inProgress.set(0);
					}
				} catch (RuntimeException e) {
					meterRegistry
						.counter("coupon.generation.result", "campaignId", String.valueOf(command.getCampaignId()),
								 "policyId", String.valueOf(command.getPolicyId()), "result", "fail")
						.increment();
					throw e;
				}
			});
	}

	private AtomicInteger generationInProgressGauge(Long campaignId, Long policyId) {
		String key = campaignId + "-" + policyId;
		return generationInProgressGauge.computeIfAbsent(key, k ->
			meterRegistry.gauge("coupon.generation.in_progress",
								Tags.of("campaignId", String.valueOf(campaignId), "policyId", String.valueOf(policyId)),
								new AtomicInteger(0)));
	}

	private AtomicInteger generationProgressGauge(Long campaignId, Long policyId) {
		String key = campaignId + "-" + policyId;
		return generationProgressGauge.computeIfAbsent(key, k ->
			meterRegistry.gauge("coupon.generation.progress",
								Tags.of("campaignId", String.valueOf(campaignId), "policyId",
										String.valueOf(policyId)),
								new AtomicInteger(0)));
	}
}

 

분명 실제 쿠폰 생성 유스케이스에 해당되는 코드는 고작 다섯 줄 정도였는데, 

Micrometer Gauge용 헬퍼 메서드 두 개가 추가되었고, `createCoupons()` 메서드 내에도 너무 많은 메트릭 코드가 생겨버렸습니다.

 

이 상태로라면, 누군가가 이 코드를 봤을 때 쿠폰 생성에 대한 유스케이스 흐름을 파악하기가 힘들 것이며,

또한 새로운 메트릭을 집계해야할 때마다 클래스는 점점 더 읽기 힘든 구조가 되어갈 것입니다.

 

 

# 01. AOP

 

## 01-1. AOP 개요

 

기존 쿠폰 생성 유스케이스 흐름에서 메트릭 코드를 분리해야곘다고 생각한 직후 바로 떠오른 것은 AOP였습니다.

 

객체지향 OOP는 "이 객체가 무슨 일을 하는가"를 클래스 단위로 잘 나누도록 합니다.

그런데 어떤 관심사는 여러 클래스에 걸쳐 똑같은 모양으로 반복됩니다. (ex. 로깅, 트랜잭션, 보안 검사, 메트릭 계측 등)

이런 걸 횡단 관심사라고 부르며, 횡단이라는 이름이 붙은 이유는 OOP의 클래스 경계를 무시하고 여러 클래스를 가로질러 나타나기 때문입니다.

 

현재로서는 쿠폰 생성에 대한 메트릭 집계만 존재하지만,

앞으로는 쿠폰 사용 등 다양한 관심사에서도 동일하게 메트릭을 집계해야 하는 경우가 생길 수 있습니다.

CouponFacade.createCoupons() <- 메트릭 코드가 여기도 필요
CouponFacade.useCoupon() <- 메트릭 코드가 여기도 필요
(나중에 다른 서비스가 생기면) <- 메트릭 코드가 여기도 필요

 

이로써 AOP는 단순히 기존 객체로부터 횡단 관심사를 분리하는 것을 넘어 반복되는 관심사를 한 곳에 모아두고, 어떤 지점들에 적용할지를 선언적으로 지정하는 방식으로 문제를 해결합니다.

 

 

## 01-2. AOP 핵심 개념

 

`Aspect`

  • 횡단 관심사를 모듈로 묶어놓은 것
  • Spring에서는 @Aspect가 붙은 클래스

`Join Point`

  • 프로그램 실행 중 “끼어들 수 있는 지점”.
  • Spring AOP는 메서드 호출 Join Point 지원

`Advice`

  • Join Point에서 실제로 실행할 동작
  • “언제”(전/후/감싸기) + “무엇을”(코드)의 조합
  • 실행 지점에 따라 @Before, @AfterReturning, @AfterThrowing, @After, @Around 등의 애너테이션 활용

`Pointcut`

  • 수많은 Join Point 중 “어떤 것들에” 이 Advice를 적용할지 고르는 조건/표현식
  • 메서드 시그니처 패턴 매칭, 특정 타입/패키지 내부의 모든 메서드, 특정 애너테이션 붙은 메서드, 특정 이름의 스프링 빈 등 여러 종류가 있음

`Target`

  • Advice가 적용되는 원래 객체
  • 진짜 CouponFacade 인스턴스

`Proxy`

  • Target을 감싸서, 호출을 가로채 Advice를 실행시키는 가짜 객체
  • 실제로 스프링 컨테이너가 주입해주는 것

`Weaving`

  • Aspect를 실제 대상 코드에 짜 넣는 과정
  • Spring AOP는 런타임에 프록시를 만드는 방식으로 위빙

 

## 01-3. AOP로 메트릭 계측하기

 

메트릭 계측을 대상으로 하는 메서드에 커스텀 애너테이션을 붙여 관심사를 분리하는 것으로 문제를 해결하고자 했습니다.

 

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

 

실제 쿠폰 생성 메서드에 위 커스텀 애너테이션을 붙임으로써 해당 메서드가 GenerationMetrics 계측 대상임을 선언합니다.

 

@Aspect
@Component
public class GenerationMetricsAspect {

	private final MeterRegistry meterRegistry;

	public GenerationMetricsAspect(MeterRegistry meterRegistry) {
		this.meterRegistry = meterRegistry;
	}

	@Around("@annotation(GenerationMetrics)")
	public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
		CreateCouponsCommand command = (CreateCouponsCommand)joinPoint.getArgs()[0];
		String campaignId = String.valueOf(command.getCampaignId());
		String policyId = String.valueOf(command.getPolicyId());

		Timer.Sample sample = Timer.start(meterRegistry);
		try {
			Object result = joinPoint.proceed();
			int quantity = (int)result;

			meterRegistry.counter("coupon.generation.count", "campaignId", campaignId, "policyId", policyId)
						 .increment(quantity);
			meterRegistry
				.counter("coupon.generation.result", "campaignId", campaignId, "policyId", policyId, "result",
						 "success")
				.increment();

			return result;
		} catch (RuntimeException e) {
			meterRegistry
				.counter("coupon.generation.result", "campaignId", campaignId, "policyId", policyId, "result", "fail")
				.increment();
			throw e;
		} finally {
			sample.stop(
				meterRegistry.timer("coupon.generation.duration", "campaignId", campaignId, "policyId", policyId));
		}
	}
}

 

`MeterRegistry` 의존성 및 관련 계측 로직을 기존 쿠폰 생성 유스케이스 코드로부터 분리합니다.

`@Around("@annotation(GenerationMetrics)")`를 Pointcut으로 지정하여,

`@GnerationMetrics`가 붙은 메서드 실행을 `GenerationMetricsAspect`가 감싸도록 합니다.

 

@GenerationMetrics
@Transactional
public int createCoupons(...) {
}

 

이때 `ProceedingJoinPoint`는 AOP 프록시가 가로챈 메서드 실행에 대한 정보를 가지고 있습니다.

`.proceed()` 메서드를 통해 실제 원본 클래스의 `createCoupons(command)` 메서드를 실행합니다.

`.proceed()` 실행 전에는 타이머 측정을 시작하고, 정상 종료 시 생성 개수와 성공 횟수를 기록합니다.

예외가 발생하면 실패 횟수를 기록하며, 성공/실패 여부와 관계없이 마지막에는 수행 시간을 기록합니다.

이 단계를 거쳐 기존 측정하던 Timer와 Count 메트릭을 계측할 수 있게 됩니다.

 

 

## 01-4. AOP가 해결하지 못하는 부분

 

위에서 말씀드린 메트릭 세 종류 Timer, Count, Gauge 중 Timer와 Count는 AOP로 관심사를 분리하여 문제를 해결할 수 있었습니다.

하지만 상태 값을 나타내는 Gauge는 AOP로 관심사를 분리할 수 없습니다.

 

@Around("@annotation(GenerationMetrics)")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
    Timer.Sample sample = Timer.start(meterRegistry);
    try {
        Object result = joinPoint.proceed();   // ← 여기서 딱 한 번만 호출
        ...
        return result;
    } ...
}

`@Around`는 감싼 메서드 호출 전체에 대해 "시작 직전 한 번", "끝난 직후 한 번" 딱 두 지점만 잡을 수 있습니다.

`coupon.generation.progress`와 `coupon.generation.in_progress`는 각각 한 번의 쿠폰 생성 요청 안에서 "각 시점에 쿠폰을 전체 몇 개중 몇 개를 생성했는지", "정책별로 쿠폰 생성 중인지 아닌지"와 같이 상태를 나타냅니다.

즉, 실제 쿠폰을 생성하는 `createBatch()` 내부의 반복문이 돌 때마다 값을 갱신해야 합니다.

결국 "시작/끝 두 지점"과 "반복마다 N번" 사이에는 AOP로 메꿀 수 없는 근본적인 간극이 존재합니다.'

 

Aspect

createCoupons() 시작
       │
       ▼
   proceed()
       │
       │  createBatch()
       │  ├─ 100개 생성
       │  ├─ 200개 생성
       │  ├─ 300개 생성
       │  └─ ...
       │
       ▼
createCoupons() 종료

Aspect에서는 ↑ 시작/종료는 알 수 있지만
createBatch 내부 진행 과정은 알 수 없음

 

따라서 progress를 측정하려면 실제 생성 작업이 이루어지는 코드로부터 진행 상황을 외부에 전달할 별도의 통로가 필요했습니다.

동시에 Gauge를 생성하고 캐싱하기 위해 `CouponFacade`가 들고 있던 `MeterRegistry`, `ConcurrentHashMap`, `AtomicInteger` 역시 여전히 Facade의 책임으로 남아 있습니다.

 

다음으로 이 상태 관리 책임 자체를 별도의 클래스로 분리해보기로 했습니다.

 

 

 

# 02. Extract Class 

 

## 02-1. Extract class 개요

 

Problem: When one class does the work of two, awkwardness results.
Solution: Instead, create a new class and place the fields and methods responsible for the relevant functionality in it.

 

 

Extract Class는 말 그대로 하나의 클래스가 너무 많은 책임을 가지고 있을 때, 그중 일부 책임을 새로운 클래스로 추출하는 리팩토링 기법입니다.

핵심은 한 클래스 안에서 서로 다른 이유로 변경되는 필드와 메서드들을 찾아, 관련된 것끼리 별도의 클래스로 옮기는 것입니다.

 

private final MeterRegistry meterRegistry;
private final ConcurrentHashMap<String, AtomicInteger> generationInProgressGauge = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, AtomicInteger> generationProgressGauge = new ConcurrentHashMap<>();
private AtomicInteger generationInProgressGauge(Long campaignId, Long policyId) {
	String key = campaignId + "-" + policyId;
	return generationInProgressGauge.computeIfAbsent(key, k ->
		meterRegistry.gauge("coupon.generation.in_progress",
							Tags.of("campaignId", String.valueOf(campaignId), "policyId", String.valueOf(policyId)),
							new AtomicInteger(0)));
}

 

AOP를 통해 가능한 횡단 관심사 분리를 이뤄냈지만,

여전히 쿠폰 유스케이스 코드에서는 메트릭을 위한 불필요한 의존성들과 로직들이 존재합니다.

이들은 모두 Extract Class 단계에서 추출되어야 할 대상입니다.

Gauge 이름이나 태그 구성이 변경되거나, Gauge 상태를 관리하는 방식이 변경되었다는 이유로 `CouponFacade`까지 수정되어야 할 필요는 없으니까요.

 

 

## 02-2. Gauge 상태 관리 책임 추출

 

우선 `CouponFacade`가 `ConcurrentHashMap` 캐시 2개를 필드로 직접 들고 있던 문제와,

private 헬퍼 메서드 2개가 Facade 안에서 MicroMeter Tags / AtmoicInteger 조립 로직을 처리하던 문제를 별도의 `CouponGenerationTracker`로 추출하는 것으로 문제를 해결합니다.

 

@Component
public class CouponGenerationTracker {

    private final MeterRegistry meterRegistry;
    private final ConcurrentHashMap<String, AtomicInteger> inProgressGauges = new ConcurrentHashMap<>();
    private final ConcurrentHashMap<String, AtomicInteger> progressGauges = new ConcurrentHashMap<>();

    public CouponGenerationTracker(MeterRegistry meterRegistry) {
        this.meterRegistry = meterRegistry;
    }

    public GenerationTracking start(Long campaignId, Long policyId) {
        String key = campaignId + "-" + policyId;
        Tags tags = Tags.of("campaignId", String.valueOf(campaignId), "policyId", String.valueOf(policyId));

        AtomicInteger inProgress = inProgressGauges.computeIfAbsent(key, k ->
            meterRegistry.gauge("coupon.generation.in_progress", tags, new AtomicInteger(0)));
        AtomicInteger progress = progressGauges.computeIfAbsent(key, k ->
            meterRegistry.gauge("coupon.generation.progress", tags, new AtomicInteger(0)));

        inProgress.set(1);
        progress.set(0);
        return new GenerationTracking(inProgress, progress);
    }
}

 

`CouponGenerationTracker`는 캠페인과 정책 단위로 Gague를 생성하고 캐싱하며, 새로운 쿠폰 생성 작업이 시작될 때 초기 상태를 구성하는 역할을 담당합니다.

 

`start()`가 호출되면 먼저 campaignId와 policyId를 기준으로 기존 Gauge를 조회하거나 새롭게 생성합니다.

이후 생성 작업이 시작되었음을 나타내기 위해 다음과 같이 상태를 초기화합니다.

inProgress.set(1); 
progress.set(0);

 

즉 하나의 쿠폰 생성 작업이 시작될 때의 흐름은 다음과 같습니다.

  • `CouponGenerationTracker.start()`
  • campaignId / policyId 기준 Gauge 조회 또는 생성
  • inProgress = 1 / progress = 0
  • `GenerationTracking` 반환

이제 `CouponFacade`는 Gague가 어떤 방식으로 생성되는지,

`ConcurrentHashMap`을 통해 어떻게 캐싱되는지, Micrometer의 Tags가 어떻게 만들어지는지 알 필요가 없습니다.

 

 

## 02-3. 한 번의 실행을 표현

 

`CouponGenerationTracker`가 여러 캠페인과 정책의 상태를 관리한다면, 실제 하나의 `createCoupons()` 호출에 대한 상태를 표현할 객체도 필요합니다.

이를 `GenerationTracking`이 담당합니다.

public class GenerationTracking implements AutoCloseable {

    private final AtomicInteger inProgress;
    private final AtomicInteger progress;

    GenerationTracking(AtomicInteger inProgress, AtomicInteger progress) {
        this.inProgress = inProgress;
        this.progress = progress;
    }

    public IntConsumer progressCallback() {
        return progress::set;
    }

    @Override
    public void close() {
        inProgress.set(0);
    }
}

 

두 클래스는 관리하는 범위와 수명이 다릅니다.

`CouponGenerationTracker`는 스프링 싱글톤 빈으로 애플리케이션이 실행되는 동안 유지되며, 여러 캠페인과 정책의 Gague 상태를 모두 관리합니다.

반면 `GenerationTracking`은 `createCoupons()`가 호출될 때마다 새롭게 생성되며, 현재 진행 중인 하나의 쿠폰 생성 작업만을 표현하는 짧은 수명의 객체입니다.

 

`CouponGenerationTracker`

  • 애플리케이션 전체에서 하나
  • 여러 campaign / policy 상태 관리
  • Gauge 생성 및 캐싱
  • 작업 시작 상태 구성

`GenerationTracking`

  • `createCoupons()` 호출마다 생성
  • 현재 한 번의 생성 작업만 관리
  • 진행률 갱신
  • 작업 종료 처리

`CouponGenerationTracker.start()`가 여러 Gague 중 이번 실행에서 사용할 상태를 찾아 `GenerationTracking`에 넘겨주고, 이후부터는 `GenerationTracking`이 현재 실행과 관련된 상태만 다루게 됩니다.

 

 

 

# 03. 최종 쿠폰 생성 유스케이스 코드

 

@Component
@Transactional(readOnly = true)
public class CouponFacade {

	private final CouponService couponService;
	private final CouponPolicyService couponPolicyService;
	private final CampaignService campaignService;
	private final PointService pointService;
	private final CouponGenerationTracker generationTracker;

	public CouponFacade(CouponService couponService, CouponPolicyService couponPolicyService,
						CampaignService campaignService, PointService pointService,
						CouponGenerationTracker generationTracker) {
		this.couponService = couponService;
		this.couponPolicyService = couponPolicyService;
		this.campaignService = campaignService;
		this.pointService = pointService;
		this.generationTracker = generationTracker;
	}

	@GenerationMetrics
	@Transactional
	public int createCoupons(CreateCouponsCommand command) {
		CouponPolicy policy = couponPolicyService.getById(command.getPolicyId());

		if (!policy.getCampaign().getId().equals(command.getCampaignId())) {
			throw new BusinessException(COUPON_POLICY_CAMPAIGN_MISMATCH);
		}

		try (GenerationTracking tracking = generationTracker.start(command.getCampaignId(), command.getPolicyId())) {
			couponService.createBatch(policy, policy.getQuantity(), tracking.progressCallback());
			return policy.getQuantity();
		}
	}
}

 

기존 `CouponFacade`가 직접 알고 있던 다음 구현 세부사항들은 모두 사라졌습니다.

  • MeterRegistry
  • Timer
  • Counter
  • Gauge
  • Tags
  • ConcurrentHashMap
  • AtmoicInteger
  • Gauge 생성 및 캐싱 방식

위와 같은 메트릭 관련된 책임은 각각의 성격에 따라 분리되었습니다

  • `GenerationMetricsAspect`
  • `CouponGenerationTracker`
  • `GenerationTracking`
  • `CouponFacade`

 

 


 

코드를 조금이라도 더 클린하게 작성하고자 고민하고 개선해나가는게 즐거운데,

실무에서는 결과와 지표가 가장 중요하고,

AI가 생성해주는 코드도 점점 많아지고 ...

 

그만큼 인간 엔지니어는 더 넓은 시야에서 다양한 문제 해결에 대해 고민할수 있게 되어가지만,

그래도 여전히 코드를 읽고 좋은 코드를 작성하는 능력이 중요하다고 생각합니다.