Skip to content

Commit 186a4cc

Browse files
authored
infra: 서버 JVM, 비즈니스 커스텀 지표 관측 prometheus 파이프라인 구축
* feat: alloy prometheus 관측 구성, 관측 전용계층 패키지명 observability으로 변경 * feat: 커스텀 메트릭 정의 * feat: 좌석 선택 커스텀 메트릭 수집코드 추가 * feat: payment, ticket 커스텀 메트릭 수집 * feat: error 메트릭 수집 * feat: scheduler 메트릭 수집 * fix: 테스트 생성자 에러 수정
1 parent 35b5378 commit 186a4cc

28 files changed

Lines changed: 475 additions & 36 deletions

backend/src/main/java/com/back/api/event/scheduler/EventOpenScheduler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import com.back.domain.event.entity.Event;
1414
import com.back.domain.event.entity.EventStatus;
1515
import com.back.domain.event.repository.EventRepository;
16-
import com.back.global.logging.MdcContext;
16+
import com.back.global.observability.MdcContext;
17+
import com.back.global.observability.metrics.SchedulerMetrics;
1718

1819
import lombok.RequiredArgsConstructor;
1920
import lombok.extern.slf4j.Slf4j;
@@ -25,6 +26,7 @@
2526
public class EventOpenScheduler {
2627

2728
private final EventRepository eventRepository;
29+
private final SchedulerMetrics schedulerMetrics;
2830

2931
/**
3032
* READY → PRE_OPEN (사전등록 시작)
@@ -172,6 +174,7 @@ private void processStatusTransition(
172174
jobName, System.currentTimeMillis() - startAt, ex.toString(), ex
173175
);
174176
} finally {
177+
schedulerMetrics.recordDuration(jobName, System.currentTimeMillis() - startAt);
175178
MdcContext.removeRunId();
176179
}
177180
}

backend/src/main/java/com/back/api/payment/payment/service/PaymentService.java

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import com.back.domain.ticket.entity.Ticket;
2525
import com.back.global.error.code.PaymentErrorCode;
2626
import com.back.global.error.exception.ErrorException;
27+
import com.back.global.observability.metrics.BusinessMetrics;
2728

2829
import lombok.RequiredArgsConstructor;
2930
import lombok.extern.slf4j.Slf4j;
@@ -52,6 +53,7 @@ public class PaymentService {
5253
private final ApplicationEventPublisher eventPublisher;
5354
private final PaymentRepository paymentRepository;
5455
private final TossPaymentService tossPaymentService;
56+
private final BusinessMetrics businessMetrics;
5557

5658
@Transactional
5759
public PaymentReceiptResponse confirmPayment(
@@ -75,11 +77,13 @@ public PaymentReceiptResponse confirmPayment(
7577
if (!result.success()) {
7678
order.markFailed();
7779
ticketService.failPayment(order.getTicket().getId()); // Ticket FAILED + Seat 해제
80+
businessMetrics.paymentConfirmFailure("PAYMENT_FAILED");
7881
throw new ErrorException(PaymentErrorCode.PAYMENT_FAILED);
7982
}
8083

8184
// PG사에서 받은 paymentKey와 클라이언트가 보낸 paymentKey 일치 여부 검증
8285
if (!result.paymentKey().equals(clientPaymentKey)) {
86+
businessMetrics.paymentConfirmFailure("PAYMENT_KEY_MISMATCH");
8387
throw new ErrorException(PaymentErrorCode.PAYMENT_KEY_MISMATCH);
8488
}
8589

@@ -92,6 +96,9 @@ public PaymentReceiptResponse confirmPayment(
9296
userId
9397
);
9498

99+
// 결제 성공 메트릭
100+
businessMetrics.paymentConfirmSuccess(ticket.getEvent().getId());
101+
95102
// Queue 완료
96103
queueEntryProcessService.completePayment(
97104
ticket.getEvent().getId(),
@@ -156,6 +163,7 @@ public V2_PaymentConfirmResponse v2_confirmPayment(
156163
if (result.status() != ApproveStatus.DONE) { // 결제 승인 완료시 토스 API 응답 : Status = "DONE"
157164
order.markFailed();
158165
ticketService.failPayment(order.getTicket().getId()); // Ticket FAILED + Seat 해제
166+
businessMetrics.paymentConfirmFailure("TOSS_PAYMENT_NOT_DONE");
159167
//TODO 결제 실패 로직 추가
160168
throw new ErrorException(PaymentErrorCode.PAYMENT_FAILED);
161169
}
@@ -185,6 +193,9 @@ public V2_PaymentConfirmResponse v2_confirmPayment(
185193
userId
186194
);
187195

196+
// 결제 성공 메트릭
197+
businessMetrics.paymentConfirmSuccess(ticket.getEvent().getId());
198+
188199
// Queue 완료 // 테스트 데이터로 진행 시 : 큐 대기열이 없으므로 이부분도 주석처리
189200
queueEntryProcessService.completePayment(
190201
ticket.getEvent().getId(),

backend/src/main/java/com/back/api/queue/scheduler/QueueEntryScheduler.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@
1313
import com.back.api.queue.service.QueueEntryProcessService;
1414
import com.back.domain.event.entity.Event;
1515
import com.back.domain.event.entity.EventStatus;
16-
import com.back.global.logging.MdcContext;
16+
import com.back.global.observability.MdcContext;
17+
import com.back.global.observability.metrics.SchedulerMetrics;
1718

1819
import lombok.RequiredArgsConstructor;
1920
import lombok.extern.slf4j.Slf4j;
@@ -29,8 +30,11 @@
2930
@Profile({"perf"})
3031
public class QueueEntryScheduler {
3132

33+
private static final String JOB_NAME = "QueueEntry";
34+
3235
private final QueueEntryProcessService queueEntryProcessService;
3336
private final EventService eventService;
37+
private final SchedulerMetrics schedulerMetrics;
3438

3539
//대기열 자동 입장 처리
3640
@Scheduled(cron = "${queue.scheduler.entry.cron}", zone = "Asia/Seoul") //10초마다 실행
@@ -88,6 +92,7 @@ public void autoQueueEntries() {
8892
ex
8993
);
9094
} finally {
95+
schedulerMetrics.recordDuration(JOB_NAME, System.currentTimeMillis() - startAt);
9196
MdcContext.removeRunId();
9297
}
9398
}

backend/src/main/java/com/back/api/queue/scheduler/QueueExpireScheduler.java

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@
1414
import com.back.domain.queue.entity.QueueEntry;
1515
import com.back.domain.queue.entity.QueueEntryStatus;
1616
import com.back.domain.queue.repository.QueueEntryRepository;
17-
import com.back.global.logging.MdcContext;
17+
import com.back.global.observability.MdcContext;
18+
import com.back.global.observability.metrics.SchedulerMetrics;
1819

1920
import lombok.RequiredArgsConstructor;
2021
import lombok.extern.slf4j.Slf4j;
@@ -25,8 +26,11 @@
2526
@Profile({"perf"})
2627
public class QueueExpireScheduler {
2728

29+
private static final String JOB_NAME = "QueueExpire";
30+
2831
private final QueueEntryRepository queueEntryRepository;
2932
private final QueueEntryProcessService queueEntryProcessService;
33+
private final SchedulerMetrics schedulerMetrics;
3034

3135
@Scheduled(cron = "${queue.scheduler.expire.cron}", zone = "Asia/Seoul")
3236
@SchedulerLock(
@@ -87,9 +91,9 @@ public void autoExpireEntries() {
8791
ex
8892
);
8993
} finally {
94+
schedulerMetrics.recordDuration(JOB_NAME, System.currentTimeMillis() - startAt);
9095
MdcContext.removeRunId();
9196
}
92-
9397
}
9498

9599
}

backend/src/main/java/com/back/api/queue/scheduler/QueueShuffleScheduler.java

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
import com.back.domain.event.entity.EventStatus;
1717
import com.back.domain.preregister.repository.PreRegisterRepository;
1818
import com.back.domain.queue.repository.QueueEntryRepository;
19-
import com.back.global.logging.MdcContext;
19+
import com.back.global.observability.MdcContext;
20+
import com.back.global.observability.metrics.SchedulerMetrics;
2021
import com.back.global.properties.QueueSchedulerProperties;
2122

2223
import lombok.RequiredArgsConstructor;
@@ -38,6 +39,7 @@ public class QueueShuffleScheduler {
3839
private final EventService eventService;
3940
private final PreRegisterRepository preRegisterRepository;
4041
private final QueueSchedulerProperties properties;
42+
private final SchedulerMetrics schedulerMetrics;
4143

4244
@Scheduled(cron = "${queue.scheduler.shuffle.cron}", zone = "Asia/Seoul")
4345
@SchedulerLock(
@@ -127,6 +129,7 @@ public void autoShuffleQueue() {
127129
ex
128130
);
129131
} finally {
132+
schedulerMetrics.recordDuration(JOB_NAME, System.currentTimeMillis() - startAt);
130133
MdcContext.removeRunId();
131134
}
132135
}

backend/src/main/java/com/back/api/seat/service/SeatService.java

Lines changed: 56 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import com.back.global.error.code.SeatErrorCode;
1515
import com.back.global.error.exception.ErrorException;
1616
import com.back.global.event.EventPublisher;
17+
import com.back.global.observability.metrics.BusinessMetrics;
1718

1819
import lombok.RequiredArgsConstructor;
1920

@@ -27,6 +28,7 @@ public class SeatService {
2728
private final SeatRepository seatRepository;
2829
private final QueueEntryReadService queueEntryReadService;
2930
private final EventPublisher eventPublisher;
31+
private final BusinessMetrics businessMetrics;
3032

3133
// 이벤트의 좌석 목록 조회
3234
@Transactional(readOnly = true)
@@ -43,78 +45,105 @@ public List<Seat> getSeatsByEvent(Long eventId, Long userId, SeatGrade grade) {
4345
}
4446

4547
// 좌석 예약 (AVAILABLE -> RESERVED)
48+
// 원자적 업데이트로 동시성 제어
4649
@Transactional
4750
public Seat reserveSeat(Long eventId, Long seatId, Long userId) {
48-
// 1) AVAILABLE -> RESERVED를 원자적으로 시도
51+
// AVAILABLE -> RESERVED를 원자적으로 시도
4952
int updated = seatRepository.updateSeatStatusIfMatch(
5053
eventId, seatId,
5154
SeatStatus.AVAILABLE, SeatStatus.RESERVED
5255
);
5356

5457
if (updated == 0) {
55-
// 2) 실패 원인 구분
58+
// 동시성 충돌 발생 (CAS 실패)
59+
businessMetrics.seatConcurrencyConflict(eventId);
60+
61+
// 실패: 좌석이 없거나, 이미 다른 상태로 변경됨
5662
Seat current = seatRepository.findByEventIdAndId(eventId, seatId)
5763
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
5864

5965
if (current.getSeatStatus() == SeatStatus.SOLD) {
66+
businessMetrics.seatSelectionFailure(eventId, "ALREADY_SOLD");
6067
throw new ErrorException(SeatErrorCode.SEAT_ALREADY_SOLD);
6168
}
6269
if (current.getSeatStatus() == SeatStatus.RESERVED) {
70+
businessMetrics.seatSelectionFailure(eventId, "ALREADY_RESERVED");
6371
throw new ErrorException(SeatErrorCode.SEAT_ALREADY_RESERVED);
6472
}
65-
// 그 외 상태면 경합/선점
73+
businessMetrics.seatSelectionFailure(eventId, "CONCURRENCY_FAILURE");
6674
throw new ErrorException(SeatErrorCode.SEAT_CONCURRENCY_FAILURE);
6775
}
6876

69-
// 3) 성공했으면 최신 상태의 Seat 반환 (이벤트 발행용)
70-
Seat reserved = seatRepository.findByEventIdAndId(eventId, seatId)
77+
// 성공: 좌석 조회 후 이벤트 발행
78+
Seat seat = seatRepository.findByEventIdAndId(eventId, seatId)
7179
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
7280

73-
eventPublisher.publishEvent(SeatStatusMessage.from(reserved));
74-
return reserved;
81+
SeatStatusMessage message = new SeatStatusMessage(
82+
eventId,
83+
seat.getId(),
84+
seat.getSeatCode(),
85+
seat.getSeatStatus().name(),
86+
seat.getPrice(),
87+
seat.getGrade().name()
88+
);
89+
90+
eventPublisher.publishEvent(message);
91+
92+
// 좌석 선택 성공 메트릭
93+
businessMetrics.seatSelectionSuccess(eventId);
94+
95+
return seat;
7596
}
7697

7798
// 좌석을 SOLD 상태로 변경 (결제 완료 시)
7899
// RESERVED -> SOLD 원자적 업데이트
79100
@Transactional
80101
public void markSeatAsSold(Long eventId, Long seatId) {
81-
// 1) RESERVED -> SOLD를 원자적으로 시도
102+
// RESERVED -> SOLD를 원자적으로 시도
82103
int updated = seatRepository.updateSeatStatusIfMatch(
83104
eventId, seatId,
84105
SeatStatus.RESERVED, SeatStatus.SOLD
85106
);
86107

87108
if (updated == 0) {
88-
// 2) 실패 원인 구분
109+
// 실패: 실패 원인 구분
89110
Seat current = seatRepository.findByEventIdAndId(eventId, seatId)
90111
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
91112

92113
if (current.getSeatStatus() == SeatStatus.SOLD) {
93114
throw new ErrorException(SeatErrorCode.SEAT_ALREADY_SOLD);
94115
}
95-
// AVAILABLE 또는 다른 상태면 상태 전이 오류
96116
throw new ErrorException(SeatErrorCode.SEAT_STATUS_TRANSITION);
97117
}
98118

99-
// 3) 성공했으면 최신 상태의 Seat 반환 (이벤트 발행용)
100-
Seat sold = seatRepository.findByEventIdAndId(eventId, seatId)
119+
// 성공: 좌석 조회 후 이벤트 발행
120+
Seat seat = seatRepository.findByEventIdAndId(eventId, seatId)
101121
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
102122

103-
eventPublisher.publishEvent(SeatStatusMessage.from(sold));
123+
SeatStatusMessage message = new SeatStatusMessage(
124+
eventId,
125+
seat.getId(),
126+
seat.getSeatCode(),
127+
seat.getSeatStatus().name(),
128+
seat.getPrice(),
129+
seat.getGrade().name()
130+
);
131+
132+
eventPublisher.publishEvent(message);
104133
}
105134

106135
// 예약 취소 또는 결제 실패 시
107136
// RESERVED -> AVAILABLE 원자적 업데이트
108137
@Transactional
109138
public void markSeatAsAvailable(Long eventId, Long seatId) {
110-
// 1) RESERVED -> AVAILABLE를 원자적으로 시도
139+
// RESERVED -> AVAILABLE를 원자적으로 시도
111140
int updated = seatRepository.updateSeatStatusIfMatch(
112141
eventId, seatId,
113142
SeatStatus.RESERVED, SeatStatus.AVAILABLE
114143
);
115144

116145
if (updated == 0) {
117-
// 2) 실패 원인 구분
146+
// 실패: 실패 원인 구분
118147
Seat current = seatRepository.findByEventIdAndId(eventId, seatId)
119148
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
120149

@@ -125,14 +154,22 @@ public void markSeatAsAvailable(Long eventId, Long seatId) {
125154
// 이미 AVAILABLE이면 무시 (멱등성)
126155
return;
127156
}
128-
// 다른 상태면 상태 전이 오류
129157
throw new ErrorException(SeatErrorCode.SEAT_STATUS_TRANSITION);
130158
}
131159

132-
// 3) 성공했으면 최신 상태의 Seat 반환 (이벤트 발행용)
133-
Seat available = seatRepository.findByEventIdAndId(eventId, seatId)
160+
// 성공: 좌석 조회 후 이벤트 발행
161+
Seat seat = seatRepository.findByEventIdAndId(eventId, seatId)
134162
.orElseThrow(() -> new ErrorException(SeatErrorCode.NOT_FOUND_SEAT));
135163

136-
eventPublisher.publishEvent(SeatStatusMessage.from(available));
164+
SeatStatusMessage message = new SeatStatusMessage(
165+
eventId,
166+
seat.getId(),
167+
seat.getSeatCode(),
168+
seat.getSeatStatus().name(),
169+
seat.getPrice(),
170+
seat.getGrade().name()
171+
);
172+
173+
eventPublisher.publishEvent(message);
137174
}
138175
}

backend/src/main/java/com/back/api/ticket/scheduler/DraftTicketExpirationScheduler.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
import com.back.domain.ticket.entity.Ticket;
1717
import com.back.domain.ticket.entity.TicketStatus;
1818
import com.back.domain.ticket.repository.TicketRepository;
19-
import com.back.global.logging.MdcContext;
19+
import com.back.global.observability.MdcContext;
20+
import com.back.global.observability.metrics.SchedulerMetrics;
2021

2122
import lombok.RequiredArgsConstructor;
2223
import lombok.extern.slf4j.Slf4j;
@@ -27,11 +28,13 @@
2728
@Profile({"perf"})
2829
public class DraftTicketExpirationScheduler {
2930

31+
private static final String JOB_NAME = "DraftTicketExpiration";
3032
private static final int PAGE_SIZE = 500;
3133
private static final int MAX_PER_RUN = 2000;
3234

3335
private final TicketRepository ticketRepository;
3436
private final TicketService ticketService;
37+
private final SchedulerMetrics schedulerMetrics;
3538

3639
@Scheduled(fixedRate = 60_000)
3740
@SchedulerLock(
@@ -120,6 +123,7 @@ public void expireDraftTicketsInternal() {
120123
durationMs, ex.toString(), ex
121124
);
122125
} finally {
126+
schedulerMetrics.recordDuration(JOB_NAME, System.currentTimeMillis() - startAt);
123127
MdcContext.removeRunId();
124128
}
125129
}

backend/src/main/java/com/back/api/ticket/service/TicketService.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
import com.back.global.error.code.EventErrorCode;
2121
import com.back.global.error.code.TicketErrorCode;
2222
import com.back.global.error.exception.ErrorException;
23+
import com.back.global.observability.metrics.BusinessMetrics;
2324

2425
import lombok.RequiredArgsConstructor;
2526
import lombok.extern.slf4j.Slf4j;
@@ -36,6 +37,7 @@ public class TicketService {
3637
private final UserRepository userRepository;
3738
private final EventRepository eventRepository;
3839
private final SeatService seatService;
40+
private final BusinessMetrics businessMetrics;
3941

4042
/**
4143
* Draft Ticket 조회 또는 생성 (유저+이벤트당 1개 유지)
@@ -169,6 +171,9 @@ public void expireDraftTicket(Long ticketId) {
169171
// 핵심 책임: 상태 변경은 무조건
170172
ticket.fail();
171173

174+
// Draft 티켓 만료 메트릭
175+
businessMetrics.draftTicketExpired(ticket.getEvent().getId());
176+
172177
// 부가 책임: 좌석이 있으면 해제 (원자적 업데이트)
173178
if (ticket.getSeat() != null) {
174179
seatService.markSeatAsAvailable(ticket.getEvent().getId(), ticket.getSeat().getId());

0 commit comments

Comments
 (0)