Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added backend/.DS_Store
Binary file not shown.
2 changes: 1 addition & 1 deletion backend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -43,4 +43,4 @@ db_dev.trace.db
### yml
/src/main/resources/application-secret.yml
apiV1.json
.env
.env
1 change: 1 addition & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ dependencies {
// ๋ชจ๋‹ˆํ„ฐ๋ง
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'io.micrometer:micrometer-registry-prometheus'
implementation 'io.prometheus:prometheus-metrics-model:1.0.0'
}

tasks.named('test') {
Expand Down
Binary file added backend/src/.DS_Store
Binary file not shown.
Binary file added backend/src/main/.DS_Store
Binary file not shown.
Binary file added backend/src/main/java/.DS_Store
Binary file not shown.
Binary file added backend/src/main/java/com/.DS_Store
Binary file not shown.
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.Catch_Course.domain.payments.entity;

import jakarta.persistence.*;
import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@EntityListeners(AuditingEntityListener.class)
public class PaymentOutbox {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

@Column(nullable = false)
private Long aggregateId;

@Column(nullable = false)
private String aggregateType;

@Column(columnDefinition = "TEXT", nullable = false)
private String payload;

@CreatedDate
@Column(updatable = false)
private LocalDateTime createdAt;

@Builder
public PaymentOutbox(Long aggregateId, String aggregateType, String payload) {
this.aggregateId = aggregateId;
this.aggregateType = aggregateType;
this.payload = payload;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package com.Catch_Course.domain.payments.repository;

import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;

public interface PaymentOutboxRepository extends JpaRepository<PaymentOutbox, Long> {
List<PaymentOutbox> findTop100ByOrderByCreatedAtAsc();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package com.Catch_Course.domain.payments.service;

import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import com.Catch_Course.domain.payments.repository.PaymentOutboxRepository;
import com.Catch_Course.global.kafka.dto.PaymentCancelRequest;
import com.Catch_Course.global.kafka.producer.PaymentCancelProducer;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;

@Slf4j
@Component
@RequiredArgsConstructor
public class OutboxScheduler {

private final PaymentOutboxRepository paymentOutboxRepository;
private final PaymentCancelProducer paymentCancelProducer;
private final ObjectMapper objectMapper;

// 10์ดˆ๋งˆ๋‹ค ์‹คํ–‰ (fixedDelayString = "10000")
@Scheduled(fixedDelay = 10000)
@Transactional
public void pollAndPublish() {
List<PaymentOutbox> events = paymentOutboxRepository.findTop100ByOrderByCreatedAtAsc();
if (events.isEmpty()) {
return;
}

log.info("[Outbox] ๋ฏธ์ฒ˜๋ฆฌ ์ด๋ฒคํŠธ {}๊ฑด์„ ์กฐํšŒํ–ˆ์Šต๋‹ˆ๋‹ค. ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰์„ ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.", events.size());

for (PaymentOutbox event : events) {
try {
// 2. ํŽ˜์ด๋กœ๋“œ๋ฅผ ์—ญ์ง๋ ฌํ™”
PaymentCancelRequest payload = objectMapper.readValue(event.getPayload(), PaymentCancelRequest.class);

// 3. ๋ฉ”์‹œ์ง€ ๋ฐœํ–‰
paymentCancelProducer.send(payload);

// 4. ์•„์›ƒ๋ฐ•์Šค ํ…Œ์ด๋ธ”์—์„œ ์‚ญ์ œ
paymentOutboxRepository.delete(event);

} catch (Exception e) {
log.error("[Outbox] ์ด๋ฒคํŠธ ์ฒ˜๋ฆฌ ์‹คํŒจ. eventId: {}. ์—๋Ÿฌ: {}", event.getId(), e.getMessage());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
import com.Catch_Course.domain.member.entity.Member;
import com.Catch_Course.domain.payments.dto.PaymentDto;
import com.Catch_Course.domain.payments.entity.Payment;
import com.Catch_Course.domain.payments.entity.PaymentOutbox;
import com.Catch_Course.domain.payments.entity.PaymentStatus;
import com.Catch_Course.domain.payments.repository.PaymentOutboxRepository;
import com.Catch_Course.domain.payments.repository.PaymentRepository;
import com.Catch_Course.domain.reservation.entity.Reservation;
import com.Catch_Course.domain.reservation.entity.ReservationStatus;
Expand All @@ -12,9 +14,9 @@
import com.Catch_Course.global.kafka.dto.PaymentCancelRequest;
import com.Catch_Course.global.kafka.producer.PaymentCancelProducer;
import com.Catch_Course.global.payment.TossPaymentsService;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -35,7 +37,8 @@ public class PaymentService {
private final ReservationRepository reservationRepository;
private final TossPaymentsService tossPaymentsService;
private final PaymentCancelProducer paymentCancelProducer;
private final ApplicationEventPublisher eventPublisher;
private final PaymentOutboxRepository paymentOutboxRepository;
private final ObjectMapper objectMapper;

public PaymentDto getPayment(Member member, Long reservationId) {

Expand Down Expand Up @@ -149,13 +152,24 @@ public PaymentDto deletePaymentRequest(Member member, Long reservationId) {
throw new ServiceException("409-2", "์ด๋ฏธ ์ทจ์†Œ๋œ ๊ฒฐ์ œ์ž…๋‹ˆ๋‹ค.");
}

// ์ทจ์†Œ ์š”์ฒญ ์ƒํƒœ
// DB ์ƒํƒœ ๋ณ€๊ฒฝ
payment.setStatus(PaymentStatus.CANCEL_REQUESTED);
paymentRepository.save(payment);

// ๋ฉ”์„ธ์ง€ ์ง์ ‘ ๋ฐœํ–‰ ๋Œ€์‹  ๋‚ด๋ถ€ ์ด๋ฒคํŠธ๋กœ ๋ฐœํ–‰
String cancelReason = "๊ณ ๊ฐ ์š”์ฒญ";
PaymentCancelRequest request = new PaymentCancelRequest(payment, cancelReason, reservation, member);
eventPublisher.publishEvent(request);
PaymentCancelRequest requestPayload = new PaymentCancelRequest(payment, cancelReason, reservation, member);

try {
String payloadJson = objectMapper.writeValueAsString(requestPayload);
PaymentOutbox outboxEvent = PaymentOutbox.builder()
.aggregateId(payment.getId())
.aggregateType("PAYMENT_CANCEL")
.payload(payloadJson)
.build();
paymentOutboxRepository.save(outboxEvent);
} catch (Exception e) {
throw new RuntimeException("Outbox payload ์ง๋ ฌํ™”์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.", e);
}

return new PaymentDto(paymentRepository.save(payment));
}
Expand Down
2 changes: 1 addition & 1 deletion backend/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,4 @@ spring:
app:
registration-time:
start: "09:00"
duration-in-minutes: 900
duration-in-minutes: 1080
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@
import java.util.List;
import java.util.function.Supplier;

import static java.util.concurrent.TimeUnit.SECONDS;
import static org.assertj.core.api.Assertions.assertThat;
import static org.awaitility.Awaitility.await;
import static org.hamcrest.Matchers.*;
import static org.mockito.Mockito.when;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
Expand Down Expand Up @@ -229,14 +231,28 @@ void cancelReservation() throws Exception {
@DisplayName("์ˆ˜๊ฐ• ์ทจ์†Œ ์‹คํŒจ - ์ด๋ฏธ ์ทจ์†Œ")
void cancelReservation2() throws Exception {
Long courseId = 1L;
cancelReservation(); // ์ˆ˜๊ฐ• ์ทจ์†Œ
Thread.sleep(1000);
cancelReservation();

// then 1: DB์—์„œ ๋ฐฉ๊ธˆ ์ƒ์„ฑ๋œ ์˜ˆ์•ฝ์„ ์ง์ ‘ ์กฐํšŒํ•˜์—ฌ ID๋ฅผ ํ™•๋ณด
Course course = courseService.findById(courseId);
Reservation reservation = reservationRepository.findByStudentAndCourse(loginedMember, course)
.orElseThrow(() -> new AssertionError("ํ…Œ์ŠคํŠธ ์˜ˆ์•ฝ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));
Long reservationId = reservation.getId();

// then 2: Awaitility๋ฅผ ์‚ฌ์šฉํ•ด ๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๊ฐ€ ์™„๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
await().atMost(5, SECONDS).untilAsserted(() -> {
Reservation updatedReservation = reservationRepository.findById(reservationId)
.orElseThrow(() -> new AssertionError("์˜ˆ์•ฝ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค."));
assertThat(updatedReservation.getStatus()).isEqualTo(ReservationStatus.CANCELLED);
});

// when 2: ๋‘ ๋ฒˆ์งธ ์ˆ˜๊ฐ• ์ทจ์†Œ ์š”์ฒญ
ResultActions resultActions = mvc.perform(
delete("/api/reserve?courseId=%d".formatted(courseId))
.header("Authorization", "Bearer " + token)
).andDo(print());

// then 3: ์ตœ์ข… ๊ฒฐ๊ณผ ๊ฒ€์ฆ
resultActions
.andExpect(status().isConflict())
.andExpect(jsonPath("$.code").value("409-5"))
Expand Down