# 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가 생성해주는 코드도 점점 많아지고 ...
그만큼 인간 엔지니어는 더 넓은 시야에서 다양한 문제 해결에 대해 고민할수 있게 되어가지만,
그래도 여전히 코드를 읽고 좋은 코드를 작성하는 능력이 중요하다고 생각합니다.
📚 References
'Spring' 카테고리의 다른 글
| Spring Boot 로깅의 표준 Logback과 SLF4J 알아보기 (0) | 2025.12.30 |
|---|---|
| 쿼리 지옥 JPA의 N+1 문제를 둘러싼 오해 (0) | 2025.08.26 |
| @Transactional 메서드에서는 Synchronized 동기화가 통하지 않는다?!?!? (0) | 2025.08.19 |
| 컨트롤러 요청/응답 가로채기, 스프링 인터셉터(Interceptor) (0) | 2025.05.13 |