From 47b01e13d2075ff8f3b98385e281c05bca817b5d Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Mon, 1 Dec 2025 02:47:30 +0900
Subject: [PATCH 01/25] =?UTF-8?q?feat:=20pg-simulator=20=EB=AA=A8=EB=93=88?=
 =?UTF-8?q?=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=84=A4=EC=A0=95=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/pg-simulator/README.md                   |  42 ++++++
 apps/pg-simulator/build.gradle.kts            |  40 ++++++
 .../com/loopers/PaymentGatewayApplication.kt  |  24 ++++
 .../loopers/application/payment/OrderInfo.kt  |  14 ++
 .../payment/PaymentApplicationService.kt      |  88 ++++++++++++
 .../application/payment/PaymentCommand.kt     |  22 +++
 .../application/payment/TransactionInfo.kt    |  39 +++++
 .../com/loopers/config/web/WebMvcConfig.kt    |  13 ++
 .../com/loopers/domain/payment/CardType.kt    |   7 +
 .../com/loopers/domain/payment/Payment.kt     |  87 +++++++++++
 .../loopers/domain/payment/PaymentEvent.kt    |  28 ++++
 .../domain/payment/PaymentEventPublisher.kt   |   6 +
 .../loopers/domain/payment/PaymentRelay.kt    |   7 +
 .../domain/payment/PaymentRepository.kt       |   8 ++
 .../domain/payment/TransactionKeyGenerator.kt |  20 +++
 .../domain/payment/TransactionStatus.kt       |   7 +
 .../com/loopers/domain/user/UserInfo.kt       |   8 ++
 .../payment/PaymentCoreEventPublisher.kt      |  19 +++
 .../payment/PaymentCoreRelay.kt               |  21 +++
 .../payment/PaymentCoreRepository.kt          |  32 +++++
 .../payment/PaymentJpaRepository.kt           |   9 ++
 .../interfaces/api/ApiControllerAdvice.kt     | 119 +++++++++++++++
 .../com/loopers/interfaces/api/ApiResponse.kt |  32 +++++
 .../UserInfoArgumentResolver.kt               |  32 +++++
 .../interfaces/api/payment/PaymentApi.kt      |  60 ++++++++
 .../interfaces/api/payment/PaymentDto.kt      | 136 ++++++++++++++++++
 .../event/payment/PaymentEventListener.kt     |  28 ++++
 .../loopers/support/error/CoreException.kt    |   6 +
 .../com/loopers/support/error/ErrorType.kt    |  11 ++
 .../src/main/resources/application.yml        |  77 ++++++++++
 settings.gradle.kts                           |   1 +
 31 files changed, 1043 insertions(+)
 create mode 100644 apps/pg-simulator/README.md
 create mode 100644 apps/pg-simulator/build.gradle.kts
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt
 create mode 100644 apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt
 create mode 100644 apps/pg-simulator/src/main/resources/application.yml

diff --git a/apps/pg-simulator/README.md b/apps/pg-simulator/README.md
new file mode 100644
index 000000000..118642638
--- /dev/null
+++ b/apps/pg-simulator/README.md
@@ -0,0 +1,42 @@
+## PG-Simulator (PaymentGateway)
+
+### Description
+Loopback BE 과정을 위해 PaymentGateway 를 시뮬레이션하는 App Module 입니다.
+`local` 프로필로 실행 권장하며, 커머스 서비스와의 동시 실행을 위해 서버 포트가 조정되어 있습니다.
+- server port : 8082
+- actuator port : 8083
+
+### Getting Started
+부트 서버를 아래 명령어 혹은 `intelliJ` 통해 실행해주세요.
+```shell
+./gradlew :apps:pg-simulator:bootRun
+```
+
+API 는 아래와 같이 주어지니, 커머스 서비스와 동시에 실행시킨 후 진행해주시면 됩니다.
+- 결제 요청 API
+- 결제 정보 확인 `by transactionKey`
+- 결제 정보 목록 조회 `by orderId`
+
+```http request
+### 결제 요청
+POST {{pg-simulator}}/api/v1/payments
+X-USER-ID: 135135
+Content-Type: application/json
+
+{
+  "orderId": "1351039135",
+  "cardType": "SAMSUNG",
+  "cardNo": "1234-5678-9814-1451",
+  "amount" : "5000",
+  "callbackUrl": "http://localhost:8080/api/v1/examples/callback"
+}
+
+### 결제 정보 확인
+GET {{pg-simulator}}/api/v1/payments/20250816:TR:9577c5
+X-USER-ID: 135135
+
+### 주문에 엮인 결제 정보 조회
+GET {{pg-simulator}}/api/v1/payments?orderId=1351039135
+X-USER-ID: 135135
+
+```
\ No newline at end of file
diff --git a/apps/pg-simulator/build.gradle.kts b/apps/pg-simulator/build.gradle.kts
new file mode 100644
index 000000000..653d549da
--- /dev/null
+++ b/apps/pg-simulator/build.gradle.kts
@@ -0,0 +1,40 @@
+plugins {
+    val kotlinVersion = "2.0.20"
+
+    id("org.jetbrains.kotlin.jvm") version(kotlinVersion)
+    id("org.jetbrains.kotlin.kapt") version(kotlinVersion)
+    id("org.jetbrains.kotlin.plugin.spring") version(kotlinVersion)
+    id("org.jetbrains.kotlin.plugin.jpa") version(kotlinVersion)
+}
+
+kotlin {
+    compilerOptions {
+        jvmToolchain(21)
+        freeCompilerArgs.addAll("-Xjsr305=strict")
+    }
+}
+
+dependencies {
+    // add-ons
+    implementation(project(":modules:jpa"))
+    implementation(project(":modules:redis"))
+    implementation(project(":supports:jackson"))
+    implementation(project(":supports:logging"))
+    implementation(project(":supports:monitoring"))
+
+    // kotlin
+    implementation("org.jetbrains.kotlin:kotlin-reflect")
+    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
+
+    // web
+    implementation("org.springframework.boot:spring-boot-starter-web")
+    implementation("org.springframework.boot:spring-boot-starter-actuator")
+    implementation("org.springdoc:springdoc-openapi-starter-webmvc-ui:${project.properties["springDocOpenApiVersion"]}")
+
+    // querydsl
+    kapt("com.querydsl:querydsl-apt::jakarta")
+
+    // test-fixtures
+    testImplementation(testFixtures(project(":modules:jpa")))
+    testImplementation(testFixtures(project(":modules:redis")))
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
new file mode 100644
index 000000000..05595d135
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/PaymentGatewayApplication.kt
@@ -0,0 +1,24 @@
+package com.loopers
+
+import jakarta.annotation.PostConstruct
+import org.springframework.boot.autoconfigure.SpringBootApplication
+import org.springframework.boot.context.properties.ConfigurationPropertiesScan
+import org.springframework.boot.runApplication
+import org.springframework.scheduling.annotation.EnableAsync
+import java.util.TimeZone
+
+@ConfigurationPropertiesScan
+@EnableAsync
+@SpringBootApplication
+class PaymentGatewayApplication {
+
+    @PostConstruct
+    fun started() {
+        // set timezone
+        TimeZone.setDefault(TimeZone.getTimeZone("Asia/Seoul"))
+    }
+}
+
+fun main(args: Array<String>) {
+    runApplication<PaymentGatewayApplication>(*args)
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt
new file mode 100644
index 000000000..7e04d1ce0
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/OrderInfo.kt
@@ -0,0 +1,14 @@
+package com.loopers.application.payment
+
+/**
+ * 결제 주문 정보
+ *
+ * 결제는 주문에 대한 다수 트랜잭션으로 구성됩니다.
+ *
+ * @property orderId 주문 정보
+ * @property transactions 주문에 엮인 트랜잭션 목록
+ */
+data class OrderInfo(
+    val orderId: String,
+    val transactions: List<TransactionInfo>,
+)
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
new file mode 100644
index 000000000..9a5ebdc5d
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentApplicationService.kt
@@ -0,0 +1,88 @@
+package com.loopers.application.payment
+
+import com.loopers.domain.payment.Payment
+import com.loopers.domain.payment.PaymentEvent
+import com.loopers.domain.payment.PaymentEventPublisher
+import com.loopers.domain.payment.PaymentRelay
+import com.loopers.domain.payment.PaymentRepository
+import com.loopers.domain.payment.TransactionKeyGenerator
+import com.loopers.domain.user.UserInfo
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+import org.springframework.stereotype.Component
+import org.springframework.transaction.annotation.Transactional
+
+@Component
+class PaymentApplicationService(
+    private val paymentRepository: PaymentRepository,
+    private val paymentEventPublisher: PaymentEventPublisher,
+    private val paymentRelay: PaymentRelay,
+    private val transactionKeyGenerator: TransactionKeyGenerator,
+) {
+    companion object {
+        private val RATE_LIMIT_EXCEEDED = (1..20)
+        private val RATE_INVALID_CARD = (21..30)
+    }
+
+    @Transactional
+    fun createTransaction(command: PaymentCommand.CreateTransaction): TransactionInfo {
+        command.validate()
+
+        val transactionKey = transactionKeyGenerator.generate()
+        val payment = paymentRepository.save(
+            Payment(
+                transactionKey = transactionKey,
+                userId = command.userId,
+                orderId = command.orderId,
+                cardType = command.cardType,
+                cardNo = command.cardNo,
+                amount = command.amount,
+                callbackUrl = command.callbackUrl,
+            ),
+        )
+
+        paymentEventPublisher.publish(PaymentEvent.PaymentCreated.from(payment = payment))
+
+        return TransactionInfo.from(payment)
+    }
+
+    @Transactional(readOnly = true)
+    fun getTransactionDetailInfo(userInfo: UserInfo, transactionKey: String): TransactionInfo {
+        val payment = paymentRepository.findByTransactionKey(userId = userInfo.userId, transactionKey = transactionKey)
+            ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
+        return TransactionInfo.from(payment)
+    }
+
+    @Transactional(readOnly = true)
+    fun findTransactionsByOrderId(userInfo: UserInfo, orderId: String): OrderInfo {
+        val payments = paymentRepository.findByOrderId(userId = userInfo.userId, orderId = orderId)
+        if (payments.isEmpty()) {
+            throw CoreException(ErrorType.NOT_FOUND, "(orderId: $orderId) 에 해당하는 결제건이 존재하지 않습니다.")
+        }
+
+        return OrderInfo(
+            orderId = orderId,
+            transactions = payments.map { TransactionInfo.from(it) },
+        )
+    }
+
+    @Transactional
+    fun handle(transactionKey: String) {
+        val payment = paymentRepository.findByTransactionKey(transactionKey)
+            ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
+
+        val rate = (1..100).random()
+        when (rate) {
+            in RATE_LIMIT_EXCEEDED -> payment.limitExceeded()
+            in RATE_INVALID_CARD -> payment.invalidCard()
+            else -> payment.approve()
+        }
+        paymentEventPublisher.publish(event = PaymentEvent.PaymentHandled.from(payment))
+    }
+
+    fun notifyTransactionResult(transactionKey: String) {
+        val payment = paymentRepository.findByTransactionKey(transactionKey)
+            ?: throw CoreException(ErrorType.NOT_FOUND, "(transactionKey: $transactionKey) 결제건이 존재하지 않습니다.")
+        paymentRelay.notify(callbackUrl = payment.callbackUrl, transactionInfo = TransactionInfo.from(payment))
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt
new file mode 100644
index 000000000..01d8ae440
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/PaymentCommand.kt
@@ -0,0 +1,22 @@
+package com.loopers.application.payment
+
+import com.loopers.domain.payment.CardType
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+
+object PaymentCommand {
+    data class CreateTransaction(
+        val userId: String,
+        val orderId: String,
+        val cardType: CardType,
+        val cardNo: String,
+        val amount: Long,
+        val callbackUrl: String,
+    ) {
+        fun validate() {
+            if (amount <= 0L) {
+                throw CoreException(ErrorType.BAD_REQUEST, "요청 금액은 0 보다 큰 정수여야 합니다.")
+            }
+        }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt
new file mode 100644
index 000000000..5c21e51af
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/application/payment/TransactionInfo.kt
@@ -0,0 +1,39 @@
+package com.loopers.application.payment
+
+import com.loopers.domain.payment.CardType
+import com.loopers.domain.payment.Payment
+import com.loopers.domain.payment.TransactionStatus
+
+/**
+ * 트랜잭션 정보
+ *
+ * @property transactionKey 트랜잭션 KEY
+ * @property orderId 주문 ID
+ * @property cardType 카드 종류
+ * @property cardNo 카드 번호
+ * @property amount 금액
+ * @property status 처리 상태
+ * @property reason 처리 사유
+ */
+data class TransactionInfo(
+    val transactionKey: String,
+    val orderId: String,
+    val cardType: CardType,
+    val cardNo: String,
+    val amount: Long,
+    val status: TransactionStatus,
+    val reason: String?,
+) {
+    companion object {
+        fun from(payment: Payment): TransactionInfo =
+            TransactionInfo(
+                transactionKey = payment.transactionKey,
+                orderId = payment.orderId,
+                cardType = payment.cardType,
+                cardNo = payment.cardNo,
+                amount = payment.amount,
+                status = payment.status,
+                reason = payment.reason,
+            )
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt
new file mode 100644
index 000000000..8aec9dc82
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/config/web/WebMvcConfig.kt
@@ -0,0 +1,13 @@
+package com.loopers.config.web
+
+import com.loopers.interfaces.api.argumentresolver.UserInfoArgumentResolver
+import org.springframework.context.annotation.Configuration
+import org.springframework.web.method.support.HandlerMethodArgumentResolver
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
+
+@Configuration
+class WebMvcConfig : WebMvcConfigurer {
+    override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver?>) {
+        resolvers.add(UserInfoArgumentResolver())
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt
new file mode 100644
index 000000000..55008a95d
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/CardType.kt
@@ -0,0 +1,7 @@
+package com.loopers.domain.payment
+
+enum class CardType {
+    SAMSUNG,
+    KB,
+    HYUNDAI,
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
new file mode 100644
index 000000000..cfc2386c1
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/Payment.kt
@@ -0,0 +1,87 @@
+package com.loopers.domain.payment
+
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+import jakarta.persistence.Column
+import jakarta.persistence.Entity
+import jakarta.persistence.EnumType
+import jakarta.persistence.Enumerated
+import jakarta.persistence.Id
+import jakarta.persistence.Index
+import jakarta.persistence.Table
+import java.time.LocalDateTime
+
+@Entity
+@Table(
+    name = "payments",
+    indexes = [
+        Index(name = "idx_user_transaction", columnList = "user_id, transaction_key"),
+        Index(name = "idx_user_order", columnList = "user_id, order_id"),
+        Index(name = "idx_unique_user_order_transaction", columnList = "user_id, order_id, transaction_key", unique = true),
+    ]
+)
+class Payment(
+    @Id
+    @Column(name = "transaction_key", nullable = false, unique = true)
+    val transactionKey: String,
+
+    @Column(name = "user_id", nullable = false)
+    val userId: String,
+
+    @Column(name = "order_id", nullable = false)
+    val orderId: String,
+
+    @Enumerated(EnumType.STRING)
+    @Column(name = "card_type", nullable = false)
+    val cardType: CardType,
+
+    @Column(name = "card_no", nullable = false)
+    val cardNo: String,
+
+    @Column(name = "amount", nullable = false)
+    val amount: Long,
+
+    @Column(name = "callback_url", nullable = false)
+    val callbackUrl: String,
+) {
+    @Enumerated(EnumType.STRING)
+    @Column(name = "status", nullable = false)
+    var status: TransactionStatus = TransactionStatus.PENDING
+        private set
+
+    @Column(name = "reason", nullable = true)
+    var reason: String? = null
+        private set
+
+    @Column(name = "created_at", nullable = false)
+    var createdAt: LocalDateTime = LocalDateTime.now()
+        private set
+
+    @Column(name = "updated_at", nullable = false)
+    var updatedAt: LocalDateTime = LocalDateTime.now()
+        private set
+
+    fun approve() {
+        if (status != TransactionStatus.PENDING) {
+            throw CoreException(ErrorType.INTERNAL_ERROR, "결제승인은 대기상태에서만 가능합니다.")
+        }
+        status = TransactionStatus.SUCCESS
+        reason = "정상 승인되었습니다."
+    }
+
+    fun invalidCard() {
+        if (status != TransactionStatus.PENDING) {
+            throw CoreException(ErrorType.INTERNAL_ERROR, "결제처리는 대기상태에서만 가능합니다.")
+        }
+        status = TransactionStatus.FAILED
+        reason = "잘못된 카드입니다. 다른 카드를 선택해주세요."
+    }
+
+    fun limitExceeded() {
+        if (status != TransactionStatus.PENDING) {
+            throw CoreException(ErrorType.INTERNAL_ERROR, "한도초과 처리는 대기상태에서만 가능합니다.")
+        }
+        status = TransactionStatus.FAILED
+        reason = "한도초과입니다. 다른 카드를 선택해주세요."
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt
new file mode 100644
index 000000000..8e495b2e3
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEvent.kt
@@ -0,0 +1,28 @@
+package com.loopers.domain.payment
+
+object PaymentEvent {
+    data class PaymentCreated(
+        val transactionKey: String,
+    ) {
+        companion object {
+            fun from(payment: Payment): PaymentCreated = PaymentCreated(transactionKey = payment.transactionKey)
+        }
+    }
+
+    data class PaymentHandled(
+        val transactionKey: String,
+        val status: TransactionStatus,
+        val reason: String?,
+        val callbackUrl: String,
+    ) {
+        companion object {
+            fun from(payment: Payment): PaymentHandled =
+                PaymentHandled(
+                    transactionKey = payment.transactionKey,
+                    status = payment.status,
+                    reason = payment.reason,
+                    callbackUrl = payment.callbackUrl,
+                )
+        }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt
new file mode 100644
index 000000000..251c68319
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentEventPublisher.kt
@@ -0,0 +1,6 @@
+package com.loopers.domain.payment
+
+interface PaymentEventPublisher {
+    fun publish(event: PaymentEvent.PaymentCreated)
+    fun publish(event: PaymentEvent.PaymentHandled)
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt
new file mode 100644
index 000000000..e622899b2
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRelay.kt
@@ -0,0 +1,7 @@
+package com.loopers.domain.payment
+
+import com.loopers.application.payment.TransactionInfo
+
+interface PaymentRelay {
+    fun notify(callbackUrl: String, transactionInfo: TransactionInfo)
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt
new file mode 100644
index 000000000..c1173c0aa
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/PaymentRepository.kt
@@ -0,0 +1,8 @@
+package com.loopers.domain.payment
+
+interface PaymentRepository {
+    fun save(payment: Payment): Payment
+    fun findByTransactionKey(transactionKey: String): Payment?
+    fun findByTransactionKey(userId: String, transactionKey: String): Payment?
+    fun findByOrderId(userId: String, orderId: String): List<Payment>
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
new file mode 100644
index 000000000..c8703a763
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionKeyGenerator.kt
@@ -0,0 +1,20 @@
+package com.loopers.domain.payment
+
+import org.springframework.stereotype.Component
+import java.time.LocalDateTime
+import java.time.format.DateTimeFormatter
+import java.util.UUID
+
+@Component
+class TransactionKeyGenerator {
+    companion object {
+        private const val KEY_TRANSACTION = "TR"
+        private val DATETIME_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd")
+    }
+
+    fun generate(): String {
+        val now = LocalDateTime.now()
+        val uuid = UUID.randomUUID().toString().replace("-", "").substring(0, 6)
+        return "${DATETIME_FORMATTER.format(now)}:$KEY_TRANSACTION:$uuid"
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt
new file mode 100644
index 000000000..0c94bcfb9
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/payment/TransactionStatus.kt
@@ -0,0 +1,7 @@
+package com.loopers.domain.payment
+
+enum class TransactionStatus {
+    PENDING,
+    SUCCESS,
+    FAILED
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt
new file mode 100644
index 000000000..c51e660a9
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/domain/user/UserInfo.kt
@@ -0,0 +1,8 @@
+package com.loopers.domain.user
+
+/**
+ * user 정보
+ *
+ * @param userId 유저 식별자
+ */
+data class UserInfo(val userId: String)
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt
new file mode 100644
index 000000000..715516360
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreEventPublisher.kt
@@ -0,0 +1,19 @@
+package com.loopers.infrastructure.payment
+
+import com.loopers.domain.payment.PaymentEvent
+import com.loopers.domain.payment.PaymentEventPublisher
+import org.springframework.context.ApplicationEventPublisher
+import org.springframework.stereotype.Component
+
+@Component
+class PaymentCoreEventPublisher(
+    private val applicationEventPublisher: ApplicationEventPublisher,
+) : PaymentEventPublisher {
+    override fun publish(event: PaymentEvent.PaymentCreated) {
+        applicationEventPublisher.publishEvent(event)
+    }
+
+    override fun publish(event: PaymentEvent.PaymentHandled) {
+        applicationEventPublisher.publishEvent(event)
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt
new file mode 100644
index 000000000..ffd643c0f
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRelay.kt
@@ -0,0 +1,21 @@
+package com.loopers.infrastructure.payment
+
+import com.loopers.application.payment.TransactionInfo
+import com.loopers.domain.payment.PaymentRelay
+import org.slf4j.LoggerFactory
+import org.springframework.stereotype.Component
+import org.springframework.web.client.RestTemplate
+
+@Component
+class PaymentCoreRelay : PaymentRelay {
+    companion object {
+        private val logger = LoggerFactory.getLogger(PaymentCoreRelay::class.java)
+        private val restTemplate = RestTemplate()
+    }
+
+    override fun notify(callbackUrl: String, transactionInfo: TransactionInfo) {
+        runCatching {
+            restTemplate.postForEntity(callbackUrl, transactionInfo, Any::class.java)
+        }.onFailure { e -> logger.error("콜백 호출을 실패했습니다. {}", e.message, e) }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt
new file mode 100644
index 000000000..cf521c47d
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentCoreRepository.kt
@@ -0,0 +1,32 @@
+package com.loopers.infrastructure.payment
+
+import com.loopers.domain.payment.Payment
+import com.loopers.domain.payment.PaymentRepository
+import org.springframework.stereotype.Component
+import org.springframework.transaction.annotation.Transactional
+import kotlin.jvm.optionals.getOrNull
+
+@Component
+class PaymentCoreRepository(
+    private val paymentJpaRepository: PaymentJpaRepository,
+) : PaymentRepository {
+    @Transactional
+    override fun save(payment: Payment): Payment {
+        return paymentJpaRepository.save(payment)
+    }
+
+    @Transactional(readOnly = true)
+    override fun findByTransactionKey(transactionKey: String): Payment? {
+        return paymentJpaRepository.findById(transactionKey).getOrNull()
+    }
+
+    @Transactional(readOnly = true)
+    override fun findByTransactionKey(userId: String, transactionKey: String): Payment? {
+        return paymentJpaRepository.findByUserIdAndTransactionKey(userId, transactionKey)
+    }
+
+    override fun findByOrderId(userId: String, orderId: String): List<Payment> {
+        return paymentJpaRepository.findByUserIdAndOrderId(userId, orderId)
+            .sortedByDescending { it.updatedAt }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt
new file mode 100644
index 000000000..a5ea32822
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/infrastructure/payment/PaymentJpaRepository.kt
@@ -0,0 +1,9 @@
+package com.loopers.infrastructure.payment
+
+import com.loopers.domain.payment.Payment
+import org.springframework.data.jpa.repository.JpaRepository
+
+interface PaymentJpaRepository : JpaRepository<Payment, String> {
+    fun findByUserIdAndTransactionKey(userId: String, transactionKey: String): Payment?
+    fun findByUserIdAndOrderId(userId: String, orderId: String): List<Payment>
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
new file mode 100644
index 000000000..434a229e2
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiControllerAdvice.kt
@@ -0,0 +1,119 @@
+package com.loopers.interfaces.api
+
+import com.fasterxml.jackson.databind.JsonMappingException
+import com.fasterxml.jackson.databind.exc.InvalidFormatException
+import com.fasterxml.jackson.databind.exc.MismatchedInputException
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+import org.slf4j.LoggerFactory
+import org.springframework.http.ResponseEntity
+import org.springframework.http.converter.HttpMessageNotReadableException
+import org.springframework.web.bind.MissingServletRequestParameterException
+import org.springframework.web.bind.annotation.ExceptionHandler
+import org.springframework.web.bind.annotation.RestControllerAdvice
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException
+import org.springframework.web.server.ServerWebInputException
+import org.springframework.web.servlet.resource.NoResourceFoundException
+import kotlin.collections.joinToString
+import kotlin.jvm.java
+import kotlin.text.isNotEmpty
+import kotlin.text.toRegex
+
+@RestControllerAdvice
+class ApiControllerAdvice {
+    private val log = LoggerFactory.getLogger(ApiControllerAdvice::class.java)
+
+    @ExceptionHandler
+    fun handle(e: CoreException): ResponseEntity<ApiResponse<*>> {
+        log.warn("CoreException : {}", e.customMessage ?: e.message, e)
+        return failureResponse(errorType = e.errorType, errorMessage = e.customMessage)
+    }
+
+    @ExceptionHandler
+    fun handleBadRequest(e: MethodArgumentTypeMismatchException): ResponseEntity<ApiResponse<*>> {
+        val name = e.name
+        val type = e.requiredType?.simpleName ?: "unknown"
+        val value = e.value ?: "null"
+        val message = "요청 파라미터 '$name' (타입: $type)의 값 '$value'이(가) 잘못되었습니다."
+        return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message)
+    }
+
+    @ExceptionHandler
+    fun handleBadRequest(e: MissingServletRequestParameterException): ResponseEntity<ApiResponse<*>> {
+        val name = e.parameterName
+        val type = e.parameterType
+        val message = "필수 요청 파라미터 '$name' (타입: $type)가 누락되었습니다."
+        return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = message)
+    }
+
+    @ExceptionHandler
+    fun handleBadRequest(e: HttpMessageNotReadableException): ResponseEntity<ApiResponse<*>> {
+        val errorMessage = when (val rootCause = e.rootCause) {
+            is InvalidFormatException -> {
+                val fieldName = rootCause.path.joinToString(".") { it.fieldName ?: "?" }
+
+                val valueIndicationMessage = when {
+                    rootCause.targetType.isEnum -> {
+                        val enumClass = rootCause.targetType
+                        val enumValues = enumClass.enumConstants.joinToString(", ") { it.toString() }
+                        "사용 가능한 값 : [$enumValues]"
+                    }
+
+                    else -> ""
+                }
+
+                val expectedType = rootCause.targetType.simpleName
+                val value = rootCause.value
+
+                "필드 '$fieldName'의 값 '$value'이(가) 예상 타입($expectedType)과 일치하지 않습니다. $valueIndicationMessage"
+            }
+
+            is MismatchedInputException -> {
+                val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" }
+                "필수 필드 '$fieldPath'이(가) 누락되었습니다."
+            }
+
+            is JsonMappingException -> {
+                val fieldPath = rootCause.path.joinToString(".") { it.fieldName ?: "?" }
+                "필드 '$fieldPath'에서 JSON 매핑 오류가 발생했습니다: ${rootCause.originalMessage}"
+            }
+
+            else -> "요청 본문을 처리하는 중 오류가 발생했습니다. JSON 메세지 규격을 확인해주세요."
+        }
+
+        return failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = errorMessage)
+    }
+
+    @ExceptionHandler
+    fun handleBadRequest(e: ServerWebInputException): ResponseEntity<ApiResponse<*>> {
+        fun extractMissingParameter(message: String): String {
+            val regex = "'(.+?)'".toRegex()
+            return regex.find(message)?.groupValues?.get(1) ?: ""
+        }
+
+        val missingParams = extractMissingParameter(e.reason ?: "")
+        return if (missingParams.isNotEmpty()) {
+            failureResponse(errorType = ErrorType.BAD_REQUEST, errorMessage = "필수 요청 값 \'$missingParams\'가 누락되었습니다.")
+        } else {
+            failureResponse(errorType = ErrorType.BAD_REQUEST)
+        }
+    }
+
+    @ExceptionHandler
+    fun handleNotFound(e: NoResourceFoundException): ResponseEntity<ApiResponse<*>> {
+        return failureResponse(errorType = ErrorType.NOT_FOUND)
+    }
+
+    @ExceptionHandler
+    fun handle(e: Throwable): ResponseEntity<ApiResponse<*>> {
+        log.error("Exception : {}", e.message, e)
+        val errorType = ErrorType.INTERNAL_ERROR
+        return failureResponse(errorType = errorType)
+    }
+
+    private fun failureResponse(errorType: ErrorType, errorMessage: String? = null): ResponseEntity<ApiResponse<*>> =
+        ResponseEntity(
+            ApiResponse.fail(errorCode = errorType.code, errorMessage = errorMessage ?: errorType.message),
+            errorType.status,
+        )
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt
new file mode 100644
index 000000000..f5c38ab5e
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/ApiResponse.kt
@@ -0,0 +1,32 @@
+package com.loopers.interfaces.api
+
+data class ApiResponse<T>(
+    val meta: Metadata,
+    val data: T?,
+) {
+    data class Metadata(
+        val result: Result,
+        val errorCode: String?,
+        val message: String?,
+    ) {
+        enum class Result { SUCCESS, FAIL }
+
+        companion object {
+            fun success() = Metadata(Result.SUCCESS, null, null)
+
+            fun fail(errorCode: String, errorMessage: String) = Metadata(Result.FAIL, errorCode, errorMessage)
+        }
+    }
+
+    companion object {
+        fun success(): ApiResponse<Any> = ApiResponse(Metadata.success(), null)
+
+        fun <T> success(data: T? = null) = ApiResponse(Metadata.success(), data)
+
+        fun fail(errorCode: String, errorMessage: String): ApiResponse<Any?> =
+            ApiResponse(
+                meta = Metadata.fail(errorCode = errorCode, errorMessage = errorMessage),
+                data = null,
+            )
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
new file mode 100644
index 000000000..9ef6c25da
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/argumentresolver/UserInfoArgumentResolver.kt
@@ -0,0 +1,32 @@
+package com.loopers.interfaces.api.argumentresolver
+
+import com.loopers.domain.user.UserInfo
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+import org.springframework.core.MethodParameter
+import org.springframework.web.bind.support.WebDataBinderFactory
+import org.springframework.web.context.request.NativeWebRequest
+import org.springframework.web.method.support.HandlerMethodArgumentResolver
+import org.springframework.web.method.support.ModelAndViewContainer
+
+class UserInfoArgumentResolver: HandlerMethodArgumentResolver {
+    companion object {
+        private const val KEY_USER_ID = "X-USER-ID"
+    }
+
+    override fun supportsParameter(parameter: MethodParameter): Boolean {
+        return UserInfo::class.java.isAssignableFrom(parameter.parameterType)
+    }
+
+    override fun resolveArgument(
+        parameter: MethodParameter,
+        mavContainer: ModelAndViewContainer?,
+        webRequest: NativeWebRequest,
+        binderFactory: WebDataBinderFactory?,
+    ): UserInfo {
+        val userId = webRequest.getHeader(KEY_USER_ID)
+            ?: throw CoreException(ErrorType.BAD_REQUEST, "유저 ID 헤더는 필수입니다.")
+
+        return UserInfo(userId)
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
new file mode 100644
index 000000000..22d5cbe38
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentApi.kt
@@ -0,0 +1,60 @@
+package com.loopers.interfaces.api.payment
+
+import com.loopers.application.payment.PaymentApplicationService
+import com.loopers.interfaces.api.ApiResponse
+import com.loopers.domain.user.UserInfo
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+import org.springframework.web.bind.annotation.GetMapping
+import org.springframework.web.bind.annotation.PathVariable
+import org.springframework.web.bind.annotation.PostMapping
+import org.springframework.web.bind.annotation.RequestBody
+import org.springframework.web.bind.annotation.RequestMapping
+import org.springframework.web.bind.annotation.RequestParam
+import org.springframework.web.bind.annotation.RestController
+
+@RestController
+@RequestMapping("/api/v1/payments")
+class PaymentApi(
+    private val paymentApplicationService: PaymentApplicationService,
+) {
+    @PostMapping
+    fun request(
+        userInfo: UserInfo,
+        @RequestBody request: PaymentDto.PaymentRequest,
+    ): ApiResponse<PaymentDto.TransactionResponse> {
+        request.validate()
+
+        // 100ms ~ 500ms 지연
+        Thread.sleep((100..500L).random())
+
+        // 40% 확률로 요청 실패
+        if ((1..100).random() <= 40) {
+            throw CoreException(ErrorType.INTERNAL_ERROR, "현재 서버가 불안정합니다. 잠시 후 다시 시도해주세요.")
+        }
+
+        return paymentApplicationService.createTransaction(request.toCommand(userInfo.userId))
+            .let { PaymentDto.TransactionResponse.from(it) }
+            .let { ApiResponse.success(it) }
+    }
+
+    @GetMapping("/{transactionKey}")
+    fun getTransaction(
+        userInfo: UserInfo,
+        @PathVariable("transactionKey") transactionKey: String,
+    ): ApiResponse<PaymentDto.TransactionDetailResponse> {
+        return paymentApplicationService.getTransactionDetailInfo(userInfo, transactionKey)
+            .let { PaymentDto.TransactionDetailResponse.from(it) }
+            .let { ApiResponse.success(it) }
+    }
+
+    @GetMapping
+    fun getTransactionsByOrder(
+        userInfo: UserInfo,
+        @RequestParam("orderId", required = false) orderId: String,
+    ): ApiResponse<PaymentDto.OrderResponse> {
+        return paymentApplicationService.findTransactionsByOrderId(userInfo, orderId)
+            .let { PaymentDto.OrderResponse.from(it) }
+            .let { ApiResponse.success(it) }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
new file mode 100644
index 000000000..52a00b156
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
@@ -0,0 +1,136 @@
+package com.loopers.interfaces.api.payment
+
+import com.loopers.application.payment.OrderInfo
+import com.loopers.application.payment.PaymentCommand
+import com.loopers.application.payment.TransactionInfo
+import com.loopers.domain.payment.CardType
+import com.loopers.domain.payment.TransactionStatus
+import com.loopers.support.error.CoreException
+import com.loopers.support.error.ErrorType
+
+object PaymentDto {
+    data class PaymentRequest(
+        val orderId: String,
+        val cardType: CardTypeDto,
+        val cardNo: String,
+        val amount: Long,
+        val callbackUrl: String,
+    ) {
+        companion object {
+            private val REGEX_CARD_NO = Regex("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$")
+            private const val PREFIX_CALLBACK_URL = "http://localhost:8080"
+        }
+
+        fun validate() {
+            if (orderId.isBlank() || orderId.length < 6) {
+                throw CoreException(ErrorType.BAD_REQUEST, "주문 ID는 6자리 이상 문자열이어야 합니다.")
+            }
+            if (!REGEX_CARD_NO.matches(cardNo)) {
+                throw CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.")
+            }
+            if (amount <= 0) {
+                throw CoreException(ErrorType.BAD_REQUEST, "결제금액은 양의 정수여야 합니다.")
+            }
+            if (!callbackUrl.startsWith(PREFIX_CALLBACK_URL)) {
+                throw CoreException(ErrorType.BAD_REQUEST, "콜백 URL 은 $PREFIX_CALLBACK_URL 로 시작해야 합니다.")
+            }
+        }
+
+        fun toCommand(userId: String): PaymentCommand.CreateTransaction =
+            PaymentCommand.CreateTransaction(
+                userId = userId,
+                orderId = orderId,
+                cardType = cardType.toCardType(),
+                cardNo = cardNo,
+                amount = amount,
+                callbackUrl = callbackUrl,
+            )
+    }
+
+    data class TransactionDetailResponse(
+        val transactionKey: String,
+        val orderId: String,
+        val cardType: CardTypeDto,
+        val cardNo: String,
+        val amount: Long,
+        val status: TransactionStatusResponse,
+        val reason: String?,
+    ) {
+        companion object {
+            fun from(transactionInfo: TransactionInfo): TransactionDetailResponse =
+                TransactionDetailResponse(
+                    transactionKey = transactionInfo.transactionKey,
+                    orderId = transactionInfo.orderId,
+                    cardType = CardTypeDto.from(transactionInfo.cardType),
+                    cardNo = transactionInfo.cardNo,
+                    amount = transactionInfo.amount,
+                    status = TransactionStatusResponse.from(transactionInfo.status),
+                    reason = transactionInfo.reason,
+                )
+        }
+    }
+
+    data class TransactionResponse(
+        val transactionKey: String,
+        val status: TransactionStatusResponse,
+        val reason: String?,
+    ) {
+        companion object {
+            fun from(transactionInfo: TransactionInfo): TransactionResponse =
+                TransactionResponse(
+                    transactionKey = transactionInfo.transactionKey,
+                    status = TransactionStatusResponse.from(transactionInfo.status),
+                    reason = transactionInfo.reason,
+                )
+        }
+    }
+
+    data class OrderResponse(
+        val orderId: String,
+        val transactions: List<TransactionResponse>,
+    ) {
+        companion object {
+            fun from(orderInfo: OrderInfo): OrderResponse =
+                OrderResponse(
+                    orderId = orderInfo.orderId,
+                    transactions = orderInfo.transactions.map { TransactionResponse.from(it) },
+                )
+        }
+    }
+
+    enum class CardTypeDto {
+        SAMSUNG,
+        KB,
+        HYUNDAI,
+        ;
+
+        fun toCardType(): CardType = when (this) {
+            SAMSUNG -> CardType.SAMSUNG
+            KB -> CardType.KB
+            HYUNDAI -> CardType.HYUNDAI
+        }
+
+        companion object {
+            fun from(cardType: CardType) = when (cardType) {
+                CardType.SAMSUNG -> SAMSUNG
+                CardType.KB -> KB
+                CardType.HYUNDAI -> HYUNDAI
+            }
+        }
+    }
+
+    enum class TransactionStatusResponse {
+        PENDING,
+        SUCCESS,
+        FAILED,
+        ;
+
+        companion object {
+            fun from(transactionStatus: TransactionStatus) = when (transactionStatus) {
+                TransactionStatus.PENDING -> PENDING
+                TransactionStatus.SUCCESS -> SUCCESS
+                TransactionStatus.FAILED -> FAILED
+            }
+        }
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt
new file mode 100644
index 000000000..241322890
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/event/payment/PaymentEventListener.kt
@@ -0,0 +1,28 @@
+package com.loopers.interfaces.event.payment
+
+import com.loopers.application.payment.PaymentApplicationService
+import com.loopers.domain.payment.PaymentEvent
+import org.springframework.scheduling.annotation.Async
+import org.springframework.stereotype.Component
+import org.springframework.transaction.event.TransactionPhase
+import org.springframework.transaction.event.TransactionalEventListener
+
+@Component
+class PaymentEventListener(
+    private val paymentApplicationService: PaymentApplicationService,
+) {
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    fun handle(event: PaymentEvent.PaymentCreated) {
+        val thresholdMillis = (1000L..5000L).random()
+        Thread.sleep(thresholdMillis)
+
+        paymentApplicationService.handle(event.transactionKey)
+    }
+
+    @Async
+    @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+    fun handle(event: PaymentEvent.PaymentHandled) {
+        paymentApplicationService.notifyTransactionResult(transactionKey = event.transactionKey)
+    }
+}
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt
new file mode 100644
index 000000000..120f7fc5f
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/CoreException.kt
@@ -0,0 +1,6 @@
+package com.loopers.support.error
+
+class CoreException(
+    val errorType: ErrorType,
+    val customMessage: String? = null,
+) : RuntimeException(customMessage ?: errorType.message)
diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt
new file mode 100644
index 000000000..e0799a5ea
--- /dev/null
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/support/error/ErrorType.kt
@@ -0,0 +1,11 @@
+package com.loopers.support.error
+
+import org.springframework.http.HttpStatus
+
+enum class ErrorType(val status: HttpStatus, val code: String, val message: String) {
+    /** 범용 에러 */
+    INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, HttpStatus.INTERNAL_SERVER_ERROR.reasonPhrase, "일시적인 오류가 발생했습니다."),
+    BAD_REQUEST(HttpStatus.BAD_REQUEST, HttpStatus.BAD_REQUEST.reasonPhrase, "잘못된 요청입니다."),
+    NOT_FOUND(HttpStatus.NOT_FOUND, HttpStatus.NOT_FOUND.reasonPhrase, "존재하지 않는 요청입니다."),
+    CONFLICT(HttpStatus.CONFLICT, HttpStatus.CONFLICT.reasonPhrase, "이미 존재하는 리소스입니다."),
+}
diff --git a/apps/pg-simulator/src/main/resources/application.yml b/apps/pg-simulator/src/main/resources/application.yml
new file mode 100644
index 000000000..addf0e29c
--- /dev/null
+++ b/apps/pg-simulator/src/main/resources/application.yml
@@ -0,0 +1,77 @@
+server:
+  shutdown: graceful
+  tomcat:
+    threads:
+      max: 200 # 최대 워커 스레드 수 (default : 200)
+      min-spare: 10 # 최소 유지 스레드 수 (default : 10)
+    connection-timeout: 1m # 연결 타임아웃 (ms) (default : 60000ms = 1m)
+    max-connections: 8192 # 최대 동시 연결 수 (default : 8192)
+    accept-count: 100 # 대기 큐 크기 (default : 100)
+    keep-alive-timeout: 60s # 60s
+  max-http-request-header-size: 8KB
+
+spring:
+  main:
+    web-application-type: servlet
+  application:
+    name: commerce-api
+  profiles:
+    active: local
+  config:
+    import:
+      - jpa.yml
+      - redis.yml
+      - logging.yml
+      - monitoring.yml
+
+datasource:
+  mysql-jpa:
+    main:
+      jdbc-url: jdbc:mysql://localhost:3306/paymentgateway
+
+springdoc:
+  use-fqn: true
+  swagger-ui:
+    path: /swagger-ui.html
+
+---
+spring:
+  config:
+    activate:
+      on-profile: local, test
+
+server:
+  port: 8082
+
+management:
+  server:
+    port: 8083
+
+---
+spring:
+  config:
+    activate:
+      on-profile: dev
+
+server:
+  port: 8082
+
+management:
+  server:
+    port: 8083
+
+---
+spring:
+  config:
+    activate:
+      on-profile: qa
+
+---
+spring:
+  config:
+    activate:
+      on-profile: prd
+
+springdoc:
+  api-docs:
+    enabled: false
\ No newline at end of file
diff --git a/settings.gradle.kts b/settings.gradle.kts
index c99fb6360..161a1ba24 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -2,6 +2,7 @@ rootProject.name = "loopers-java-spring-template"
 
 include(
     ":apps:commerce-api",
+    ":apps:pg-simulator",
     ":apps:commerce-streamer",
     ":modules:jpa",
     ":modules:redis",

From 6f0c8a116ddece5c7f6b4c94f8cdf36565a6378b Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Mon, 1 Dec 2025 20:28:27 +0900
Subject: [PATCH 02/25] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EA=B4=80?=
 =?UTF-8?q?=EB=A0=A8=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/domain/payment/Payment.java   | 137 ++++++++++++++++++
 .../loopers/domain/payment/PaymentStatus.java |  19 +++
 2 files changed, 156 insertions(+)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
new file mode 100644
index 000000000..dd1d48dd5
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -0,0 +1,137 @@
+package com.loopers.domain.payment;
+
+import com.loopers.domain.BaseEntity;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EnumType;
+import jakarta.persistence.Enumerated;
+import jakarta.persistence.Table;
+import java.time.LocalDateTime;
+import lombok.Builder;
+import lombok.Getter;
+
+@Entity
+@Table(name = "payments")
+@Getter
+public class Payment extends BaseEntity {
+
+    @Column(nullable = false, unique = true)
+    private Long orderId;
+
+    @Enumerated(EnumType.STRING)
+    @Column(nullable = false)
+    private PaymentStatus status;
+
+    @Column(nullable = false)
+    private Integer amount;
+
+    @Column(nullable = false)
+    private String cardType;
+
+    @Column(nullable = false)
+    private String cardNumber;
+
+    @Column
+    private LocalDateTime paidAt;
+
+    @Builder
+    private Payment(Long orderId, PaymentStatus status, Integer amount, String cardType,
+                    String cardNumber, LocalDateTime paidAt) {
+        validateCreatePayment(orderId, status, amount, cardType, cardNumber);
+        this.orderId = orderId;
+        this.status = status;
+        this.amount = amount;
+        this.cardType = cardType;
+        this.cardNumber = cardNumber;
+        this.paidAt = paidAt;
+    }
+
+    public Payment() {
+    }
+
+    public static Payment createPayment(Long orderId, Integer amount, String cardType,
+                                       String cardNumber) {
+        return Payment.builder()
+                .orderId(orderId)
+                .status(PaymentStatus.PENDING)
+                .amount(amount)
+                .cardType(cardType)
+                .cardNumber(cardNumber)
+                .paidAt(null)
+                .build();
+    }
+
+    public void complete() {
+        if (this.status != PaymentStatus.PENDING) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 완료 처리할 수 있습니다.");
+        }
+        this.status = PaymentStatus.COMPLETED;
+        this.paidAt = LocalDateTime.now();
+    }
+
+    public void fail() {
+        if (this.status != PaymentStatus.PENDING) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 실패 처리할 수 있습니다.");
+        }
+        this.status = PaymentStatus.FAILED;
+    }
+
+    public void cancel() {
+        if (this.status != PaymentStatus.COMPLETED) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 완료 상태인 경우에만 결제 취소할 수 있습니다.");
+        }
+        this.status = PaymentStatus.CANCELED;
+    }
+
+    public void refund() {
+        if (this.status != PaymentStatus.COMPLETED) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 완료 상태인 경우에만 환불할 수 있습니다.");
+        }
+        this.status = PaymentStatus.REFUNDED;
+    }
+
+    private static void validateCreatePayment(Long orderId, PaymentStatus status, Integer amount,
+                                              String cardType, String cardNumber) {
+        validateOrderId(orderId);
+        validateStatus(status);
+        validateAmount(amount);
+        validateCardType(cardType);
+        validateCardNumber(cardNumber);
+    }
+
+    private static void validateOrderId(Long orderId) {
+        if (orderId == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다.");
+        }
+    }
+
+    private static void validateStatus(PaymentStatus status) {
+        if (status == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 상태는 필수입니다.");
+        }
+    }
+
+    private static void validateAmount(Integer amount) {
+        if (amount == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다.");
+        }
+        if (amount <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 0보다 커야 합니다.");
+        }
+    }
+
+    private static void validateCardType(String cardType) {
+        if (cardType == null || cardType.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다.");
+        }
+    }
+
+    private static void validateCardNumber(String cardNumber) {
+        if (cardNumber == null || cardNumber.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
new file mode 100644
index 000000000..31db569dc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentStatus.java
@@ -0,0 +1,19 @@
+package com.loopers.domain.payment;
+
+import lombok.Getter;
+
+@Getter
+public enum PaymentStatus {
+    PENDING("결제 대기"),
+    COMPLETED("결제 완료"),
+    FAILED("결제 실패"),
+    CANCELED("결제 취소"),
+    REFUNDED("환불 완료");
+
+    private final String description;
+
+    PaymentStatus(String description) {
+        this.description = description;
+    }
+}
+

From ccc96ef5690c12a79c2701a5ab45e2537f7e5ffb Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 01:20:45 +0900
Subject: [PATCH 03/25] =?UTF-8?q?feat:=20resilience4j,=20feign=20=EC=84=A4?=
 =?UTF-8?q?=EC=A0=95=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/commerce-api/build.gradle.kts            | 10 ++++
 .../src/main/resources/application.yml        | 59 +++++++++++++++++++
 2 files changed, 69 insertions(+)

diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index b56950699..0e7b1e1dc 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -24,4 +24,14 @@ dependencies {
 
     // retry
     implementation("org.springframework.retry:spring-retry")
+
+    // resilience4j
+    implementation("io.github.resilience4j:resilience4j-spring-boot3")
+    implementation("io.github.resilience4j:resilience4j-circuitbreaker")
+    implementation("io.github.resilience4j:resilience4j-timelimiter")
+    implementation("io.github.resilience4j:resilience4j-retry")
+
+    // feign
+    implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
+    implementation("io.github.resilience4j:resilience4j-spring-cloud2")
 }
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 484c070d0..d0ec98c98 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -29,6 +29,65 @@ springdoc:
   swagger-ui:
     path: /swagger-ui.html
 
+pg-simulator:
+  url: http://localhost:8082
+
+resilience4j:
+  circuitbreaker:
+    configs:
+      default:
+        registerHealthIndicator: true
+        slidingWindowSize: 20
+        minimumNumberOfCalls: 10
+        permittedNumberOfCallsInHalfOpenState: 5
+        automaticTransitionFromOpenToHalfOpenEnabled: true
+        waitDurationInOpenState: 30s
+        failureRateThreshold: 60
+        eventConsumerBufferSize: 10
+    instances:
+      pgPayment:
+        baseConfig: default
+        waitDurationInOpenState: 30s
+        failureRateThreshold: 60
+        slidingWindowSize: 20
+        minimumNumberOfCalls: 10
+  timelimiter:
+    configs:
+      default:
+        timeoutDuration: 3s
+    instances:
+      pgPayment:
+        baseConfig: default
+        timeoutDuration: 3s
+  retry:
+    instances:
+      pgRetry:
+        max-attempts: 2
+        wait-duration: 1s
+        retry-exceptions:
+          # Feign 관련
+          - feign.RetryableException
+          - feign.FeignException
+
+          # 네트워크 연결 관련
+          - java.net.SocketTimeoutException
+          - java.net.ConnectException
+          - java.net.UnknownHostException
+          - java.net.NoRouteToHostException
+          - java.io.IOException
+
+          # Spring Web Client 관련
+          - org.springframework.web.client.ResourceAccessException
+          - org.springframework.web.client.HttpServerErrorException
+
+          # 타임아웃 관련
+          - java.util.concurrent.TimeoutException
+
+          # 재시도하지 않을 예외 (비즈니스 로직 에러)
+        ignore-exceptions:
+            - com.loopers.support.error.CoreException
+        fail-after-max-attempts: true
+
 ---
 spring:
   config:

From 177f73f2a9ba2af92c68f6b76fcad890d6708a28 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 01:31:00 +0900
Subject: [PATCH 04/25] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=EC=9A=94?=
 =?UTF-8?q?=EC=B2=AD=20=EC=8B=9C=20=EC=A3=BC=EB=AC=B8=ED=82=A4=20=EC=83=9D?=
 =?UTF-8?q?=EC=84=B1,=20Payment=20=EC=A0=80=EC=9E=A5=20=EB=A1=9C=EC=A7=81?=
 =?UTF-8?q?=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderFacade.java        | 16 +++-
 .../java/com/loopers/domain/payment/Card.java | 46 ++++++++++
 .../com/loopers/domain/payment/Payment.java   | 92 ++++++-------------
 .../domain/payment/PaymentService.java        | 19 ++++
 .../payment/PaymentJpaRepository.java         |  8 ++
 .../payment/PaymentRepositoryImpl.java        | 19 ++++
 6 files changed, 135 insertions(+), 65 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/Card.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 32fc5b24a..e9d18f62c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -5,15 +5,18 @@
 import com.loopers.domain.order.Delivery;
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderService;
+import com.loopers.domain.payment.PaymentService;
 import com.loopers.domain.point.PointService;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
+import java.util.UUID;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import java.util.List;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.dao.OptimisticLockingFailureException;
 import org.springframework.retry.annotation.Backoff;
 import org.springframework.retry.annotation.Recover;
@@ -21,6 +24,7 @@
 import org.springframework.stereotype.Component;
 import org.springframework.transaction.annotation.Transactional;
 
+@Slf4j
 @RequiredArgsConstructor
 @Component
 public class OrderFacade {
@@ -28,8 +32,9 @@ public class OrderFacade {
     private final UserService userService;
     private final ProductService productService;
     private final OrderService orderService;
-    private final PointService pointService;
     private final CouponService couponService;
+    private final PointService pointService;
+    private final PaymentService paymentService;
 
     @Retryable(
             retryFor = OptimisticLockingFailureException.class,
@@ -65,10 +70,15 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
             couponService.usedCoupon(coupon);
         }
 
-        pointService.deductPoint(user.getId(), originalTotalPrice - discountPrice);
+        int finalAmount = originalTotalPrice - discountPrice;
+        pointService.deductPoint(user.getId(), finalAmount);
 
         orderService.saveOrder(order);
-        return OrderInfo.from(order, order.getOrderItems(), delivery);
+
+        String orderKey = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+        paymentService.createPayment(order.getId(), finalAmount, orderKey);
+
+        return OrderInfo.from(order, order.getOrderItems(), delivery, orderKey);
     }
 
     @Recover
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Card.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Card.java
new file mode 100644
index 000000000..9a1437166
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Card.java
@@ -0,0 +1,46 @@
+package com.loopers.domain.payment;
+
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import jakarta.persistence.Column;
+import jakarta.persistence.Embeddable;
+import lombok.AccessLevel;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+@Embeddable
+@Getter
+@NoArgsConstructor(access = AccessLevel.PROTECTED)
+public class Card {
+
+    @Column(name = "card_type")
+    private String cardType;
+
+    @Column(name = "card_number")
+    private String cardNumber;
+
+    public Card(String cardType, String cardNumber) {
+        validate(cardType, cardNumber);
+        this.cardType = cardType;
+        this.cardNumber = cardNumber;
+    }
+
+    public static Card createCard(String cardType, String cardNumber) {
+        return new Card(cardType, cardNumber);
+    }
+
+    private void validate(String cardType, String cardNumber) {
+        if (cardType == null || cardType.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다.");
+        }
+        if (cardNumber == null || cardNumber.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+        }
+        if (!cardNumber.matches("^\\d{4}-\\d{4}-\\d{4}-\\d{4}$")) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 xxxx-xxxx-xxxx-xxxx 형식이어야 합니다.");
+        }
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index dd1d48dd5..184711cfb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -3,7 +3,10 @@
 import com.loopers.domain.BaseEntity;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import jakarta.persistence.AttributeOverride;
+import jakarta.persistence.AttributeOverrides;
 import jakarta.persistence.Column;
+import jakarta.persistence.Embedded;
 import jakarta.persistence.Entity;
 import jakarta.persistence.EnumType;
 import jakarta.persistence.Enumerated;
@@ -20,6 +23,12 @@ public class Payment extends BaseEntity {
     @Column(nullable = false, unique = true)
     private Long orderId;
 
+    @Column(nullable = false, unique = true)
+    private String orderKey;
+
+    @Column(unique = true)
+    private String transactionKey;
+
     @Enumerated(EnumType.STRING)
     @Column(nullable = false)
     private PaymentStatus status;
@@ -27,78 +36,45 @@ public class Payment extends BaseEntity {
     @Column(nullable = false)
     private Integer amount;
 
-    @Column(nullable = false)
-    private String cardType;
-
-    @Column(nullable = false)
-    private String cardNumber;
+    @Embedded
+    @AttributeOverrides({
+            @AttributeOverride(name = "cardType", column = @Column(name = "card_type")),
+            @AttributeOverride(name = "cardNumber", column = @Column(name = "card_number"))
+    })
+    private Card card;
 
     @Column
     private LocalDateTime paidAt;
 
     @Builder
-    private Payment(Long orderId, PaymentStatus status, Integer amount, String cardType,
-                    String cardNumber, LocalDateTime paidAt) {
-        validateCreatePayment(orderId, status, amount, cardType, cardNumber);
+    private Payment(Long orderId, String orderKey, String transactionKey, PaymentStatus status, Integer amount,
+                    Card card, LocalDateTime paidAt) {
+        validateCreatePayment(orderId, amount, orderKey);
         this.orderId = orderId;
+        this.orderKey = orderKey;
+        this.transactionKey = transactionKey;
         this.status = status;
         this.amount = amount;
-        this.cardType = cardType;
-        this.cardNumber = cardNumber;
+        this.card = card;
         this.paidAt = paidAt;
     }
 
     public Payment() {
     }
 
-    public static Payment createPayment(Long orderId, Integer amount, String cardType,
-                                       String cardNumber) {
+    public static Payment createPayment(Long orderId, Integer amount, String orderKey) {
         return Payment.builder()
                 .orderId(orderId)
+                .orderKey(orderKey)
                 .status(PaymentStatus.PENDING)
                 .amount(amount)
-                .cardType(cardType)
-                .cardNumber(cardNumber)
-                .paidAt(null)
                 .build();
     }
 
-    public void complete() {
-        if (this.status != PaymentStatus.PENDING) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 완료 처리할 수 있습니다.");
-        }
-        this.status = PaymentStatus.COMPLETED;
-        this.paidAt = LocalDateTime.now();
-    }
-
-    public void fail() {
-        if (this.status != PaymentStatus.PENDING) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 실패 처리할 수 있습니다.");
-        }
-        this.status = PaymentStatus.FAILED;
-    }
-
-    public void cancel() {
-        if (this.status != PaymentStatus.COMPLETED) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 완료 상태인 경우에만 결제 취소할 수 있습니다.");
-        }
-        this.status = PaymentStatus.CANCELED;
-    }
-
-    public void refund() {
-        if (this.status != PaymentStatus.COMPLETED) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 완료 상태인 경우에만 환불할 수 있습니다.");
-        }
-        this.status = PaymentStatus.REFUNDED;
-    }
-
-    private static void validateCreatePayment(Long orderId, PaymentStatus status, Integer amount,
-                                              String cardType, String cardNumber) {
+    private static void validateCreatePayment(Long orderId, Integer amount, String orderKey) {
         validateOrderId(orderId);
-        validateStatus(status);
         validateAmount(amount);
-        validateCardType(cardType);
-        validateCardNumber(cardNumber);
+        validateOrderKey(orderKey);
     }
 
     private static void validateOrderId(Long orderId) {
@@ -107,12 +83,6 @@ private static void validateOrderId(Long orderId) {
         }
     }
 
-    private static void validateStatus(PaymentStatus status) {
-        if (status == null) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 상태는 필수입니다.");
-        }
-    }
-
     private static void validateAmount(Integer amount) {
         if (amount == null) {
             throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다.");
@@ -122,15 +92,13 @@ private static void validateAmount(Integer amount) {
         }
     }
 
-    private static void validateCardType(String cardType) {
-        if (cardType == null || cardType.isBlank()) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입은 필수입니다.");
+    private static void validateOrderKey(String orderKey) {
+        if (orderKey == null || orderKey.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 키는 필수입니다.");
         }
-    }
 
-    private static void validateCardNumber(String cardNumber) {
-        if (cardNumber == null || cardNumber.isBlank()) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+        if (orderKey.length() < 6) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 키는 최소 6자리 이상이어야 합니다.");
         }
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
new file mode 100644
index 000000000..d7eaa64fc
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -0,0 +1,19 @@
+package com.loopers.domain.payment;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+@Slf4j
+@RequiredArgsConstructor
+@Service
+public class PaymentService {
+
+    private final PaymentRepository paymentRepository;
+
+    public void createPayment(Long orderId, Integer amount, String orderKey) {
+        Payment payment = Payment.createPayment(orderId, amount, orderKey);
+        paymentRepository.savePayment(payment);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
new file mode 100644
index 000000000..19995d398
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
@@ -0,0 +1,8 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.Payment;
+import org.springframework.data.jpa.repository.JpaRepository;
+
+public interface PaymentJpaRepository extends JpaRepository<Payment, Long> {
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
new file mode 100644
index 000000000..382a235e3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -0,0 +1,19 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentRepository;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Repository;
+
+@RequiredArgsConstructor
+@Repository
+public class PaymentRepositoryImpl implements PaymentRepository {
+
+    private final PaymentJpaRepository paymentJpaRepository;
+
+    @Override
+    public void savePayment(Payment payment) {
+        paymentJpaRepository.save(payment);
+    }
+}
+

From 40dba2e6daf7a3bf8e1e751a9aa8e6de1b18aee5 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 01:31:35 +0900
Subject: [PATCH 05/25] =?UTF-8?q?feat:=20OrderInfo=EC=97=90=20orderKey=20?=
 =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20from=20?=
 =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=98=A4=EB=B2=84=EB=A1=9C?=
 =?UTF-8?q?=EB=94=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/order/OrderInfo.java  | 19 +++++++++++++++++--
 1 file changed, 17 insertions(+), 2 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
index 013d6ad61..15a1df924 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -9,8 +9,22 @@ public record OrderInfo(
         Long orderId,
         String orderStatus,
         List<OrderItemInfo> orderItems,
-        DeliveryInfo delivery
+        DeliveryInfo delivery,
+        String orderKey
 ) {
+    public static OrderInfo from(Order order, List<OrderItem> orderItems, Delivery delivery, String orderKey) {
+        List<OrderItemInfo> itemInfos = orderItems.stream()
+                .map(OrderItemInfo::from)
+                .toList();
+
+        return new OrderInfo(
+                order.getId(),
+                order.getOrderStatus().getDescription(),
+                itemInfos,
+                DeliveryInfo.from(delivery),
+                orderKey
+        );
+    }
 
     public static OrderInfo from(Order order, List<OrderItem> orderItems, Delivery delivery) {
         List<OrderItemInfo> itemInfos = orderItems.stream()
@@ -21,7 +35,8 @@ public static OrderInfo from(Order order, List<OrderItem> orderItems, Delivery d
                 order.getId(),
                 order.getOrderStatus().getDescription(),
                 itemInfos,
-                DeliveryInfo.from(delivery)
+                DeliveryInfo.from(delivery),
+                null  // orderKey는 조회 시 반환하지 않음
         );
     }
 

From 422e383dfd9cc8ea607732f5b261a696f099bddb Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 04:41:38 +0900
Subject: [PATCH 06/25] =?UTF-8?q?feat:=20=EC=A4=91=EB=B3=B5=EB=90=98?=
 =?UTF-8?q?=EB=8A=94=20resilience4j=20=EC=9D=98=EC=A1=B4=EC=84=B1=20?=
 =?UTF-8?q?=EC=A0=9C=EA=B1=B0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 apps/commerce-api/build.gradle.kts | 1 -
 1 file changed, 1 deletion(-)

diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 0e7b1e1dc..4be7306e4 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -33,5 +33,4 @@ dependencies {
 
     // feign
     implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
-    implementation("io.github.resilience4j:resilience4j-spring-cloud2")
 }

From b04e540b19ec3c764767e2ebb9e89ba3dc999c38 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 04:44:22 +0900
Subject: [PATCH 07/25] =?UTF-8?q?feat:=20=EC=A3=BC=EB=AC=B8=20=ED=82=A4=20?=
 =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EC=A3=BC?=
 =?UTF-8?q?=EB=AC=B8=20=EC=83=81=ED=83=9C=20=EA=B4=80=EB=A6=AC=20=EB=A9=94?=
 =?UTF-8?q?=EC=84=9C=EB=93=9C=20=EA=B5=AC=ED=98=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderFacade.java        |  8 ++--
 .../loopers/application/order/OrderInfo.java  | 14 ------
 .../java/com/loopers/domain/order/Order.java  | 44 +++++++++++++++++--
 .../loopers/domain/order/OrderRepository.java |  5 +++
 .../loopers/domain/order/OrderService.java    | 29 ++++++++++--
 .../order/OrderJpaRepository.java             |  2 +
 .../order/OrderRepositoryImpl.java            | 13 ++++++
 .../interfaces/api/order/OrderV1Dto.java      |  6 ++-
 8 files changed, 94 insertions(+), 27 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index e9d18f62c..8ddb20169 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -48,7 +48,8 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
 
         Delivery delivery = OrderCommand.DeliveryCommand.toDelivery(createOrderCommand.delivery());
-        Order order = orderService.createOrder(user.getId(), delivery);
+        String orderKey = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+        Order order = orderService.createOrder(user.getId(), orderKey, delivery);
 
         int originalTotalPrice = 0;
         for (OrderCommand.OrderItemCommand orderItemCommand : createOrderCommand.orderItems()) {
@@ -75,7 +76,6 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
 
         orderService.saveOrder(order);
 
-        String orderKey = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
         paymentService.createPayment(order.getId(), finalAmount, orderKey);
 
         return OrderInfo.from(order, order.getOrderItems(), delivery, orderKey);
@@ -94,7 +94,7 @@ public List<OrderInfo> getOrdersInfo(String loginId) {
         List<Order> orders = orderService.findOrdersByUserId(user.getId());
 
         return orders.stream()
-                .map(order -> OrderInfo.from(order, order.getOrderItems(), order.getDelivery()))
+                .map(order -> OrderInfo.from(order, order.getOrderItems(), order.getDelivery(), null))
                 .toList();
     }
 
@@ -106,6 +106,6 @@ public OrderInfo getOrderInfo(String loginId, Long orderId) {
         Order order = orderService.findOrderByIdAndUserId(orderId, user.getId())
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
 
-        return OrderInfo.from(order, order.getOrderItems(), order.getDelivery());
+        return OrderInfo.from(order, order.getOrderItems(), order.getDelivery(), null);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
index 15a1df924..6e1acc0e4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderInfo.java
@@ -26,20 +26,6 @@ public static OrderInfo from(Order order, List<OrderItem> orderItems, Delivery d
         );
     }
 
-    public static OrderInfo from(Order order, List<OrderItem> orderItems, Delivery delivery) {
-        List<OrderItemInfo> itemInfos = orderItems.stream()
-                .map(OrderItemInfo::from)
-                .toList();
-
-        return new OrderInfo(
-                order.getId(),
-                order.getOrderStatus().getDescription(),
-                itemInfos,
-                DeliveryInfo.from(delivery),
-                null  // orderKey는 조회 시 반환하지 않음
-        );
-    }
-
     public record OrderItemInfo(
             Long productId,
             String productName,
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index 22b3e64b6..699a0a98d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -28,6 +28,9 @@ public class Order extends BaseEntity {
 
     private Long userId;
 
+    @Column(nullable = false, unique = true)
+    private String orderKey;
+
     @OneToMany(cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
     @JoinColumn(name = "order_id")
     @Builder.Default
@@ -56,9 +59,10 @@ public class Order extends BaseEntity {
     public Order() {
     }
 
-    private Order(Long userId, List<OrderItem> orderItems, Integer originalTotalPrice,
+    private Order(Long userId, String orderKey, List<OrderItem> orderItems, Integer originalTotalPrice,
                   Long couponId, Integer discountPrice, OrderStatus orderStatus, Delivery delivery) {
         this.userId = userId;
+        this.orderKey = orderKey;
         this.orderItems = orderItems;
         this.originalTotalPrice = originalTotalPrice;
         this.couponId = couponId;
@@ -67,10 +71,11 @@ private Order(Long userId, List<OrderItem> orderItems, Integer originalTotalPric
         this.delivery = delivery;
     }
 
-    public static Order createOrder(Long userId, Delivery delivery) {
-        validateCreateOrder(userId, delivery);
+    public static Order createOrder(Long userId, String orderKey, Delivery delivery) {
+        validateCreateOrder(userId, orderKey, delivery);
         return Order.builder()
                 .userId(userId)
+                .orderKey(orderKey)
                 .originalTotalPrice(0)
                 .couponId(null)
                 .discountPrice(0)
@@ -104,8 +109,30 @@ public int addPrice(int price) {
         return this.originalTotalPrice;
     }
 
-    private static void validateCreateOrder(Long userId, Delivery delivery) {
+    public void pay() {
+        if (this.orderStatus != OrderStatus.CREATED) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 생성 상태인 경우에만 결제 완료 처리할 수 있습니다.");
+        }
+        this.orderStatus = OrderStatus.PAID;
+    }
+
+    public void cancel() {
+        if (this.orderStatus == OrderStatus.COMPLETED) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 완료 상태인 경우 취소할 수 없습니다.");
+        }
+        this.orderStatus = OrderStatus.CANCELED;
+    }
+
+    public void complete() {
+        if (this.orderStatus != OrderStatus.PAID) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 완료 상태인 경우에만 주문 완료 처리할 수 있습니다.");
+        }
+        this.orderStatus = OrderStatus.COMPLETED;
+    }
+
+    private static void validateCreateOrder(Long userId, String orderKey, Delivery delivery) {
         validateUserId(userId);
+        validateOrderKey(orderKey);
         validateDelivery(delivery);
     }
 
@@ -115,6 +142,15 @@ private static void validateUserId(Long userId) {
         }
     }
 
+    private static void validateOrderKey(String orderKey) {
+        if (orderKey == null || orderKey.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 키는 필수입니다.");
+        }
+        if (orderKey.length() < 6) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "주문 키는 최소 6자리 이상이어야 합니다.");
+        }
+    }
+
     private static void validateDelivery(Delivery delivery) {
         if (delivery == null) {
             throw new CoreException(ErrorType.BAD_REQUEST, "배송 정보는 필수입니다.");
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
index 40a157046..110a4af4c 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -9,5 +9,10 @@ public interface OrderRepository {
 
     Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId);
 
+    Optional<Order> findOrderById(Long orderId);
+
+    Optional<Order> findByOrderKey(String orderKey);
+
     void saveOrder(Order order);
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
index fa9e2ea2b..6b36c68ae 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -1,7 +1,6 @@
 package com.loopers.domain.order;
 
 import com.loopers.domain.coupon.Coupon;
-import com.loopers.domain.coupon.CouponRepository;
 import com.loopers.domain.product.Product;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
@@ -16,8 +15,8 @@ public class OrderService {
 
     private final OrderRepository orderRepository;
 
-    public Order createOrder(Long userId, Delivery delivery) {
-        return Order.createOrder(userId, delivery);
+    public Order createOrder(Long userId, String orderKey, Delivery delivery) {
+        return Order.createOrder(userId, orderKey, delivery);
     }
 
     public void createOrderItem(Order order, Product product, Integer quantity) {
@@ -56,4 +55,28 @@ public List<Order> findOrdersByUserId(Long userId) {
     public Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId) {
         return orderRepository.findOrderByIdAndUserId(orderId, userId);
     }
+
+    public Order getOrderById(Long orderId) {
+        return orderRepository.findOrderById(orderId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+    }
+
+    public void validateIsUserOrder(String orderKey, Long userId) {
+        Order order = orderRepository.findByOrderKey(orderKey)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
+
+        if (!order.getUserId().equals(userId)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "본인의 주문만 결제할 수 있습니다.");
+        }
+    }
+
+    public void payOrder(Order order) {
+        order.pay();
+        orderRepository.saveOrder(order);
+    }
+
+    public void cancelOrder(Order order) {
+        order.cancel();
+        orderRepository.saveOrder(order);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
index 5aa1eed2e..6a3cc64d8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderJpaRepository.java
@@ -9,4 +9,6 @@ public interface OrderJpaRepository extends JpaRepository<Order, Long> {
     List<Order> findByUserId(Long userId);
 
     Optional<Order> findByIdAndUserId(Long orderId, Long userId);
+
+    Optional<Order> findByOrderKey(String orderKey);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
index 53d221f85..eab10756f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -2,9 +2,12 @@
 
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import java.time.ZonedDateTime;
 import java.util.List;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Limit;
 import org.springframework.stereotype.Repository;
 
 @Repository
@@ -23,6 +26,16 @@ public Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId) {
         return orderJpaRepository.findByIdAndUserId(orderId, userId);
     }
 
+    @Override
+    public Optional<Order> findOrderById(Long orderId) {
+        return orderJpaRepository.findById(orderId);
+    }
+
+    @Override
+    public Optional<Order> findByOrderKey(String orderKey) {
+        return orderJpaRepository.findByOrderKey(orderKey);
+    }
+
     @Override
     public void saveOrder(Order order) {
         orderJpaRepository.save(order);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
index fe3efc3d8..38a0fa210 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
@@ -55,7 +55,8 @@ public record OrderResponse(
             Long orderId,
             String orderStatus,
             List<OrderItemResponse> orderItems,
-            DeliveryResponse delivery
+            DeliveryResponse delivery,
+            String orderKey
     ) {
         public static OrderResponse from(OrderInfo orderInfo) {
             return new OrderResponse(
@@ -64,7 +65,8 @@ public static OrderResponse from(OrderInfo orderInfo) {
                     orderInfo.orderItems().stream()
                             .map(OrderItemResponse::from)
                             .toList(),
-                    DeliveryResponse.from(orderInfo.delivery())
+                    DeliveryResponse.from(orderInfo.delivery()),
+                    orderInfo.orderKey()
             );
         }
     }

From d0f25c696eb78a14192862f9a9590ed4909602e4 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 04:46:34 +0900
Subject: [PATCH 08/25] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94?=
 =?UTF-8?q?=EC=B2=AD,=20=EC=BD=9C=EB=B0=B1=20=EA=B8=B0=EB=8A=A5=20?=
 =?UTF-8?q?=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../com/loopers/CommerceApiApplication.java   |   4 +
 .../application/payment/PaymentCommand.java   |  15 +++
 .../application/payment/PaymentFacade.java    | 100 ++++++++++++++++++
 .../application/payment/PaymentInfo.java      |  24 +++++
 .../com/loopers/domain/coupon/Coupon.java     |   7 ++
 .../loopers/domain/coupon/CouponService.java  |   6 ++
 .../com/loopers/domain/payment/Payment.java   |  30 +++++-
 .../loopers/domain/payment/PaymentClient.java |  27 +++++
 .../domain/payment/PaymentRepository.java     |  12 +++
 .../domain/payment/PaymentService.java        |  83 +++++++++++++++
 .../loopers/domain/point/PointService.java    |  10 ++
 .../com/loopers/domain/product/Product.java   |   4 +
 .../domain/product/ProductService.java        |   7 ++
 .../com/loopers/domain/product/Stock.java     |  12 +++
 .../com/loopers/domain/user/UserService.java  |   6 ++
 .../payment/PaymentClientConfig.java          |  38 +++++++
 .../payment/PaymentJpaRepository.java         |   4 +
 .../payment/PaymentRepositoryImpl.java        |  15 ++-
 .../payment/PgPaymentClient.java              |  78 ++++++++++++++
 .../infrastructure/payment/PgPaymentDto.java  |  36 +++++++
 .../payment/PgPaymentFeignClient.java         |  22 ++++
 .../api/payment/PaymentCallbackDto.java       |  20 ++++
 .../api/payment/PaymentV1Controller.java      |  53 ++++++++++
 .../interfaces/api/payment/PaymentV1Dto.java  |  48 +++++++++
 .../src/main/resources/application.yml        |   4 -
 25 files changed, 656 insertions(+), 9 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
index 4b1376648..43e2d3ec8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
+++ b/apps/commerce-api/src/main/java/com/loopers/CommerceApiApplication.java
@@ -4,12 +4,16 @@
 import org.springframework.boot.SpringApplication;
 import org.springframework.boot.autoconfigure.SpringBootApplication;
 import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
+import org.springframework.cloud.openfeign.EnableFeignClients;
+import org.springframework.scheduling.annotation.EnableScheduling;
 import java.util.TimeZone;
 import org.springframework.retry.annotation.EnableRetry;
 
 @ConfigurationPropertiesScan
 @SpringBootApplication
 @EnableRetry
+@EnableFeignClients
+@EnableScheduling
 public class CommerceApiApplication {
 
     @PostConstruct
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java
new file mode 100644
index 000000000..617408ce3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentCommand.java
@@ -0,0 +1,15 @@
+package com.loopers.application.payment;
+
+public class PaymentCommand {
+
+    public record RequestPaymentCommand(
+            String loginId,
+            String orderKey,
+            String cardType,
+            String cardNo
+    ) {
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
new file mode 100644
index 000000000..11e96511e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -0,0 +1,100 @@
+package com.loopers.application.payment;
+
+import com.loopers.domain.coupon.Coupon;
+import com.loopers.domain.coupon.CouponService;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderItem;
+import com.loopers.domain.order.OrderService;
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentService;
+import com.loopers.domain.payment.PaymentStatus;
+import com.loopers.domain.point.PointService;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductService;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserService;
+import com.loopers.interfaces.api.payment.PaymentCallbackDto.PaymentCallbackRequest;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class PaymentFacade {
+
+    private final PaymentService paymentService;
+    private final OrderService orderService;
+    private final UserService userService;
+    private final ProductService productService;
+    private final PointService pointService;
+    private final CouponService couponService;
+
+    @Transactional
+    public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command) {
+        Payment savedPayment = paymentService.getPaymentByOrderKey(command.orderKey());
+        paymentService.validatePaymentStatus(savedPayment);
+
+        User user = userService.getUserByLoginId(command.loginId());
+        orderService.validateIsUserOrder(command.orderKey(), user.getId());
+
+        Payment updatedPayment = paymentService.requestPaymentToPg(
+                savedPayment,
+                command.cardType(),
+                command.cardNo(),
+                command.loginId()
+        );
+
+        return PaymentInfo.from(updatedPayment);
+    }
+
+    @Transactional
+    public void handlePaymentCallback(PaymentCallbackRequest request) {
+        Payment payment = paymentService.getPendingPaymentByTransactionKey(request.transactionKey());
+        Order order = orderService.getOrderById(payment.getOrderId());
+
+        String status = request.status();
+        switch (status) {
+            case "SUCCESS" -> handleSuccess(payment, order);
+            case "FAILED" -> handleFailure(payment, order, request.reason());
+            default -> {
+                log.warn("알 수 없는 결제 상태: transactionKey={}, status={}", request.transactionKey(), status);
+            }
+        }
+    }
+
+    private void handleSuccess(Payment payment, Order order) {
+        orderService.payOrder(order);
+        paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.COMPLETED);
+
+        log.info("결제 완료 처리: orderId={}, transactionKey={}", order.getId(), payment.getTransactionKey());
+    }
+
+    private void handleFailure(Payment payment, Order order, String reason) {
+        orderService.cancelOrder(order);
+        paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.FAILED);
+
+        for (OrderItem orderItem : order.getOrderItems()) {
+            Product product = productService.findProductById(orderItem.getProductId())
+                    .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다: " + orderItem.getProductId()));
+            productService.restoreStock(product, orderItem.getQuantity());
+        }
+
+        int pointAmount = order.getOriginalTotalPrice() - (order.getDiscountPrice() != null ? order.getDiscountPrice() : 0);
+        pointService.restorePoint(order.getUserId(), pointAmount);
+
+        if (order.getCouponId() != null) {
+            Coupon coupon = couponService.getCouponByIdAndUserId(order.getCouponId(), order.getUserId());
+            couponService.restoreCoupon(coupon);
+        }
+
+        log.warn("결제 실패 처리: orderId={}, transactionKey={}, reason={}",
+                payment.getOrderId(), payment.getTransactionKey(), reason);
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
new file mode 100644
index 000000000..df73222c0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
@@ -0,0 +1,24 @@
+package com.loopers.application.payment;
+
+import com.loopers.domain.payment.Payment;
+
+public record PaymentInfo(
+        Long paymentId,
+        Long orderId,
+        String transactionKey,
+        String status,
+        Integer amount
+) {
+    public static PaymentInfo from(Payment payment) {
+        return new PaymentInfo(
+                payment.getId(),
+                payment.getOrderId(),
+                payment.getTransactionKey(),
+                payment.getStatus().name(),
+                payment.getAmount()
+        );
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
index e8dd86d7e..3c1601ad9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/Coupon.java
@@ -71,6 +71,13 @@ public void use() {
         this.used = true;
     }
 
+    public void restore() {
+        if (!used) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "사용되지 않은 쿠폰입니다.");
+        }
+        this.used = false;
+    }
+
     public boolean isUsed() {
         return used;
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
index 522507ae2..5940f6a1e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/coupon/CouponService.java
@@ -29,6 +29,12 @@ public void usedCoupon(Coupon coupon) {
         coupon.use();
     }
 
+    @Transactional
+    public void restoreCoupon(Coupon coupon) {
+        coupon.restore();
+        couponRepository.saveCoupon(coupon);
+    }
+
     @Transactional
     public void saveCoupon(Coupon coupon) {
         couponRepository.saveCoupon(coupon);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index 184711cfb..1a7fa0863 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -16,7 +16,7 @@
 import lombok.Getter;
 
 @Entity
-@Table(name = "payments")
+@Table(name = "payment")
 @Getter
 public class Payment extends BaseEntity {
 
@@ -38,8 +38,8 @@ public class Payment extends BaseEntity {
 
     @Embedded
     @AttributeOverrides({
-            @AttributeOverride(name = "cardType", column = @Column(name = "card_type")),
-            @AttributeOverride(name = "cardNumber", column = @Column(name = "card_number"))
+            @AttributeOverride(name = "cardType", column = @Column(name = "card_type", nullable = true)),
+            @AttributeOverride(name = "cardNumber", column = @Column(name = "card_number", nullable = true))
     })
     private Card card;
 
@@ -101,5 +101,29 @@ private static void validateOrderKey(String orderKey) {
             throw new CoreException(ErrorType.BAD_REQUEST, "주문 키는 최소 6자리 이상이어야 합니다.");
         }
     }
+
+    public void updateStatus(PaymentStatus status) {
+        if (status == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 상태는 필수입니다.");
+        }
+        this.status = status;
+        if (status == PaymentStatus.COMPLETED) {
+            this.paidAt = LocalDateTime.now();
+        }
+    }
+
+    public void updateTransactionKey(String transactionKey) {
+        if (transactionKey == null || transactionKey.isBlank()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "거래 키는 필수입니다.");
+        }
+        this.transactionKey = transactionKey;
+    }
+
+    public void updateCard(String cardType, String cardNumber) {
+        if (cardType == null || cardNumber == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입과 카드 번호는 필수입니다.");
+        }
+        this.card = new Card(cardType, cardNumber);
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
new file mode 100644
index 000000000..078b9eb25
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
@@ -0,0 +1,27 @@
+package com.loopers.domain.payment;
+
+import java.util.concurrent.CompletableFuture;
+
+public interface PaymentClient {
+
+    CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request, String userId);
+
+    record PaymentRequest(
+            String orderId,
+            String cardType,
+            String cardNo,
+            Long amount,
+            String callbackUrl
+    ) {
+    }
+
+    record PaymentResponse(
+            String transactionKey,
+            String status,
+            String reason
+    ) {
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
new file mode 100644
index 000000000..ee7fdc63e
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
@@ -0,0 +1,12 @@
+package com.loopers.domain.payment;
+
+import java.util.Optional;
+
+public interface PaymentRepository {
+    Payment savePayment(Payment payment);
+
+    Optional<Payment> findByOrderKey(String orderKey);
+
+    Optional<Payment> findByTransactionKey(String transactionKey);
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index d7eaa64fc..9a8f436e8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -1,8 +1,15 @@
 package com.loopers.domain.payment;
 
+import com.loopers.domain.payment.PaymentClient.PaymentRequest;
+import com.loopers.domain.payment.PaymentClient.PaymentResponse;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import java.util.concurrent.CompletableFuture;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
 
 @Slf4j
 @RequiredArgsConstructor
@@ -10,10 +17,86 @@
 public class PaymentService {
 
     private final PaymentRepository paymentRepository;
+    private final PaymentClient paymentClient;
+
+    @Value("${server.port:8080}")
+    private String serverPort;
 
     public void createPayment(Long orderId, Integer amount, String orderKey) {
         Payment payment = Payment.createPayment(orderId, amount, orderKey);
         paymentRepository.savePayment(payment);
     }
+
+    public Payment getPaymentByOrderKey(String orderKey) {
+        return paymentRepository.findByOrderKey(orderKey)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
+    }
+
+    public void validatePaymentStatus(Payment payment) {
+        if (payment.getStatus() != PaymentStatus.PENDING) {
+            if (payment.getStatus() == PaymentStatus.COMPLETED) {
+                throw new CoreException(ErrorType.CONFLICT, "이미 성공한 결제입니다.");
+            }
+            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 요청할 수 있습니다. 현재 상태: " + payment.getStatus());
+        }
+    }
+
+    @Transactional
+    public Payment requestPaymentToPg(Payment savedPayment, String cardType, String cardNo, String userId) {
+        String callbackUrl = String.format("http://localhost:%s/api/v1/payments/callback", serverPort);
+        PaymentRequest request = new PaymentRequest(
+                savedPayment.getOrderKey(),
+                cardType,
+                cardNo,
+                (long) savedPayment.getAmount(),
+                callbackUrl
+        );
+
+        try {
+            CompletableFuture<PaymentResponse> paymentFuture = paymentClient.requestPayment(request, userId);
+            PaymentResponse response = paymentFuture.get();
+            String transactionKey = response.transactionKey();
+
+            if (transactionKey != null && !transactionKey.isBlank()) {
+                savedPayment.updateTransactionKey(transactionKey);
+                savedPayment.updateStatus(PaymentStatus.COMPLETED);
+                savedPayment.updateCard(cardType, cardNo);
+                paymentRepository.savePayment(savedPayment);
+
+                log.info("PG 결제 요청 완료: orderKey={}, transactionKey={}", savedPayment.getOrderKey(), transactionKey);
+                return savedPayment;
+            } else {
+                log.error("PG 결제 요청 실패: transactionKey가 null입니다.");
+                throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 요청에 실패했습니다.");
+            }
+        } catch (Exception e) {
+            log.error("PG 결제 요청 실패: orderKey={}, error={}", savedPayment.getOrderKey(), e.getMessage(), e);
+            throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 요청에 실패했습니다.");
+        }
+    }
+
+    public Payment getPendingPaymentByTransactionKey(String transactionKey) {
+        Payment payment = paymentRepository.findByTransactionKey(transactionKey)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
+
+        if (payment.getStatus() == PaymentStatus.COMPLETED) {
+            throw new CoreException(ErrorType.CONFLICT, "이미 성공한 결제입니다.");
+        }
+
+        return payment;
+    }
+
+    @Transactional
+    public void updatePaymentStatus(String transactionKey, PaymentStatus status) {
+        Payment payment = paymentRepository.findByTransactionKey(transactionKey)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
+        payment.updateStatus(status);
+        paymentRepository.savePayment(payment);
+    }
+
+    @Transactional
+    public Payment savePayment(Payment payment) {
+        return paymentRepository.savePayment(payment);
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
index 01c34134f..d0c490cc4 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -33,6 +33,7 @@ public Optional<Point> findPointByUserId(Long userId) {
         return pointRepository.findPointByUserId(userId);
     }
 
+    @Transactional
     public void deductPoint(Long userId, int amount) {
         Point point = pointRepository.findPointByUserId(userId)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다."));
@@ -40,4 +41,13 @@ public void deductPoint(Long userId, int amount) {
         point.deduct(amount);
         pointRepository.savePoint(point);
     }
+
+    @Transactional
+    public void restorePoint(Long userId, int amount) {
+        Point point = pointRepository.findPointByUserId(userId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다."));
+
+        point.charge(amount);
+        pointRepository.savePoint(point);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
index 3dac66fe9..2132b4591 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Product.java
@@ -85,6 +85,10 @@ public void reduceStock(Integer quantity) {
         this.stock.reduceQuantity(quantity);
     }
 
+    public void restoreStock(Integer quantity) {
+        this.stock.restoreQuantity(quantity);
+    }
+
     public void increaseLikeCount() {
         this.likeCount.increase();
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
index 59e2fecf7..254b02725 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -19,11 +19,18 @@ public Optional<Product> findProductById(Long productId) {
         return productRepository.findProductById(productId);
     }
 
+    @Transactional
     public void reduceStock(Product product, Integer quantity) {
         product.reduceStock(quantity);
         productRepository.saveProduct(product);
     }
 
+    @Transactional
+    public void restoreStock(Product product, Integer quantity) {
+        product.restoreStock(quantity);
+        productRepository.saveProduct(product);
+    }
+
     public List<Product> findProductsByLatest(Long brandId, int page, int size) {
         return productRepository.findProductsByLatest(brandId, page, size);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java
index fb5bacad2..083495440 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/Stock.java
@@ -40,6 +40,18 @@ public void reduceQuantity(Integer quantity) {
         this.quantity -= quantity;
     }
 
+    public void restoreQuantity(Integer quantity) {
+        if (quantity == null || quantity <= 0) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "원복할 재고 수량은 1 이상이어야 합니다.");
+        }
+
+        if (this.quantity == null) {
+            this.quantity = 0;
+        }
+
+        this.quantity += quantity;
+    }
+
     private void validate(Integer quantity) {
         if (quantity == null) {
             throw new CoreException(ErrorType.BAD_REQUEST, "재고 수량은 필수입니다.");
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
index 7447f2a9b..a2668467f 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -33,4 +33,10 @@ public Optional<User> findUserByLoginId(String loginId) {
         return userRepository.findByLoginId(loginId);
     }
 
+    @Transactional(readOnly = true)
+    public User getUserByLoginId(String loginId) {
+        return userRepository.findByLoginId(loginId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
+    }
+
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
new file mode 100644
index 000000000..2fc1ad023
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
@@ -0,0 +1,38 @@
+package com.loopers.infrastructure.payment;
+
+import feign.Request;
+import feign.Retryer;
+import java.util.concurrent.TimeUnit;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+/**
+ * Feign Client 타임아웃 설정
+ * - connectTimeout: 연결 타임아웃 (ms)
+ * - readTimeout: 응답 읽기 타임아웃 (ms)
+ * 실무에서는 보통 2~5초 사이로 설정하며, 기능 특성과 요청 수에 따라 조절합니다.
+ */
+@Configuration
+public class PaymentClientConfig {
+
+    @Bean
+    public Request.Options feignOptions() {
+        // 연결 타임아웃: 1초, 응답 읽기 타임아웃: 2초, 리디렉션 허용
+        return new Request.Options(
+                1000, TimeUnit.MILLISECONDS,
+                2000, TimeUnit.MILLISECONDS,
+                true
+        );
+    }
+
+    @Bean
+    public Retryer retryer() {
+        // 재시도 설정: 100ms 간격으로 시작, 최대 1초까지 증가, 최대 3회 시도
+        return new Retryer.Default(
+                100,
+                1000,
+                2
+        );
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
index 19995d398..696626484 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
@@ -1,8 +1,12 @@
 package com.loopers.infrastructure.payment;
 
 import com.loopers.domain.payment.Payment;
+import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
 
 public interface PaymentJpaRepository extends JpaRepository<Payment, Long> {
+    Optional<Payment> findByOrderKey(String orderKey);
+
+    Optional<Payment> findByTransactionKey(String transactionKey);
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
index 382a235e3..aea091227 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -2,6 +2,7 @@
 
 import com.loopers.domain.payment.Payment;
 import com.loopers.domain.payment.PaymentRepository;
+import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
 
@@ -12,8 +13,18 @@ public class PaymentRepositoryImpl implements PaymentRepository {
     private final PaymentJpaRepository paymentJpaRepository;
 
     @Override
-    public void savePayment(Payment payment) {
-        paymentJpaRepository.save(payment);
+    public Payment savePayment(Payment payment) {
+        return paymentJpaRepository.save(payment);
+    }
+
+    @Override
+    public Optional<Payment> findByOrderKey(String orderKey) {
+        return paymentJpaRepository.findByOrderKey(orderKey);
+    }
+
+    @Override
+    public Optional<Payment> findByTransactionKey(String transactionKey) {
+        return paymentJpaRepository.findByTransactionKey(transactionKey);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
new file mode 100644
index 000000000..5c5345867
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
@@ -0,0 +1,78 @@
+package com.loopers.infrastructure.payment;
+
+import com.loopers.domain.payment.PaymentClient;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import io.github.resilience4j.retry.annotation.Retry;
+import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
+import java.util.concurrent.CompletableFuture;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PgPaymentClient implements PaymentClient {
+
+    private final PgPaymentFeignClient pgPaymentFeignClient;
+
+    @Override
+    @Retry(name = "pgRetry", fallbackMethod = "requestPaymentRetryFallback")
+    @CircuitBreaker(name = "pgPayment", fallbackMethod = "requestPaymentFallback")
+    @TimeLimiter(name = "pgPayment")
+    public CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request, String userId) {
+        try {
+            log.info("PG 결제 요청: orderKey={}, amount={}", request.orderId(), request.amount());
+
+            PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
+                    request.orderId(),
+                    request.cardType(),
+                    request.cardNo(),
+                    request.amount(),
+                    request.callbackUrl()
+            );
+
+            PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(userId, pgRequest);
+
+            if (response != null && response.data() != null) {
+                log.info("PG 결제 요청 성공: transactionKey={}, status={}",
+                        response.data().transactionKey(),
+                        response.data().status());
+
+                PaymentResponse paymentResponse = new PaymentResponse(
+                        response.data().transactionKey(),
+                        response.data().status(),
+                        response.data().reason()
+                );
+                return CompletableFuture.completedFuture(paymentResponse);
+            } else {
+                log.error("PG 결제 요청 실패: 응답이 null입니다.");
+                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 결제 요청이 실패했습니다.");
+            }
+        } catch (Exception e) {
+            log.error("PG 결제 요청 중 오류 발생: {}", e.getMessage(), e);
+            throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 결제 요청 중 오류가 발생했습니다: " + e.getMessage());
+        }
+    }
+
+    public CompletableFuture<PaymentResponse> requestPaymentRetryFallback(
+            PaymentRequest request, String userId, Throwable t) {
+        log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, error={}", request.orderId(), t.getMessage());
+        
+        PaymentResponse response = new PaymentResponse(
+                null,
+                "PENDING",
+                "결제 요청이 재시도 후에도 실패했습니다. 잠시 후 다시 시도해주세요."
+        );
+        return CompletableFuture.completedFuture(response);
+    }
+
+    public CompletableFuture<PaymentResponse> requestPaymentFallback(
+            PaymentRequest request, String userId, Exception e) {
+        log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, error={}", request.orderId(), e.getMessage());
+        throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템이 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.");
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java
new file mode 100644
index 000000000..18e8ab8c8
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java
@@ -0,0 +1,36 @@
+package com.loopers.infrastructure.payment;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class PgPaymentDto {
+
+    public record PgPaymentRequest(
+            @JsonProperty("orderId") String orderId,
+            @JsonProperty("cardType") String cardType,
+            @JsonProperty("cardNo") String cardNo,
+            @JsonProperty("amount") Long amount,
+            @JsonProperty("callbackUrl") String callbackUrl
+    ) {
+    }
+
+    public record PgPaymentResponse(
+            @JsonProperty("meta") Meta meta,
+            @JsonProperty("data") TransactionData data
+    ) {
+        public record Meta(
+                @JsonProperty("result") String result,
+                @JsonProperty("errorCode") String errorCode,
+                @JsonProperty("message") String message
+        ) {
+        }
+
+        public record TransactionData(
+                @JsonProperty("transactionKey") String transactionKey,
+                @JsonProperty("status") String status,
+                @JsonProperty("reason") String reason
+        ) {
+        }
+    }
+}
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java
new file mode 100644
index 000000000..26ba346b1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java
@@ -0,0 +1,22 @@
+package com.loopers.infrastructure.payment;
+
+import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+
+@FeignClient(
+        name = "pgPaymentClient",
+        url = "${pg-simulator.url:http://localhost:8081}",
+        path = "/api/v1/payments",
+        configuration = PaymentClientConfig.class
+)
+public interface PgPaymentFeignClient {
+
+    @PostMapping
+    PgPaymentDto.PgPaymentResponse requestPayment(
+            @RequestHeader("X-USER-ID") String userId,
+            @RequestBody PgPaymentDto.PgPaymentRequest request
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java
new file mode 100644
index 000000000..6cdc2d552
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java
@@ -0,0 +1,20 @@
+package com.loopers.interfaces.api.payment;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+public class PaymentCallbackDto {
+
+    public record PaymentCallbackRequest(
+            @JsonProperty("transactionKey") String transactionKey,
+            @JsonProperty("orderId") String orderId,
+            @JsonProperty("cardType") String cardType,
+            @JsonProperty("cardNo") String cardNo,
+            @JsonProperty("amount") Long amount,
+            @JsonProperty("status") String status,
+            @JsonProperty("reason") String reason
+    ) {
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
new file mode 100644
index 000000000..ef7eeefb1
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
@@ -0,0 +1,53 @@
+package com.loopers.interfaces.api.payment;
+
+import com.loopers.application.payment.PaymentCommand;
+import com.loopers.application.payment.PaymentFacade;
+import com.loopers.application.payment.PaymentInfo;
+import com.loopers.interfaces.api.ApiResponse;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestHeader;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@Slf4j
+@RequiredArgsConstructor
+@RestController
+@RequestMapping("/api/v1/payments")
+public class PaymentV1Controller {
+
+    private final PaymentFacade paymentFacade;
+
+    @PostMapping
+    public ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
+            @RequestHeader("X-USER-ID") String loginId,
+            @Valid @RequestBody PaymentV1Dto.PaymentRequest request
+    ) {
+        PaymentCommand.RequestPaymentCommand command = request.toCommand(loginId);
+        PaymentInfo paymentInfo = paymentFacade.requestPayment(command);
+        PaymentV1Dto.PaymentResponse response = PaymentV1Dto.PaymentResponse.from(paymentInfo);
+
+        return ApiResponse.success(response);
+    }
+
+    @PostMapping("/callback")
+    public ApiResponse<Object> handlePaymentCallback(
+            @RequestBody PaymentCallbackDto.PaymentCallbackRequest request
+    ) {
+        log.info("결제 콜백 수신: transactionKey={}, orderKey={}, status={}",
+                request.transactionKey(), request.orderId(), request.status());
+
+        try {
+            paymentFacade.handlePaymentCallback(request);
+            return ApiResponse.success();
+        } catch (Exception e) {
+            log.error("결제 콜백 처리 중 오류 발생: transactionKey={}, error={}",
+                    request.transactionKey(), e.getMessage(), e);
+            return ApiResponse.success();
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
new file mode 100644
index 000000000..e64724e9b
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
@@ -0,0 +1,48 @@
+package com.loopers.interfaces.api.payment;
+
+import com.loopers.application.payment.PaymentCommand;
+import com.loopers.application.payment.PaymentInfo;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+
+public class PaymentV1Dto {
+
+    public record PaymentRequest(
+            @NotNull(message = "주문키는 필수입니다.")
+            String orderKey,
+            @NotBlank(message = "카드 타입은 필수입니다.")
+            String cardType,
+            @NotBlank(message = "카드 번호는 필수입니다.")
+            String cardNumber
+    ) {
+        public PaymentCommand.RequestPaymentCommand toCommand(String loginId) {
+            return new PaymentCommand.RequestPaymentCommand(
+                    loginId,
+                    orderKey,
+                    cardType,
+                    cardNumber
+            );
+        }
+    }
+
+    public record PaymentResponse(
+            Long paymentId,
+            Long orderId,
+            String transactionKey,
+            String status,
+            Integer amount
+    ) {
+        public static PaymentResponse from(PaymentInfo paymentInfo) {
+            return new PaymentResponse(
+                    paymentInfo.paymentId(),
+                    paymentInfo.orderId(),
+                    paymentInfo.transactionKey(),
+                    paymentInfo.status(),
+                    paymentInfo.amount()
+            );
+        }
+    }
+}
+
+
+
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index d0ec98c98..0cb319cc2 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -76,10 +76,6 @@ resilience4j:
           - java.net.NoRouteToHostException
           - java.io.IOException
 
-          # Spring Web Client 관련
-          - org.springframework.web.client.ResourceAccessException
-          - org.springframework.web.client.HttpServerErrorException
-
           # 타임아웃 관련
           - java.util.concurrent.TimeoutException
 

From 9b67a90d41484bbf61a78b7ba179423d62edd429 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 04:46:52 +0900
Subject: [PATCH 09/25] =?UTF-8?q?feat:=20Kotlin=20=EB=AA=A8=EB=93=88=20?=
 =?UTF-8?q?=EB=AA=85=EC=8B=9C=EC=A0=81=20=EB=93=B1=EB=A1=9D=20=EB=B0=8F=20?=
 =?UTF-8?q?PaymentRequest=EC=97=90=20JSON=20=EC=86=8D=EC=84=B1=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt | 6 ++++++
 .../main/java/com/loopers/config/jackson/JacksonConfig.java | 2 ++
 2 files changed, 8 insertions(+)

diff --git a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
index 52a00b156..410256c2f 100644
--- a/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
+++ b/apps/pg-simulator/src/main/kotlin/com/loopers/interfaces/api/payment/PaymentDto.kt
@@ -1,5 +1,6 @@
 package com.loopers.interfaces.api.payment
 
+import com.fasterxml.jackson.annotation.JsonProperty
 import com.loopers.application.payment.OrderInfo
 import com.loopers.application.payment.PaymentCommand
 import com.loopers.application.payment.TransactionInfo
@@ -10,10 +11,15 @@ import com.loopers.support.error.ErrorType
 
 object PaymentDto {
     data class PaymentRequest(
+        @JsonProperty("orderId")
         val orderId: String,
+        @JsonProperty("cardType")
         val cardType: CardTypeDto,
+        @JsonProperty("cardNo")
         val cardNo: String,
+        @JsonProperty("amount")
         val amount: Long,
+        @JsonProperty("callbackUrl")
         val callbackUrl: String,
     ) {
         companion object {
diff --git a/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
index 4f8bda6f4..af147de68 100644
--- a/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
+++ b/supports/jackson/src/main/java/com/loopers/config/jackson/JacksonConfig.java
@@ -19,6 +19,8 @@ public Jackson2ObjectMapperBuilderCustomizer jacksonCustomizer() {
         return builder -> {
             // Classpath 내의 모든 Jackson 모듈 자동 등록
             builder.findModulesViaServiceLoader(true);
+            // Kotlin 모듈 명시적 등록
+            builder.modulesToInstall(new com.fasterxml.jackson.module.kotlin.KotlinModule.Builder().build());
 
             // Serialization Features
             builder.serializationInclusion(JsonInclude.Include.NON_NULL);

From a323364c9053079007806a59dcb47ef97ef7208b Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 19:41:23 +0900
Subject: [PATCH 10/25] =?UTF-8?q?feat:=20PaymentV1ApiSpec=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../api/payment/PaymentV1ApiSpec.java         | 34 +++++++++++++++++++
 .../api/payment/PaymentV1Controller.java      |  4 ++-
 2 files changed, 37 insertions(+), 1 deletion(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java
new file mode 100644
index 000000000..cb9195418
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java
@@ -0,0 +1,34 @@
+package com.loopers.interfaces.api.payment;
+
+import com.loopers.interfaces.api.ApiResponse;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.tags.Tag;
+
+@Tag(name = "Payment V1 API", description = "Payment V1 API 입니다.")
+public interface PaymentV1ApiSpec {
+
+    @Operation(
+            summary = "결제 요청",
+            description = "사용자가 결제 요청을 합니다."
+    )
+    ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
+            @Parameter(name = "X-USER-ID", description = "결제 요청을 하는 사용자의 로그인 ID", required = true)
+            String loginId,
+
+            @Schema(name = "결제 요청 정보", description = "결제할 정보")
+            PaymentV1Dto.PaymentRequest request
+    );
+
+    @Operation(
+            summary = "결제 콜백",
+            description = "PG 시스템으로부터 결제 결과 콜백을 수신합니다."
+    )
+    ApiResponse<Object> handlePaymentCallback(
+            @Schema(name = "결제 콜백 정보", description = "PG 시스템으로부터 받은 결제 결과 정보")
+            PaymentCallbackDto.PaymentCallbackRequest request
+    );
+
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
index ef7eeefb1..9b4679797 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
@@ -17,11 +17,12 @@
 @RequiredArgsConstructor
 @RestController
 @RequestMapping("/api/v1/payments")
-public class PaymentV1Controller {
+public class PaymentV1Controller implements PaymentV1ApiSpec {
 
     private final PaymentFacade paymentFacade;
 
     @PostMapping
+    @Override
     public ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
             @RequestHeader("X-USER-ID") String loginId,
             @Valid @RequestBody PaymentV1Dto.PaymentRequest request
@@ -34,6 +35,7 @@ public ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
     }
 
     @PostMapping("/callback")
+    @Override
     public ApiResponse<Object> handlePaymentCallback(
             @RequestBody PaymentCallbackDto.PaymentCallbackRequest request
     ) {

From 209f5028eab5bf9e5060a45ee8509bef97eb3b9f Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 19:46:19 +0900
Subject: [PATCH 11/25] =?UTF-8?q?refactor:=20PaymentCallbackRequest?=
 =?UTF-8?q?=EB=A5=BC=20PaymentV1Dto=EB=A1=9C=20=EC=9D=B4=EB=8F=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    |  2 +-
 .../api/payment/PaymentCallbackDto.java       | 20 -------------------
 .../api/payment/PaymentV1ApiSpec.java         |  2 +-
 .../api/payment/PaymentV1Controller.java      |  2 +-
 .../interfaces/api/payment/PaymentV1Dto.java  | 12 +++++++++++
 5 files changed, 15 insertions(+), 23 deletions(-)
 delete mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 11e96511e..3d8e227c3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -13,7 +13,7 @@
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
-import com.loopers.interfaces.api.payment.PaymentCallbackDto.PaymentCallbackRequest;
+import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentCallbackRequest;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java
deleted file mode 100644
index 6cdc2d552..000000000
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentCallbackDto.java
+++ /dev/null
@@ -1,20 +0,0 @@
-package com.loopers.interfaces.api.payment;
-
-import com.fasterxml.jackson.annotation.JsonProperty;
-
-public class PaymentCallbackDto {
-
-    public record PaymentCallbackRequest(
-            @JsonProperty("transactionKey") String transactionKey,
-            @JsonProperty("orderId") String orderId,
-            @JsonProperty("cardType") String cardType,
-            @JsonProperty("cardNo") String cardNo,
-            @JsonProperty("amount") Long amount,
-            @JsonProperty("status") String status,
-            @JsonProperty("reason") String reason
-    ) {
-    }
-}
-
-
-
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java
index cb9195418..8f3954120 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1ApiSpec.java
@@ -27,7 +27,7 @@ ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
     )
     ApiResponse<Object> handlePaymentCallback(
             @Schema(name = "결제 콜백 정보", description = "PG 시스템으로부터 받은 결제 결과 정보")
-            PaymentCallbackDto.PaymentCallbackRequest request
+            PaymentV1Dto.PaymentCallbackRequest request
     );
 
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
index 9b4679797..25ce8c302 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
@@ -37,7 +37,7 @@ public ApiResponse<PaymentV1Dto.PaymentResponse> requestPayment(
     @PostMapping("/callback")
     @Override
     public ApiResponse<Object> handlePaymentCallback(
-            @RequestBody PaymentCallbackDto.PaymentCallbackRequest request
+            @RequestBody PaymentV1Dto.PaymentCallbackRequest request
     ) {
         log.info("결제 콜백 수신: transactionKey={}, orderKey={}, status={}",
                 request.transactionKey(), request.orderId(), request.status());
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
index e64724e9b..b82225a79 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
@@ -1,5 +1,6 @@
 package com.loopers.interfaces.api.payment;
 
+import com.fasterxml.jackson.annotation.JsonProperty;
 import com.loopers.application.payment.PaymentCommand;
 import com.loopers.application.payment.PaymentInfo;
 import jakarta.validation.constraints.NotBlank;
@@ -42,6 +43,17 @@ public static PaymentResponse from(PaymentInfo paymentInfo) {
             );
         }
     }
+
+    public record PaymentCallbackRequest(
+            @JsonProperty("transactionKey") String transactionKey,
+            @JsonProperty("orderId") String orderId,
+            @JsonProperty("cardType") String cardType,
+            @JsonProperty("cardNo") String cardNo,
+            @JsonProperty("amount") Long amount,
+            @JsonProperty("status") String status,
+            @JsonProperty("reason") String reason
+    ) {
+    }
 }
 
 

From e99588e2fc7524fbb350cf2f68f75d3213c284fb Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Thu, 4 Dec 2025 20:27:47 +0900
Subject: [PATCH 12/25] =?UTF-8?q?feat:=20pg=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=9E=AC=EC=8B=9C=EB=8F=84=20=ED=9B=84=20?=
 =?UTF-8?q?fallback=20=EC=8B=9C=20=EC=A3=BC=EB=AC=B8,=20=ED=8F=AC=EC=9D=B8?=
 =?UTF-8?q?=ED=8A=B8,=20=EC=9E=AC=EA=B3=A0=20=EC=9B=90=EB=B3=B5=20?=
 =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    | 30 +++++++++++++------
 .../com/loopers/domain/payment/Payment.java   |  2 +-
 .../domain/payment/PaymentRepository.java     |  2 +-
 .../domain/payment/PaymentService.java        |  8 +++--
 .../payment/PaymentClientConfig.java          |  8 -----
 .../payment/PaymentRepositoryImpl.java        |  4 +--
 .../payment/PgPaymentClient.java              |  4 +--
 7 files changed, 33 insertions(+), 25 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 3d8e227c3..95f22a45a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -39,16 +39,23 @@ public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command)
         paymentService.validatePaymentStatus(savedPayment);
 
         User user = userService.getUserByLoginId(command.loginId());
+        Order order = orderService.getOrderById(savedPayment.getOrderId());
         orderService.validateIsUserOrder(command.orderKey(), user.getId());
 
-        Payment updatedPayment = paymentService.requestPaymentToPg(
-                savedPayment,
-                command.cardType(),
-                command.cardNo(),
-                command.loginId()
-        );
-
-        return PaymentInfo.from(updatedPayment);
+        try {
+            Payment updatedPayment = paymentService.requestPaymentToPg(
+                    savedPayment,
+                    command.cardType(),
+                    command.cardNo(),
+                    command.loginId()
+            );
+
+            return PaymentInfo.from(updatedPayment);
+        } catch (Exception e) {
+            log.error("PG 결제 요청 실패: orderKey={}, error={}", command.orderKey(), e.getMessage(), e);
+            handleFailure(savedPayment, order, "PG 결제 요청 실패: " + e.getMessage());
+            throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 요청에 실패했습니다.");
+        }
     }
 
     @Transactional
@@ -75,7 +82,12 @@ private void handleSuccess(Payment payment, Order order) {
 
     private void handleFailure(Payment payment, Order order, String reason) {
         orderService.cancelOrder(order);
-        paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.FAILED);
+        
+        if (payment.getTransactionKey() != null && !payment.getTransactionKey().isBlank()) {
+            paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.FAILED);
+        } else {
+            paymentService.updatePaymentStatusByOrderKey(payment.getOrderKey(), PaymentStatus.FAILED);
+        }
 
         for (OrderItem orderItem : order.getOrderItems()) {
             Product product = productService.findProductById(orderItem.getProductId())
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index 1a7fa0863..d2c1ff28b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -123,7 +123,7 @@ public void updateCard(String cardType, String cardNumber) {
         if (cardType == null || cardNumber == null) {
             throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입과 카드 번호는 필수입니다.");
         }
-        this.card = new Card(cardType, cardNumber);
+        this.card = Card.createCard(cardType, cardNumber);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
index ee7fdc63e..ef8d4dee6 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
@@ -3,7 +3,7 @@
 import java.util.Optional;
 
 public interface PaymentRepository {
-    Payment savePayment(Payment payment);
+    void savePayment(Payment payment);
 
     Optional<Payment> findByOrderKey(String orderKey);
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index 9a8f436e8..d39de18e0 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -91,12 +91,16 @@ public void updatePaymentStatus(String transactionKey, PaymentStatus status) {
         Payment payment = paymentRepository.findByTransactionKey(transactionKey)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
         payment.updateStatus(status);
+        payment.updateTransactionKey(transactionKey);
         paymentRepository.savePayment(payment);
     }
 
     @Transactional
-    public Payment savePayment(Payment payment) {
-        return paymentRepository.savePayment(payment);
+    public void updatePaymentStatusByOrderKey(String orderKey, PaymentStatus status) {
+        Payment payment = paymentRepository.findByOrderKey(orderKey)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
+        payment.updateStatus(status);
+        paymentRepository.savePayment(payment);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
index 2fc1ad023..b922393f1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
@@ -6,18 +6,11 @@
 import org.springframework.context.annotation.Bean;
 import org.springframework.context.annotation.Configuration;
 
-/**
- * Feign Client 타임아웃 설정
- * - connectTimeout: 연결 타임아웃 (ms)
- * - readTimeout: 응답 읽기 타임아웃 (ms)
- * 실무에서는 보통 2~5초 사이로 설정하며, 기능 특성과 요청 수에 따라 조절합니다.
- */
 @Configuration
 public class PaymentClientConfig {
 
     @Bean
     public Request.Options feignOptions() {
-        // 연결 타임아웃: 1초, 응답 읽기 타임아웃: 2초, 리디렉션 허용
         return new Request.Options(
                 1000, TimeUnit.MILLISECONDS,
                 2000, TimeUnit.MILLISECONDS,
@@ -27,7 +20,6 @@ public Request.Options feignOptions() {
 
     @Bean
     public Retryer retryer() {
-        // 재시도 설정: 100ms 간격으로 시작, 최대 1초까지 증가, 최대 3회 시도
         return new Retryer.Default(
                 100,
                 1000,
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
index aea091227..e7c315860 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -13,8 +13,8 @@ public class PaymentRepositoryImpl implements PaymentRepository {
     private final PaymentJpaRepository paymentJpaRepository;
 
     @Override
-    public Payment savePayment(Payment payment) {
-        return paymentJpaRepository.save(payment);
+    public void savePayment(Payment payment) {
+        paymentJpaRepository.save(payment);
     }
 
     @Override
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
index 5c5345867..1e3b96316 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
@@ -59,7 +59,7 @@ public CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request,
 
     public CompletableFuture<PaymentResponse> requestPaymentRetryFallback(
             PaymentRequest request, String userId, Throwable t) {
-        log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, error={}", request.orderId(), t.getMessage());
+        log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, userId={}, error={}", request.orderId(), userId, t.getMessage());
         
         PaymentResponse response = new PaymentResponse(
                 null,
@@ -71,7 +71,7 @@ public CompletableFuture<PaymentResponse> requestPaymentRetryFallback(
 
     public CompletableFuture<PaymentResponse> requestPaymentFallback(
             PaymentRequest request, String userId, Exception e) {
-        log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, error={}", request.orderId(), e.getMessage());
+        log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, userId={}, error={}", request.orderId(), userId, e.getMessage());
         throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템이 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.");
     }
 }

From bd5918d10c6825ffda79bcd9d940ff11f0095119 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Fri, 5 Dec 2025 00:07:34 +0900
Subject: [PATCH 13/25] =?UTF-8?q?feat:=20Payment=EC=97=90=20orderId=20?=
 =?UTF-8?q?=EC=A0=9C=EA=B1=B0,=20=EA=B4=80=EB=A0=A8=20=EB=A1=9C=EC=A7=81?=
 =?UTF-8?q?=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderFacade.java        |  3 +-
 .../application/payment/PaymentFacade.java    |  8 ++---
 .../application/payment/PaymentInfo.java      |  4 +--
 .../loopers/domain/order/OrderService.java    |  4 +--
 .../com/loopers/domain/payment/Payment.java   | 20 +++----------
 .../domain/payment/PaymentService.java        |  4 +--
 .../order/OrderRepositoryImpl.java            |  3 --
 .../interfaces/api/payment/PaymentV1Dto.java  |  4 +--
 .../order/OrderServiceIntegrationTest.java    | 30 ++++++++++++-------
 .../com/loopers/domain/order/OrderTest.java   | 24 ++++++++++-----
 10 files changed, 53 insertions(+), 51 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 8ddb20169..90b67e3d5 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -75,8 +75,7 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
         pointService.deductPoint(user.getId(), finalAmount);
 
         orderService.saveOrder(order);
-
-        paymentService.createPayment(order.getId(), finalAmount, orderKey);
+        paymentService.createPayment(finalAmount, orderKey);
 
         return OrderInfo.from(order, order.getOrderItems(), delivery, orderKey);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 95f22a45a..9c9ecfb84 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -39,7 +39,7 @@ public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command)
         paymentService.validatePaymentStatus(savedPayment);
 
         User user = userService.getUserByLoginId(command.loginId());
-        Order order = orderService.getOrderById(savedPayment.getOrderId());
+        Order order = orderService.getOrderByOrderKey(command.orderKey());
         orderService.validateIsUserOrder(command.orderKey(), user.getId());
 
         try {
@@ -61,7 +61,7 @@ public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command)
     @Transactional
     public void handlePaymentCallback(PaymentCallbackRequest request) {
         Payment payment = paymentService.getPendingPaymentByTransactionKey(request.transactionKey());
-        Order order = orderService.getOrderById(payment.getOrderId());
+        Order order = orderService.getOrderByOrderKey(payment.getOrderKey());
 
         String status = request.status();
         switch (status) {
@@ -103,8 +103,8 @@ private void handleFailure(Payment payment, Order order, String reason) {
             couponService.restoreCoupon(coupon);
         }
 
-        log.warn("결제 실패 처리: orderId={}, transactionKey={}, reason={}",
-                payment.getOrderId(), payment.getTransactionKey(), reason);
+        log.warn("결제 실패 처리: orderKey={}, transactionKey={}, reason={}",
+                payment.getOrderKey(), payment.getTransactionKey(), reason);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
index df73222c0..d50df110a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentInfo.java
@@ -4,7 +4,7 @@
 
 public record PaymentInfo(
         Long paymentId,
-        Long orderId,
+        String orderKey,
         String transactionKey,
         String status,
         Integer amount
@@ -12,7 +12,7 @@ public record PaymentInfo(
     public static PaymentInfo from(Payment payment) {
         return new PaymentInfo(
                 payment.getId(),
-                payment.getOrderId(),
+                payment.getOrderKey(),
                 payment.getTransactionKey(),
                 payment.getStatus().name(),
                 payment.getAmount()
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
index 6b36c68ae..ac3f83bd3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -56,8 +56,8 @@ public Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId) {
         return orderRepository.findOrderByIdAndUserId(orderId, userId);
     }
 
-    public Order getOrderById(Long orderId) {
-        return orderRepository.findOrderById(orderId)
+    public Order getOrderByOrderKey(String orderKey) {
+        return orderRepository.findByOrderKey(orderKey)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "주문을 찾을 수 없습니다."));
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index d2c1ff28b..027cdc899 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -20,9 +20,6 @@
 @Getter
 public class Payment extends BaseEntity {
 
-    @Column(nullable = false, unique = true)
-    private Long orderId;
-
     @Column(nullable = false, unique = true)
     private String orderKey;
 
@@ -47,10 +44,9 @@ public class Payment extends BaseEntity {
     private LocalDateTime paidAt;
 
     @Builder
-    private Payment(Long orderId, String orderKey, String transactionKey, PaymentStatus status, Integer amount,
+    private Payment(String orderKey, String transactionKey, PaymentStatus status, Integer amount,
                     Card card, LocalDateTime paidAt) {
-        validateCreatePayment(orderId, amount, orderKey);
-        this.orderId = orderId;
+        validateCreatePayment(amount, orderKey);
         this.orderKey = orderKey;
         this.transactionKey = transactionKey;
         this.status = status;
@@ -62,27 +58,19 @@ private Payment(Long orderId, String orderKey, String transactionKey, PaymentSta
     public Payment() {
     }
 
-    public static Payment createPayment(Long orderId, Integer amount, String orderKey) {
+    public static Payment createPayment(Integer amount, String orderKey) {
         return Payment.builder()
-                .orderId(orderId)
                 .orderKey(orderKey)
                 .status(PaymentStatus.PENDING)
                 .amount(amount)
                 .build();
     }
 
-    private static void validateCreatePayment(Long orderId, Integer amount, String orderKey) {
-        validateOrderId(orderId);
+    private static void validateCreatePayment(Integer amount, String orderKey) {
         validateAmount(amount);
         validateOrderKey(orderKey);
     }
 
-    private static void validateOrderId(Long orderId) {
-        if (orderId == null) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "주문 ID는 필수입니다.");
-        }
-    }
-
     private static void validateAmount(Integer amount) {
         if (amount == null) {
             throw new CoreException(ErrorType.BAD_REQUEST, "결제 금액은 필수입니다.");
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index d39de18e0..616dc8e7e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -22,8 +22,8 @@ public class PaymentService {
     @Value("${server.port:8080}")
     private String serverPort;
 
-    public void createPayment(Long orderId, Integer amount, String orderKey) {
-        Payment payment = Payment.createPayment(orderId, amount, orderKey);
+    public void createPayment(Integer amount, String orderKey) {
+        Payment payment = Payment.createPayment(amount, orderKey);
         paymentRepository.savePayment(payment);
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
index eab10756f..8aed690ef 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -2,12 +2,9 @@
 
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderRepository;
-import com.loopers.domain.order.OrderStatus;
-import java.time.ZonedDateTime;
 import java.util.List;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
-import org.springframework.data.domain.Limit;
 import org.springframework.stereotype.Repository;
 
 @Repository
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
index b82225a79..cf41822b7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Dto.java
@@ -28,7 +28,7 @@ public PaymentCommand.RequestPaymentCommand toCommand(String loginId) {
 
     public record PaymentResponse(
             Long paymentId,
-            Long orderId,
+            String orderKey,
             String transactionKey,
             String status,
             Integer amount
@@ -36,7 +36,7 @@ public record PaymentResponse(
         public static PaymentResponse from(PaymentInfo paymentInfo) {
             return new PaymentResponse(
                     paymentInfo.paymentId(),
-                    paymentInfo.orderId(),
+                    paymentInfo.orderKey(),
                     paymentInfo.transactionKey(),
                     paymentInfo.status(),
                     paymentInfo.amount()
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
index 278c44bd3..0628d077e 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
@@ -48,10 +48,11 @@ class CreateOrder {
         void createsOrder_whenUserIdAndDeliveryAreProvided() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
 
             // act
-            Order order = orderService.createOrder(userId, delivery);
+            Order order = orderService.createOrder(userId, orderKey, delivery);
 
             // assert
             assertAll(
@@ -72,8 +73,9 @@ class CreateOrderItem {
         @Test
         void createsOrderItem_whenOrderProductAndQuantityAreProvided() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
             Integer price = 10000;
             Product product = createProduct(1L, "상품명", price);
             Integer quantity = 2;
@@ -101,8 +103,9 @@ class AddTotalPrice {
         @Test
         void addsTotalPrice_whenPriceAndQuantityAreProvided() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
             int price = 10000;
             int quantity = 2;
 
@@ -117,8 +120,9 @@ void addsTotalPrice_whenPriceAndQuantityAreProvided() {
         @Test
         void accumulatesTotalPrice_whenPriceIsAddedMultipleTimes() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
 
             // act
             orderService.addTotalPrice(order, 10000, 2);
@@ -138,9 +142,11 @@ class FindOrdersByUserId {
         void returnsOrders_whenOrdersExist() {
             // arrange
             Long userId = 1L;
+            String orderKey1 = "order12345";
+            String orderKey2 = "order67890";
             Delivery delivery = createDelivery();
-            Order order1 = Order.createOrder(userId, delivery);
-            Order order2 = Order.createOrder(userId, delivery);
+            Order order1 = Order.createOrder(userId, orderKey1, delivery);
+            Order order2 = Order.createOrder(userId, orderKey2, delivery);
             List<Order> orders = List.of(order1, order2);
 
             doReturn(orders).when(orderRepository).findOrdersByUserId(userId);
@@ -182,8 +188,9 @@ void returnsOrder_whenOrderExists() {
             // arrange
             Long orderId = 1L;
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(userId, delivery);
+            Order order = Order.createOrder(userId, orderKey, delivery);
 
             doReturn(Optional.of(order)).when(orderRepository).findOrderByIdAndUserId(orderId, userId);
 
@@ -222,8 +229,9 @@ class ApplyCoupon {
         void appliesCoupon_whenCouponIsValid() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(userId, delivery);
+            Order order = Order.createOrder(userId, orderKey, delivery);
             order.addPrice(10000);
 
             Coupon coupon = Coupon.createCoupon(userId, "쿠폰", Discount.createFixed(3000));
@@ -244,8 +252,9 @@ void appliesCoupon_whenCouponIsValid() {
         void appliesFixedAmountCoupon() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(userId, delivery);
+            Order order = Order.createOrder(userId, orderKey, delivery);
             order.addPrice(10000);
 
             Coupon coupon = Coupon.createCoupon(userId, "쿠폰", Discount.createFixed(3000));
@@ -263,8 +272,9 @@ void appliesFixedAmountCoupon() {
         void appliesPercentCoupon() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = createDelivery();
-            Order order = Order.createOrder(userId, delivery);
+            Order order = Order.createOrder(userId, orderKey, delivery);
             order.addPrice(10000);
 
             Coupon coupon = Coupon.createCoupon(userId, "쿠폰", Discount.createPercent(10));
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
index b0025826d..9132aa66d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderTest.java
@@ -23,10 +23,11 @@ class CreateOrder {
         void createsOrder_whenAllFieldsAreValid() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
 
             // act
-            Order order = Order.createOrder(userId, delivery);
+            Order order = Order.createOrder(userId, orderKey, delivery);
 
             // assert
             assertAll(
@@ -42,11 +43,12 @@ void createsOrder_whenAllFieldsAreValid() {
         @Test
         void throwsException_whenUserIdIsNull() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
 
             // act & assert
             CoreException exception = assertThrows(CoreException.class, () -> {
-                Order.createOrder(null, delivery);
+                Order.createOrder(null, orderKey, delivery);
             });
 
             assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
@@ -58,10 +60,11 @@ void throwsException_whenUserIdIsNull() {
         void throwsException_whenDeliveryIsNull() {
             // arrange
             Long userId = 1L;
+            String orderKey = "order12345";
 
             // act & assert
             CoreException exception = assertThrows(CoreException.class, () -> {
-                Order.createOrder(userId, null);
+                Order.createOrder(userId, orderKey, null);
             });
 
             assertThat(exception.getErrorType()).isEqualTo(ErrorType.BAD_REQUEST);
@@ -77,8 +80,9 @@ class AddOrderItem {
         @Test
         void addsOrderItem_whenOrderItemIsValid() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
             OrderItem orderItem = OrderItem.createOrderItem(1L, "상품명", 10000, 2);
 
             // act
@@ -93,8 +97,9 @@ void addsOrderItem_whenOrderItemIsValid() {
         @Test
         void throwsException_whenOrderItemIsNull() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
 
             // act & assert
             CoreException exception = assertThrows(CoreException.class, () -> {
@@ -114,8 +119,9 @@ class AddPrice {
         @Test
         void addsPrice_whenPriceIsValid() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
             int price = 10000;
 
             // act
@@ -129,8 +135,9 @@ void addsPrice_whenPriceIsValid() {
         @Test
         void accumulatesPrice_whenPriceIsAddedMultipleTimes() {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
 
             // act
             order.addPrice(10000);
@@ -146,8 +153,9 @@ void accumulatesPrice_whenPriceIsAddedMultipleTimes() {
         @ValueSource(ints = {0, -1, -100})
         void throwsException_whenPriceIsZeroOrNegative(int invalidPrice) {
             // arrange
+            String orderKey = "order12345";
             Delivery delivery = Delivery.createDelivery("홍길동", "010-1234-5678", "서울시 강남구", "테헤란로 123");
-            Order order = Order.createOrder(1L, delivery);
+            Order order = Order.createOrder(1L, orderKey, delivery);
 
             // act & assert
             CoreException exception = assertThrows(CoreException.class, () -> {

From f68e54087c07b500ea53a3a79243d50df819f956 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Fri, 5 Dec 2025 02:32:00 +0900
Subject: [PATCH 14/25] =?UTF-8?q?test:=20PgPaymentClient=20Resilience4j=20?=
 =?UTF-8?q?=EB=8F=99=EC=9E=91=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../api/payment/PaymentV1Controller.java      |  11 +-
 .../PgPaymentClientResilienceTest.java        | 412 ++++++++++++++++++
 2 files changed, 414 insertions(+), 9 deletions(-)
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
index 25ce8c302..01c80e3b7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/payment/PaymentV1Controller.java
@@ -41,15 +41,8 @@ public ApiResponse<Object> handlePaymentCallback(
     ) {
         log.info("결제 콜백 수신: transactionKey={}, orderKey={}, status={}",
                 request.transactionKey(), request.orderId(), request.status());
-
-        try {
-            paymentFacade.handlePaymentCallback(request);
-            return ApiResponse.success();
-        } catch (Exception e) {
-            log.error("결제 콜백 처리 중 오류 발생: transactionKey={}, error={}",
-                    request.transactionKey(), e.getMessage(), e);
-            return ApiResponse.success();
-        }
+        paymentFacade.handlePaymentCallback(request);
+        return ApiResponse.success();
     }
 }
 
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
new file mode 100644
index 000000000..8d257321a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
@@ -0,0 +1,412 @@
+package com.loopers.infrastructure.payment;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+
+import com.loopers.domain.payment.PaymentClient.PaymentRequest;
+import com.loopers.domain.payment.PaymentClient.PaymentResponse;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.TimeUnit;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.bean.override.mockito.MockitoSpyBean;
+
+@SpringBootTest
+@DisplayName("PgPaymentClient Resilience4j 동작 테스트")
+class PgPaymentClientResilienceTest {
+
+    @Autowired
+    private PgPaymentClient pgPaymentClient;
+
+    @MockitoSpyBean
+    private PgPaymentFeignClient pgPaymentFeignClient;
+
+    @Autowired(required = false)
+    private CircuitBreakerRegistry circuitBreakerRegistry;
+
+    private PaymentRequest paymentRequest;
+    private String userId;
+
+    @BeforeEach
+    void setUp() {
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
+            circuitBreaker.transitionToClosedState();
+            circuitBreaker.reset();
+        }
+
+        paymentRequest = new PaymentRequest(
+                "order12345",
+                "SAMSUNG",
+                "1234-5678-9012-3456",
+                10000L,
+                "http://localhost:8080/api/v1/payments/callback"
+        );
+        userId = "user123";
+    }
+
+    @DisplayName("Circuit Breaker 동작 테스트")
+    @Nested
+    class CircuitBreakerTest {
+
+        @Test
+        @DisplayName("정상 응답 시 Circuit Breaker는 Close 상태를 유지한다")
+        void circuitBreakerStaysClosed_whenSuccess() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            doReturn(successResponse)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any(PgPaymentDto.PgPaymentRequest.class));
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isEqualTo("txn-key-123");
+            assertThat(response.status()).isEqualTo("PENDING");
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("설정: 슬라이딩 윈도우 20, 최소 호출 10, 실패율 60% - 10번 중 6번 실패하면 Open 상태")
+        void circuitBreakerOpens_whenFailureRateExceeds60Percent() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+
+            doReturn(successResponse)  // 1 - 성공
+                    .doReturn(successResponse)  // 2 - 성공
+                    .doReturn(successResponse)  // 3 - 성공
+                    .doReturn(successResponse)  // 4 - 성공
+                    .doThrow(runtimeException)        // 5 - 실패
+                    .doThrow(runtimeException)        // 6 - 실패
+                    .doThrow(runtimeException)        // 7 - 실패
+                    .doThrow(runtimeException)        // 8 - 실패
+                    .doThrow(runtimeException)        // 9 - 실패
+                    .doThrow(runtimeException)       // 10 - 실패
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            for (int i = 0; i < 10; i++) {
+                try {
+                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                } catch (Exception e) {
+                }
+            }
+
+            // then
+            verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
+
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("HalfOpen 상태에서 성공 시 Closed 상태로 전환된다")
+        void circuitBreakerTransitionsToClosed_whenHalfOpenSucceeds() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+
+            doReturn(successResponse)  // 1 - 성공
+                    .doReturn(successResponse)  // 2 - 성공
+                    .doReturn(successResponse)  // 3 - 성공
+                    .doReturn(successResponse)  // 4 - 성공
+                    .doThrow(runtimeException)        // 5 - 실패
+                    .doThrow(runtimeException)        // 6 - 실패
+                    .doThrow(runtimeException)        // 7 - 실패
+                    .doThrow(runtimeException)        // 8 - 실패
+                    .doThrow(runtimeException)        // 9 - 실패
+                    .doThrow(runtimeException)       // 10 - 실패
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            for (int i = 0; i < 10; i++) {
+                try {
+                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                } catch (Exception e) {
+                }
+            }
+
+            if (circuitBreakerRegistry != null) {
+                CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
+                circuitBreaker.transitionToHalfOpenState();
+            }
+
+            doReturn(successResponse)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isEqualTo("txn-key-123");
+            assertThat(response.status()).isEqualTo("PENDING");
+
+            verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("HalfOpen 상태에서 실패 시 Open 상태로 다시 전환된다")
+        void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+
+            doReturn(successResponse)  // 1 - 성공
+                    .doReturn(successResponse)  // 2 - 성공
+                    .doReturn(successResponse)  // 3 - 성공
+                    .doReturn(successResponse)  // 4 - 성공
+                    .doThrow(runtimeException)        // 5 - 실패
+                    .doThrow(runtimeException)        // 6 - 실패
+                    .doThrow(runtimeException)        // 7 - 실패
+                    .doThrow(runtimeException)        // 8 - 실패
+                    .doThrow(runtimeException)        // 9 - 실패
+                    .doThrow(runtimeException)       // 10 - 실패
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            for (int i = 0; i < 10; i++) {
+                try {
+                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                } catch (Exception e) {
+                }
+            }
+
+            if (circuitBreakerRegistry != null) {
+                CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgPayment");
+                circuitBreaker.transitionToHalfOpenState();
+            }
+
+            doThrow(runtimeException)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
+        }
+    }
+
+    @DisplayName("Retry 동작 테스트")
+    @Nested
+    class RetryTest {
+
+        @Test
+        @DisplayName("재시도 가능한 예외 발생 시 최대 2번 재시도한다 (총 3번 시도)")
+        void retriesUpTo2Times_whenRetryableException() throws Exception {
+            // given
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+
+            doThrow(runtimeException)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("모든 재시도 실패 시 Retry Fallback이 호출된다")
+        void callsRetryFallback_whenAllRetriesFail() throws Exception {
+            // given
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+            doThrow(runtimeException)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("재시도 불가능한 예외(CoreException) 발생 시 재시도하지 않지만 Fallback이 호출된다")
+        void doesNotRetry_whenNonRetryableException() throws Exception {
+            // given
+            doReturn(null)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+    }
+
+    @DisplayName("Time Limiter 동작 테스트")
+    @Nested
+    class TimeLimiterTest {
+
+        @Test
+        @DisplayName("3초를 초과하는 응답은 타임아웃된다")
+        void timesOut_whenResponseExceeds3Seconds() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            doAnswer(invocation -> {
+                        Thread.sleep(4000);
+                        return successResponse;
+                    })
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            
+            // then
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            assertThat(response.transactionKey()).isEqualTo("txn-key-123");
+            assertThat(response.status()).isEqualTo("PENDING");
+        }
+    }
+
+    @DisplayName("외부 시스템(PG) 응답별 동작 테스트")
+    @Nested
+    class ExternalSystemResponseTest {
+
+        @Test
+        @DisplayName("400 Bad Request 발생 시 재시도하지 않고 Fallback이 호출된다")
+        void doesNotRetry_when400BadRequest() throws Exception {
+            // given
+            doReturn(null)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("정상 응답(200 OK) 시 성공적으로 처리한다")
+        void succeeds_when200OK() throws Exception {
+            // given
+            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
+            doReturn(successResponse)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isEqualTo("txn-key-123");
+            assertThat(response.status()).isEqualTo("PENDING");
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("500 Internal Server Error 발생 시 재시도한다")
+        void retries_when500InternalServerError() throws Exception {
+            // given
+            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
+
+            doThrow(runtimeException)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+
+        @Test
+        @DisplayName("네트워크 연결 실패(RuntimeException) 발생 시 재시도한다")
+        void retries_whenRuntimeException() throws Exception {
+            // given
+            RuntimeException runtimeException = new RuntimeException("Connection refused");
+
+            doThrow(runtimeException)
+                    .when(pgPaymentFeignClient)
+                    .requestPayment(anyString(), any());
+
+            // when
+            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
+            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+
+            // then
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.reason()).contains("재시도 후에도 실패");
+
+            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
+        }
+    }
+
+    private PgPaymentDto.PgPaymentResponse createSuccessResponse(String transactionKey) {
+        return new PgPaymentDto.PgPaymentResponse(
+                new PgPaymentDto.PgPaymentResponse.Meta("SUCCESS", null, null),
+                new PgPaymentDto.PgPaymentResponse.TransactionData(
+                        transactionKey,
+                        "PENDING",
+                        null
+                )
+        );
+    }
+}

From 954a381627eb198d32e7a7accc9726e69dde2105 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 00:36:42 +0900
Subject: [PATCH 15/25] =?UTF-8?q?refactor:=20pg=20=EC=8B=9C=EC=8A=A4?=
 =?UTF-8?q?=ED=85=9C=20=EC=97=B0=EB=8F=99=20=ED=8C=A8=ED=82=A4=EC=A7=80?=
 =?UTF-8?q?=EB=AA=85=20gateway=EB=A1=9C=20=EB=B3=80=EA=B2=BD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../{payment => gateway}/PaymentClientConfig.java              | 2 +-
 .../infrastructure/{payment => gateway}/PgPaymentClient.java   | 2 +-
 .../infrastructure/{payment => gateway}/PgPaymentDto.java      | 2 +-
 .../{payment => gateway}/PgPaymentFeignClient.java             | 2 +-
 .../infrastructure/payment/PgPaymentClientResilienceTest.java  | 3 +++
 5 files changed, 7 insertions(+), 4 deletions(-)
 rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{payment => gateway}/PaymentClientConfig.java (93%)
 rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{payment => gateway}/PgPaymentClient.java (98%)
 rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{payment => gateway}/PgPaymentDto.java (95%)
 rename apps/commerce-api/src/main/java/com/loopers/infrastructure/{payment => gateway}/PgPaymentFeignClient.java (93%)

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PaymentClientConfig.java
similarity index 93%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PaymentClientConfig.java
index b922393f1..d43da38a1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentClientConfig.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PaymentClientConfig.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.payment;
+package com.loopers.infrastructure.gateway;
 
 import feign.Request;
 import feign.Retryer;
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
similarity index 98%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
index 1e3b96316..6c2043c42 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.payment;
+package com.loopers.infrastructure.gateway;
 
 import com.loopers.domain.payment.PaymentClient;
 import com.loopers.support.error.CoreException;
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
similarity index 95%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java
rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
index 18e8ab8c8..5f37eeaef 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.payment;
+package com.loopers.infrastructure.gateway;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
similarity index 93%
rename from apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java
rename to apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
index 26ba346b1..2d650ceb1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PgPaymentFeignClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
@@ -1,4 +1,4 @@
-package com.loopers.infrastructure.payment;
+package com.loopers.infrastructure.gateway;
 
 import org.springframework.cloud.openfeign.FeignClient;
 import org.springframework.web.bind.annotation.PostMapping;
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
index 8d257321a..464ab4d97 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
@@ -11,6 +11,9 @@
 
 import com.loopers.domain.payment.PaymentClient.PaymentRequest;
 import com.loopers.domain.payment.PaymentClient.PaymentResponse;
+import com.loopers.infrastructure.gateway.PgPaymentClient;
+import com.loopers.infrastructure.gateway.PgPaymentDto;
+import com.loopers.infrastructure.gateway.PgPaymentFeignClient;
 import io.github.resilience4j.circuitbreaker.CircuitBreaker;
 import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
 import java.util.concurrent.CompletableFuture;

From 9d4bd07e5fc56b891a3a79105c25548c6d2c0ec3 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 00:38:14 +0900
Subject: [PATCH 16/25] =?UTF-8?q?refactor:=20PaymentCallbackRequest=20impo?=
 =?UTF-8?q?rt=EB=A5=BC=20PaymentV1Dto=EB=A1=9C=20=EA=B5=90=EC=B2=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../java/com/loopers/application/payment/PaymentFacade.java   | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 9c9ecfb84..16a54ac69 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -13,7 +13,7 @@
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
-import com.loopers.interfaces.api.payment.PaymentV1Dto.PaymentCallbackRequest;
+import com.loopers.interfaces.api.payment.PaymentV1Dto;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import lombok.RequiredArgsConstructor;
@@ -59,7 +59,7 @@ public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command)
     }
 
     @Transactional
-    public void handlePaymentCallback(PaymentCallbackRequest request) {
+    public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
         Payment payment = paymentService.getPendingPaymentByTransactionKey(request.transactionKey());
         Order order = orderService.getOrderByOrderKey(payment.getOrderKey());
 

From ebd7de069e9b56ada5928a52afb7fb7903f3a1ce Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 13:14:17 +0900
Subject: [PATCH 17/25] =?UTF-8?q?refactor:=20PaymentFacade=EC=97=90?=
 =?UTF-8?q?=EC=84=9C=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88?=
 =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EC=97=AD=ED=95=A0=EC=97=90=20=EC=A7=91?=
 =?UTF-8?q?=EC=A4=91=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9?=
 =?UTF-8?q?=ED=86=A0=EB=A7=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    | 30 +++++++------------
 .../java/com/loopers/domain/order/Order.java  |  4 +++
 .../loopers/domain/order/OrderService.java    |  2 --
 .../com/loopers/domain/payment/Payment.java   |  4 +++
 .../domain/payment/PaymentRepository.java     |  2 --
 .../domain/payment/PaymentService.java        | 24 +++++----------
 .../loopers/domain/point/PointService.java    |  3 --
 .../domain/product/ProductService.java        |  7 +++--
 .../payment/PaymentRepositoryImpl.java        |  5 ----
 9 files changed, 32 insertions(+), 49 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 16a54ac69..4034d5937 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -7,7 +7,6 @@
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.Payment;
 import com.loopers.domain.payment.PaymentService;
-import com.loopers.domain.payment.PaymentStatus;
 import com.loopers.domain.point.PointService;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
@@ -60,12 +59,12 @@ public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command)
 
     @Transactional
     public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
-        Payment payment = paymentService.getPendingPaymentByTransactionKey(request.transactionKey());
-        Order order = orderService.getOrderByOrderKey(payment.getOrderKey());
+        Payment payment = paymentService.getPendingPaymentByOrderKey(request.orderId());
+        Order order = orderService.getOrderByOrderKey(request.orderId());
 
         String status = request.status();
         switch (status) {
-            case "SUCCESS" -> handleSuccess(payment, order);
+            case "SUCCESS" -> handleSuccess(payment, order, request.transactionKey());
             case "FAILED" -> handleFailure(payment, order, request.reason());
             default -> {
                 log.warn("알 수 없는 결제 상태: transactionKey={}, status={}", request.transactionKey(), status);
@@ -73,38 +72,31 @@ public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
         }
     }
 
-    private void handleSuccess(Payment payment, Order order) {
+    private void handleSuccess(Payment payment, Order order, String transactionKey) {
         orderService.payOrder(order);
-        paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.COMPLETED);
+        paymentService.completePayment(payment, transactionKey);
 
         log.info("결제 완료 처리: orderId={}, transactionKey={}", order.getId(), payment.getTransactionKey());
     }
 
     private void handleFailure(Payment payment, Order order, String reason) {
         orderService.cancelOrder(order);
-        
-        if (payment.getTransactionKey() != null && !payment.getTransactionKey().isBlank()) {
-            paymentService.updatePaymentStatus(payment.getTransactionKey(), PaymentStatus.FAILED);
-        } else {
-            paymentService.updatePaymentStatusByOrderKey(payment.getOrderKey(), PaymentStatus.FAILED);
-        }
+        paymentService.failPayment(payment);
 
         for (OrderItem orderItem : order.getOrderItems()) {
-            Product product = productService.findProductById(orderItem.getProductId())
-                    .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다: " + orderItem.getProductId()));
+            Product product = productService.getProductById(orderItem.getProductId());
             productService.restoreStock(product, orderItem.getQuantity());
         }
 
-        int pointAmount = order.getOriginalTotalPrice() - (order.getDiscountPrice() != null ? order.getDiscountPrice() : 0);
-        pointService.restorePoint(order.getUserId(), pointAmount);
+        int restorePointAmount = order.getOriginalTotalPrice() - (order.getDiscountPrice() != null ? order.getDiscountPrice() : 0);
+        pointService.restorePoint(order.getUserId(), restorePointAmount);
 
-        if (order.getCouponId() != null) {
+        if (order.hasCoupon()) {
             Coupon coupon = couponService.getCouponByIdAndUserId(order.getCouponId(), order.getUserId());
             couponService.restoreCoupon(coupon);
         }
 
-        log.warn("결제 실패 처리: orderKey={}, transactionKey={}, reason={}",
-                payment.getOrderKey(), payment.getTransactionKey(), reason);
+        log.warn("결제 실패 처리: orderKey={}, reason={}", payment.getOrderKey(), reason);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index 699a0a98d..773d95df2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -168,4 +168,8 @@ private void validatePrice(int price) {
             throw new CoreException(ErrorType.BAD_REQUEST, "가격은 0보다 커야 합니다.");
         }
     }
+
+    public boolean hasCoupon() {
+        return this.couponId != null;
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
index ac3f83bd3..3c202fe06 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -72,11 +72,9 @@ public void validateIsUserOrder(String orderKey, Long userId) {
 
     public void payOrder(Order order) {
         order.pay();
-        orderRepository.saveOrder(order);
     }
 
     public void cancelOrder(Order order) {
         order.cancel();
-        orderRepository.saveOrder(order);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index 027cdc899..77b1f0587 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -113,5 +113,9 @@ public void updateCard(String cardType, String cardNumber) {
         }
         this.card = Card.createCard(cardType, cardNumber);
     }
+
+    public boolean isCompleted() {
+        return this.status == PaymentStatus.COMPLETED;
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
index ef8d4dee6..18db950db 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
@@ -6,7 +6,5 @@ public interface PaymentRepository {
     void savePayment(Payment payment);
 
     Optional<Payment> findByOrderKey(String orderKey);
-
-    Optional<Payment> findByTransactionKey(String transactionKey);
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index 616dc8e7e..33f829987 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -75,32 +75,24 @@ public Payment requestPaymentToPg(Payment savedPayment, String cardType, String
         }
     }
 
-    public Payment getPendingPaymentByTransactionKey(String transactionKey) {
-        Payment payment = paymentRepository.findByTransactionKey(transactionKey)
+    public Payment getPendingPaymentByOrderKey(String orderKey) {
+        Payment payment = paymentRepository.findByOrderKey(orderKey)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
 
-        if (payment.getStatus() == PaymentStatus.COMPLETED) {
+        if (payment.isCompleted()) {
             throw new CoreException(ErrorType.CONFLICT, "이미 성공한 결제입니다.");
         }
 
         return payment;
     }
 
-    @Transactional
-    public void updatePaymentStatus(String transactionKey, PaymentStatus status) {
-        Payment payment = paymentRepository.findByTransactionKey(transactionKey)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
-        payment.updateStatus(status);
-        payment.updateTransactionKey(transactionKey);
-        paymentRepository.savePayment(payment);
+    public void completePayment(Payment savedPayment, String transactionKey) {
+        savedPayment.updateStatus(PaymentStatus.COMPLETED);
+        savedPayment.updateTransactionKey(transactionKey);
     }
 
-    @Transactional
-    public void updatePaymentStatusByOrderKey(String orderKey, PaymentStatus status) {
-        Payment payment = paymentRepository.findByOrderKey(orderKey)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
-        payment.updateStatus(status);
-        paymentRepository.savePayment(payment);
+    public void failPayment(Payment savedPayment) {
+        savedPayment.updateStatus(PaymentStatus.FAILED);
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
index d0c490cc4..42332b248 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/point/PointService.java
@@ -39,15 +39,12 @@ public void deductPoint(Long userId, int amount) {
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다."));
 
         point.deduct(amount);
-        pointRepository.savePoint(point);
     }
 
-    @Transactional
     public void restorePoint(Long userId, int amount) {
         Point point = pointRepository.findPointByUserId(userId)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "포인트를 찾을 수 없습니다."));
 
         point.charge(amount);
-        pointRepository.savePoint(point);
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
index 254b02725..13001a6ff 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/product/ProductService.java
@@ -19,16 +19,19 @@ public Optional<Product> findProductById(Long productId) {
         return productRepository.findProductById(productId);
     }
 
+    public Product getProductById(Long productId) {
+        return productRepository.findProductById(productId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+    }
+
     @Transactional
     public void reduceStock(Product product, Integer quantity) {
         product.reduceStock(quantity);
         productRepository.saveProduct(product);
     }
 
-    @Transactional
     public void restoreStock(Product product, Integer quantity) {
         product.restoreStock(quantity);
-        productRepository.saveProduct(product);
     }
 
     public List<Product> findProductsByLatest(Long brandId, int page, int size) {
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
index e7c315860..d720008eb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -21,10 +21,5 @@ public void savePayment(Payment payment) {
     public Optional<Payment> findByOrderKey(String orderKey) {
         return paymentJpaRepository.findByOrderKey(orderKey);
     }
-
-    @Override
-    public Optional<Payment> findByTransactionKey(String transactionKey) {
-        return paymentJpaRepository.findByTransactionKey(transactionKey);
-    }
 }
 

From 4e3238332747ec1e621fd1d63d54bdd036c1c038 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 14:30:16 +0900
Subject: [PATCH 18/25] =?UTF-8?q?refactor:=20OrderFacade=EC=97=90=EC=84=9C?=
 =?UTF-8?q?=20=EC=98=A4=EC=BC=80=EC=8A=A4=ED=8A=B8=EB=A0=88=EC=9D=B4?=
 =?UTF-8?q?=EC=85=98=20=EC=97=AD=ED=95=A0=EC=97=90=20=EC=A7=91=EC=A4=91?=
 =?UTF-8?q?=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=A6=AC=ED=8C=A9=ED=86=A0?=
 =?UTF-8?q?=EB=A7=81,=20=EA=B2=B0=EC=A0=9C=20=EC=88=98=EB=8B=A8=EC=97=90?=
 =?UTF-8?q?=20=EB=94=B0=EB=A5=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?=
 =?UTF-8?q?=20-=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B2=B0=EC=A0=9C=20:=20?=
 =?UTF-8?q?=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=B0=A8=EA=B0=90=20-=20?=
 =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EA=B2=B0=EC=A0=9C=20:=20Payment=20?=
 =?UTF-8?q?=EC=83=9D=EC=84=B1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/order/OrderCommand.java       | 28 ++++++++++++++++++-
 .../application/order/OrderFacade.java        | 22 +++++++++------
 .../java/com/loopers/domain/order/Order.java  |  8 ++++++
 .../loopers/domain/order/OrderRepository.java |  2 --
 .../loopers/domain/order/OrderService.java    | 12 +++++++-
 .../order/OrderRepositoryImpl.java            |  5 ----
 .../interfaces/api/order/OrderV1Dto.java      |  9 +++---
 .../order/OrderFacadeConcurrencyTest.java     | 14 ++++++----
 .../order/OrderServiceIntegrationTest.java    |  3 +-
 9 files changed, 75 insertions(+), 28 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java
index 0cf894f7e..60e336aa7 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderCommand.java
@@ -1,15 +1,41 @@
 package com.loopers.application.order;
 
 import com.loopers.domain.order.Delivery;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import java.util.Arrays;
 import java.util.List;
 
 public class OrderCommand {
 
+    public enum PaymentMethod {
+        POINT("point"),
+        CARD("card");
+
+        private final String value;
+
+        PaymentMethod(String value) {
+            this.value = value;
+        }
+
+        public static PaymentMethod fromValue(String value) {
+            if (value == null || value.isBlank()) {
+                throw new CoreException(ErrorType.BAD_REQUEST, "결제 수단은 필수입니다.");
+            }
+            return Arrays.stream(PaymentMethod.values())
+                    .filter(method -> method.value.equalsIgnoreCase(value))
+                    .findFirst()
+                    .orElseThrow(() -> new CoreException(ErrorType.BAD_REQUEST, 
+                            String.format("유효하지 않은 결제 수단입니다: %s", value)));
+        }
+    }
+
     public record CreateOrderCommand(
             String loginId,
             List<OrderItemCommand> orderItems,
             Long couponId,
-            DeliveryCommand delivery
+            DeliveryCommand delivery,
+            PaymentMethod paymentMethod
     ) {
     }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
index 90b67e3d5..197a4ff88 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/order/OrderFacade.java
@@ -1,5 +1,6 @@
 package com.loopers.application.order;
 
+import com.loopers.application.order.OrderCommand.PaymentMethod;
 import com.loopers.domain.coupon.Coupon;
 import com.loopers.domain.coupon.CouponService;
 import com.loopers.domain.order.Delivery;
@@ -11,7 +12,6 @@
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserService;
-import java.util.UUID;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import java.util.List;
@@ -48,13 +48,11 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
 
         Delivery delivery = OrderCommand.DeliveryCommand.toDelivery(createOrderCommand.delivery());
-        String orderKey = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
-        Order order = orderService.createOrder(user.getId(), orderKey, delivery);
+        Order order = orderService.createOrder(user.getId(), delivery);
 
         int originalTotalPrice = 0;
         for (OrderCommand.OrderItemCommand orderItemCommand : createOrderCommand.orderItems()) {
-            Product product = productService.findProductById(orderItemCommand.productId())
-                    .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "상품을 찾을 수 없습니다."));
+            Product product = productService.getProductById(orderItemCommand.productId());
 
             orderService.createOrderItem(order, product, orderItemCommand.quantity());
             originalTotalPrice += orderService.addTotalPrice(order, product.getPrice().getPrice(), orderItemCommand.quantity());
@@ -72,12 +70,20 @@ public OrderInfo createOrder(OrderCommand.CreateOrderCommand createOrderCommand)
         }
 
         int finalAmount = originalTotalPrice - discountPrice;
-        pointService.deductPoint(user.getId(), finalAmount);
 
+        PaymentMethod paymentMethod = createOrderCommand.paymentMethod();
+        if (paymentMethod == PaymentMethod.POINT) {
+            pointService.deductPoint(user.getId(), finalAmount);
+        } else if (paymentMethod == PaymentMethod.CARD) {
+            paymentService.createPayment(finalAmount, order.getOrderKey());
+        } else {
+            throw new CoreException(ErrorType.BAD_REQUEST, "지원하지 않는 결제 수단입니다.");
+        }
+
+        orderService.applyPrice(order, originalTotalPrice, discountPrice);
         orderService.saveOrder(order);
-        paymentService.createPayment(finalAmount, orderKey);
 
-        return OrderInfo.from(order, order.getOrderItems(), delivery, orderKey);
+        return OrderInfo.from(order, order.getOrderItems(), delivery, order.getOrderKey());
     }
 
     @Recover
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
index 773d95df2..a0db8b29d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/Order.java
@@ -172,4 +172,12 @@ private void validatePrice(int price) {
     public boolean hasCoupon() {
         return this.couponId != null;
     }
+
+    public void applyOriginalTotalPrice(int originalTotalPrice) {
+        this.originalTotalPrice = originalTotalPrice;
+    }
+
+    public void applyDiscountPrice(int discountPrice) {
+        this.discountPrice = discountPrice;
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
index 110a4af4c..b89c88c34 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderRepository.java
@@ -9,8 +9,6 @@ public interface OrderRepository {
 
     Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId);
 
-    Optional<Order> findOrderById(Long orderId);
-
     Optional<Order> findByOrderKey(String orderKey);
 
     void saveOrder(Order order);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
index 3c202fe06..785621499 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderService.java
@@ -6,6 +6,7 @@
 import com.loopers.support.error.ErrorType;
 import java.util.List;
 import java.util.Optional;
+import java.util.UUID;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
@@ -15,7 +16,8 @@ public class OrderService {
 
     private final OrderRepository orderRepository;
 
-    public Order createOrder(Long userId, String orderKey, Delivery delivery) {
+    public Order createOrder(Long userId, Delivery delivery) {
+        String orderKey = UUID.randomUUID().toString().replace("-", "").substring(0, 8);
         return Order.createOrder(userId, orderKey, delivery);
     }
 
@@ -77,4 +79,12 @@ public void payOrder(Order order) {
     public void cancelOrder(Order order) {
         order.cancel();
     }
+
+    public void applyPrice(Order order, int originalTotalPrice, int discountPrice) {
+        order.applyOriginalTotalPrice(originalTotalPrice);
+
+        if (discountPrice > 0) {
+            order.applyDiscountPrice(discountPrice);
+        }
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
index 8aed690ef..aebe05c7e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/order/OrderRepositoryImpl.java
@@ -23,11 +23,6 @@ public Optional<Order> findOrderByIdAndUserId(Long orderId, Long userId) {
         return orderJpaRepository.findByIdAndUserId(orderId, userId);
     }
 
-    @Override
-    public Optional<Order> findOrderById(Long orderId) {
-        return orderJpaRepository.findById(orderId);
-    }
-
     @Override
     public Optional<Order> findByOrderKey(String orderKey) {
         return orderJpaRepository.findByOrderKey(orderKey);
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
index 38a0fa210..a6b0ebb5e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/order/OrderV1Dto.java
@@ -1,7 +1,6 @@
 package com.loopers.interfaces.api.order;
 
 import com.loopers.application.order.OrderCommand;
-import com.loopers.application.order.OrderCommand.CreateOrderCommand;
 import com.loopers.application.order.OrderInfo;
 import java.util.List;
 
@@ -10,9 +9,10 @@ public class OrderV1Dto {
     public record OrderRequest(
             List<OrderItemRequest> orderItems,
             Long couponId,
-            DeliveryRequest delivery
+            DeliveryRequest delivery,
+            String paymentMethod
     ) {
-        public CreateOrderCommand toCommand(String loginId) {
+        public OrderCommand.CreateOrderCommand toCommand(String loginId) {
             List<OrderCommand.OrderItemCommand> itemCommands = orderItems.stream()
                     .map(OrderItemRequest::toCommand)
                     .toList();
@@ -21,7 +21,8 @@ public CreateOrderCommand toCommand(String loginId) {
                     loginId,
                     itemCommands,
                     couponId,
-                    delivery.toCommand()
+                    delivery.toCommand(),
+                    OrderCommand.PaymentMethod.fromValue(paymentMethod)
             );
         }
     }
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeConcurrencyTest.java
index ed82237a3..c08bf391d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeConcurrencyTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/order/OrderFacadeConcurrencyTest.java
@@ -137,7 +137,8 @@ void shouldDeductPointCorrectly_whenSameUserCreatesMultipleOrdersConcurrently()
                             loginId,
                             List.of(orderItemCommand),
                             null,
-                            DEFAULT_DELIVERY_COMMAND
+                            DEFAULT_DELIVERY_COMMAND,
+                            OrderCommand.PaymentMethod.POINT
                     );
 
                     return orderFacade.createOrder(createOrderCommand);
@@ -207,7 +208,8 @@ void shouldDeductPointCorrectly_whenSameUserCreatesOrdersWithSameProductConcurre
                             loginId,
                             List.of(orderItemCommand),
                             null,
-                            DEFAULT_DELIVERY_COMMAND
+                            DEFAULT_DELIVERY_COMMAND,
+                            OrderCommand.PaymentMethod.POINT
                     );
 
                     return orderFacade.createOrder(createOrderCommand);
@@ -286,7 +288,7 @@ void shouldSucceedOnlyAvailableStock_whenOrdersExceedStockConcurrently() throws
                             limitedProductId, 1);
 
                     OrderCommand.CreateOrderCommand createOrderCommand = new OrderCommand.CreateOrderCommand(
-                            buyerId, List.of(orderItemCommand), null, DEFAULT_DELIVERY_COMMAND);
+                            buyerId, List.of(orderItemCommand), null, DEFAULT_DELIVERY_COMMAND, OrderCommand.PaymentMethod.POINT);
 
                     orderFacade.createOrder(createOrderCommand);
 
@@ -350,7 +352,8 @@ void shouldSucceedOnlyOneOrder_whenSameCouponIsUsedConcurrently() throws Excepti
                             loginId,
                             List.of(orderItemCommand),
                             couponId,
-                            DEFAULT_DELIVERY_COMMAND
+                            DEFAULT_DELIVERY_COMMAND,
+                            OrderCommand.PaymentMethod.POINT
                     );
 
                     orderFacade.createOrder(createOrderCommand);
@@ -423,7 +426,8 @@ void shouldSucceedAllOrders_whenDifferentCouponsAreUsedConcurrently() throws Exc
                             loginId,
                             List.of(orderItemCommand),
                             couponId,
-                            DEFAULT_DELIVERY_COMMAND
+                            DEFAULT_DELIVERY_COMMAND,
+                            OrderCommand.PaymentMethod.POINT
                     );
 
                     return orderFacade.createOrder(createOrderCommand);
diff --git a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
index 0628d077e..b05b31f1a 100644
--- a/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/domain/order/OrderServiceIntegrationTest.java
@@ -48,11 +48,10 @@ class CreateOrder {
         void createsOrder_whenUserIdAndDeliveryAreProvided() {
             // arrange
             Long userId = 1L;
-            String orderKey = "order12345";
             Delivery delivery = createDelivery();
 
             // act
-            Order order = orderService.createOrder(userId, orderKey, delivery);
+            Order order = orderService.createOrder(userId, delivery);
 
             // assert
             assertAll(

From e5488dc31fd64d43b7b6a5d35ce76c15432564e4 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 15:07:35 +0900
Subject: [PATCH 19/25] =?UTF-8?q?fix:=20pg=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=8B=9C=20=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EA=B4=80=EB=A0=A8=20?=
 =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20=EC=B9=B4?=
 =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=B3=B4=20=EC=97=85=EB=8D=B0=EC=9D=B4?=
 =?UTF-8?q?=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/application/payment/PaymentFacade.java  |  8 ++------
 .../java/com/loopers/domain/payment/Payment.java    |  4 ++++
 .../com/loopers/domain/payment/PaymentService.java  | 13 +++++++------
 3 files changed, 13 insertions(+), 12 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 4034d5937..4a33815e9 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -7,7 +7,6 @@
 import com.loopers.domain.order.OrderService;
 import com.loopers.domain.payment.Payment;
 import com.loopers.domain.payment.PaymentService;
-import com.loopers.domain.point.PointService;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductService;
 import com.loopers.domain.user.User;
@@ -29,13 +28,13 @@ public class PaymentFacade {
     private final OrderService orderService;
     private final UserService userService;
     private final ProductService productService;
-    private final PointService pointService;
     private final CouponService couponService;
 
     @Transactional
     public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command) {
         Payment savedPayment = paymentService.getPaymentByOrderKey(command.orderKey());
-        paymentService.validatePaymentStatus(savedPayment);
+        paymentService.validatePaymentStatusPending(savedPayment);
+        paymentService.applyCardInfo(savedPayment, command.cardType(), command.cardNo());
 
         User user = userService.getUserByLoginId(command.loginId());
         Order order = orderService.getOrderByOrderKey(command.orderKey());
@@ -88,9 +87,6 @@ private void handleFailure(Payment payment, Order order, String reason) {
             productService.restoreStock(product, orderItem.getQuantity());
         }
 
-        int restorePointAmount = order.getOriginalTotalPrice() - (order.getDiscountPrice() != null ? order.getDiscountPrice() : 0);
-        pointService.restorePoint(order.getUserId(), restorePointAmount);
-
         if (order.hasCoupon()) {
             Coupon coupon = couponService.getCouponByIdAndUserId(order.getCouponId(), order.getUserId());
             couponService.restoreCoupon(coupon);
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index 77b1f0587..a8a0397a1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -117,5 +117,9 @@ public void updateCard(String cardType, String cardNumber) {
     public boolean isCompleted() {
         return this.status == PaymentStatus.COMPLETED;
     }
+
+    public boolean isPending() {
+        return this.status == PaymentStatus.PENDING;
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index 33f829987..0bfc3cff3 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -32,15 +32,16 @@ public Payment getPaymentByOrderKey(String orderKey) {
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "결제 정보를 찾을 수 없습니다."));
     }
 
-    public void validatePaymentStatus(Payment payment) {
-        if (payment.getStatus() != PaymentStatus.PENDING) {
-            if (payment.getStatus() == PaymentStatus.COMPLETED) {
-                throw new CoreException(ErrorType.CONFLICT, "이미 성공한 결제입니다.");
-            }
-            throw new CoreException(ErrorType.BAD_REQUEST, "결제 대기 상태인 경우에만 결제 요청할 수 있습니다. 현재 상태: " + payment.getStatus());
+    public void validatePaymentStatusPending(Payment payment) {
+        if (!payment.isPending()) {
+            throw new CoreException(ErrorType.CONFLICT, "결제 대기 상태가 아닙니다.");
         }
     }
 
+    public void applyCardInfo(Payment savedPayment, String cardType, String cardNumber) {
+        savedPayment.updateCard(cardType, cardNumber);
+    }
+
     @Transactional
     public Payment requestPaymentToPg(Payment savedPayment, String cardType, String cardNo, String userId) {
         String callbackUrl = String.format("http://localhost:%s/api/v1/payments/callback", serverPort);

From a04aaf7b5de0269607806a6ac6d5703abd516a0e Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 17:15:18 +0900
Subject: [PATCH 20/25] =?UTF-8?q?refactor:=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=B0=8F=20=EC=BD=9C=EB=B0=B1=20=EB=A1=9C?=
 =?UTF-8?q?=EC=A7=81=20=EC=88=98=EC=A0=95=20-=20PaymentService=EC=99=80=20?=
 =?UTF-8?q?PaymentClient=EC=9D=98=20=EC=B1=85=EC=9E=84=EC=9D=84=20?=
 =?UTF-8?q?=EB=AA=85=ED=99=95=ED=9E=88=20=EB=B6=84=EB=A6=AC=20-=20Payment?=
 =?UTF-8?q?=20=EC=83=81=ED=83=9C=20=EC=88=98=EC=A0=95=EC=9D=80=20=EC=BD=9C?=
 =?UTF-8?q?=EB=B0=B1=20=EB=A1=9C=EC=A7=81=EC=9D=B4=20=EC=95=84=EB=8B=8C,?=
 =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94=EC=B2=AD?=
 =?UTF-8?q?=20=ED=9B=84=20=EC=B2=98=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    | 20 ++++-------
 .../com/loopers/domain/payment/Payment.java   |  7 ++--
 .../loopers/domain/payment/PaymentClient.java | 11 +++---
 .../gateway/PgPaymentClient.java              | 36 +++++++++++++------
 .../gateway/PgPaymentFeignClient.java         |  4 +--
 .../src/main/resources/application.yml        |  2 ++
 6 files changed, 45 insertions(+), 35 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index 4a33815e9..cc8d0326b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -5,6 +5,7 @@
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderItem;
 import com.loopers.domain.order.OrderService;
+import com.loopers.domain.payment.Card;
 import com.loopers.domain.payment.Payment;
 import com.loopers.domain.payment.PaymentService;
 import com.loopers.domain.product.Product;
@@ -34,20 +35,16 @@ public class PaymentFacade {
     public PaymentInfo requestPayment(PaymentCommand.RequestPaymentCommand command) {
         Payment savedPayment = paymentService.getPaymentByOrderKey(command.orderKey());
         paymentService.validatePaymentStatusPending(savedPayment);
-        paymentService.applyCardInfo(savedPayment, command.cardType(), command.cardNo());
+
+        Card card = paymentService.createCard(command.cardType(), command.cardNo());
+        paymentService.applyCardInfo(savedPayment, card);
 
         User user = userService.getUserByLoginId(command.loginId());
         Order order = orderService.getOrderByOrderKey(command.orderKey());
         orderService.validateIsUserOrder(command.orderKey(), user.getId());
 
         try {
-            Payment updatedPayment = paymentService.requestPaymentToPg(
-                    savedPayment,
-                    command.cardType(),
-                    command.cardNo(),
-                    command.loginId()
-            );
-
+            Payment updatedPayment = paymentService.requestPaymentToPg(savedPayment, command.loginId());
             return PaymentInfo.from(updatedPayment);
         } catch (Exception e) {
             log.error("PG 결제 요청 실패: orderKey={}, error={}", command.orderKey(), e.getMessage(), e);
@@ -63,7 +60,7 @@ public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
 
         String status = request.status();
         switch (status) {
-            case "SUCCESS" -> handleSuccess(payment, order, request.transactionKey());
+            case "SUCCESS" -> handleSuccess(payment, order);
             case "FAILED" -> handleFailure(payment, order, request.reason());
             default -> {
                 log.warn("알 수 없는 결제 상태: transactionKey={}, status={}", request.transactionKey(), status);
@@ -71,16 +68,13 @@ public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
         }
     }
 
-    private void handleSuccess(Payment payment, Order order, String transactionKey) {
+    private void handleSuccess(Payment payment, Order order) {
         orderService.payOrder(order);
-        paymentService.completePayment(payment, transactionKey);
-
         log.info("결제 완료 처리: orderId={}, transactionKey={}", order.getId(), payment.getTransactionKey());
     }
 
     private void handleFailure(Payment payment, Order order, String reason) {
         orderService.cancelOrder(order);
-        paymentService.failPayment(payment);
 
         for (OrderItem orderItem : order.getOrderItems()) {
             Product product = productService.getProductById(orderItem.getProductId());
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index a8a0397a1..d034a6bfe 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -107,11 +107,8 @@ public void updateTransactionKey(String transactionKey) {
         this.transactionKey = transactionKey;
     }
 
-    public void updateCard(String cardType, String cardNumber) {
-        if (cardType == null || cardNumber == null) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "카드 타입과 카드 번호는 필수입니다.");
-        }
-        this.card = Card.createCard(cardType, cardNumber);
+    public void updateCard(Card card) {
+        this.card = card;
     }
 
     public boolean isCompleted() {
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
index 078b9eb25..45bb45f3e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
@@ -1,17 +1,15 @@
 package com.loopers.domain.payment;
 
-import java.util.concurrent.CompletableFuture;
-
 public interface PaymentClient {
 
-    CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request, String userId);
+    PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest request);
 
     record PaymentRequest(
             String orderId,
             String cardType,
             String cardNo,
-            Long amount,
-            String callbackUrl
+            Integer amount,
+            String userId
     ) {
     }
 
@@ -20,6 +18,9 @@ record PaymentResponse(
             String status,
             String reason
     ) {
+        public boolean isSuccess() {
+            return "SUCCESS".equalsIgnoreCase(status);
+        }
     }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
index 6c2043c42..ed64bcd47 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
@@ -9,6 +9,7 @@
 import java.util.concurrent.CompletableFuture;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Component;
 
 @Slf4j
@@ -16,25 +17,40 @@
 @RequiredArgsConstructor
 public class PgPaymentClient implements PaymentClient {
 
+    @Value("${pg-simulator.callback-url}")
+    private String callbackUrl;
+
     private final PgPaymentFeignClient pgPaymentFeignClient;
 
     @Override
     @Retry(name = "pgRetry", fallbackMethod = "requestPaymentRetryFallback")
     @CircuitBreaker(name = "pgPayment", fallbackMethod = "requestPaymentFallback")
     @TimeLimiter(name = "pgPayment")
-    public CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request, String userId) {
+    public PaymentResponse requestPayment(PaymentClient.PaymentRequest request) {
+
+        PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
+                request.orderId(),
+                request.cardType(),
+                request.cardNo(),
+                (long) request.amount(),
+                callbackUrl
+        );
+
+        PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(request.userId(), pgRequest);
+
+
         try {
             log.info("PG 결제 요청: orderKey={}, amount={}", request.orderId(), request.amount());
 
-            PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
-                    request.orderId(),
-                    request.cardType(),
-                    request.cardNo(),
-                    request.amount(),
-                    request.callbackUrl()
-            );
+//            PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
+//                    request.orderId(),
+//                    request.cardType(),
+//                    request.cardNo(),
+//                    request.amount(),
+//                    request.callbackUrl()
+//            );
 
-            PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(userId, pgRequest);
+//            PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(userId, pgRequest);
 
             if (response != null && response.data() != null) {
                 log.info("PG 결제 요청 성공: transactionKey={}, status={}",
@@ -46,7 +62,7 @@ public CompletableFuture<PaymentResponse> requestPayment(PaymentRequest request,
                         response.data().status(),
                         response.data().reason()
                 );
-                return CompletableFuture.completedFuture(paymentResponse);
+                return paymentResponse;
             } else {
                 log.error("PG 결제 요청 실패: 응답이 null입니다.");
                 throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 결제 요청이 실패했습니다.");
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
index 2d650ceb1..ad32de4fe 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
@@ -7,8 +7,8 @@
 
 @FeignClient(
         name = "pgPaymentClient",
-        url = "${pg-simulator.url:http://localhost:8081}",
-        path = "/api/v1/payments",
+        url = "${pg-simulator.url}",
+        path = "${pg-simulator.path}",
         configuration = PaymentClientConfig.class
 )
 public interface PgPaymentFeignClient {
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 0cb319cc2..b55d1cb08 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -31,6 +31,8 @@ springdoc:
 
 pg-simulator:
   url: http://localhost:8082
+  path: /api/v1/payments/api/v1/payments
+  callback-url: http://localhost:8080/api/v1/payments/callback
 
 resilience4j:
   circuitbreaker:

From 6d680e6f2eb9335db0f3e71e1f0108f9a5307d83 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 17:40:47 +0900
Subject: [PATCH 21/25] =?UTF-8?q?refactor:=20PaymentService=20=EC=B9=B4?=
 =?UTF-8?q?=EB=93=9C=20=EC=A0=95=EB=B3=B4=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F?=
 =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81?=
 =?UTF-8?q?=20=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../domain/payment/PaymentService.java        | 72 ++++++-------------
 1 file changed, 22 insertions(+), 50 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index 0bfc3cff3..4fad04fbe 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -1,17 +1,10 @@
 package com.loopers.domain.payment;
 
-import com.loopers.domain.payment.PaymentClient.PaymentRequest;
-import com.loopers.domain.payment.PaymentClient.PaymentResponse;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
-import java.util.concurrent.CompletableFuture;
 import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
 
-@Slf4j
 @RequiredArgsConstructor
 @Service
 public class PaymentService {
@@ -19,9 +12,6 @@ public class PaymentService {
     private final PaymentRepository paymentRepository;
     private final PaymentClient paymentClient;
 
-    @Value("${server.port:8080}")
-    private String serverPort;
-
     public void createPayment(Integer amount, String orderKey) {
         Payment payment = Payment.createPayment(amount, orderKey);
         paymentRepository.savePayment(payment);
@@ -38,42 +28,33 @@ public void validatePaymentStatusPending(Payment payment) {
         }
     }
 
-    public void applyCardInfo(Payment savedPayment, String cardType, String cardNumber) {
-        savedPayment.updateCard(cardType, cardNumber);
+    public Card createCard(String cardType, String cardNumber) {
+        return Card.createCard(cardType, cardNumber);
+    }
+
+    public void applyCardInfo(Payment savedPayment, Card card) {
+        savedPayment.updateCard(card);
     }
 
-    @Transactional
-    public Payment requestPaymentToPg(Payment savedPayment, String cardType, String cardNo, String userId) {
-        String callbackUrl = String.format("http://localhost:%s/api/v1/payments/callback", serverPort);
-        PaymentRequest request = new PaymentRequest(
-                savedPayment.getOrderKey(),
-                cardType,
-                cardNo,
-                (long) savedPayment.getAmount(),
-                callbackUrl
+    public Payment requestPaymentToPg(Payment payment, String loginId) {
+        PaymentClient.PaymentRequest request = new PaymentClient.PaymentRequest(
+                payment.getOrderKey(),
+                payment.getCard().getCardType(),
+                payment.getCard().getCardNumber(),
+                payment.getAmount(),
+                loginId
         );
 
-        try {
-            CompletableFuture<PaymentResponse> paymentFuture = paymentClient.requestPayment(request, userId);
-            PaymentResponse response = paymentFuture.get();
-            String transactionKey = response.transactionKey();
-
-            if (transactionKey != null && !transactionKey.isBlank()) {
-                savedPayment.updateTransactionKey(transactionKey);
-                savedPayment.updateStatus(PaymentStatus.COMPLETED);
-                savedPayment.updateCard(cardType, cardNo);
-                paymentRepository.savePayment(savedPayment);
-
-                log.info("PG 결제 요청 완료: orderKey={}, transactionKey={}", savedPayment.getOrderKey(), transactionKey);
-                return savedPayment;
-            } else {
-                log.error("PG 결제 요청 실패: transactionKey가 null입니다.");
-                throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 요청에 실패했습니다.");
-            }
-        } catch (Exception e) {
-            log.error("PG 결제 요청 실패: orderKey={}, error={}", savedPayment.getOrderKey(), e.getMessage(), e);
-            throw new CoreException(ErrorType.INTERNAL_ERROR, "결제 요청에 실패했습니다.");
+        PaymentClient.PaymentResponse paymentResponse = paymentClient.requestPayment(request);
+
+        if (paymentResponse.isSuccess()) {
+            payment.updateStatus(PaymentStatus.COMPLETED);
+            payment.updateTransactionKey(paymentResponse.transactionKey());
+        } else {
+            payment.updateStatus(PaymentStatus.FAILED);
         }
+
+        return payment;
     }
 
     public Payment getPendingPaymentByOrderKey(String orderKey) {
@@ -86,14 +67,5 @@ public Payment getPendingPaymentByOrderKey(String orderKey) {
 
         return payment;
     }
-
-    public void completePayment(Payment savedPayment, String transactionKey) {
-        savedPayment.updateStatus(PaymentStatus.COMPLETED);
-        savedPayment.updateTransactionKey(transactionKey);
-    }
-
-    public void failPayment(Payment savedPayment) {
-        savedPayment.updateStatus(PaymentStatus.FAILED);
-    }
 }
 

From 65b6a6c5379bfbc7a79dc1e5f1c3a048ef5a52f1 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Tue, 9 Dec 2025 19:05:18 +0900
Subject: [PATCH 22/25] =?UTF-8?q?refactor:=20PgPaymentClient=20=EA=B2=B0?=
 =?UTF-8?q?=EC=A0=9C=20=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EC=A7=81=20=EA=B0=9C?=
 =?UTF-8?q?=EC=84=A0=20=EB=B0=8F=20=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC?=
 =?UTF-8?q?=20=EA=B0=95=ED=99=94?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../gateway/PgPaymentClient.java              | 47 ++++++-------------
 1 file changed, 15 insertions(+), 32 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
index ed64bcd47..70632c54e 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
@@ -26,7 +26,7 @@ public class PgPaymentClient implements PaymentClient {
     @Retry(name = "pgRetry", fallbackMethod = "requestPaymentRetryFallback")
     @CircuitBreaker(name = "pgPayment", fallbackMethod = "requestPaymentFallback")
     @TimeLimiter(name = "pgPayment")
-    public PaymentResponse requestPayment(PaymentClient.PaymentRequest request) {
+    public PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest request) {
 
         PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
                 request.orderId(),
@@ -38,45 +38,28 @@ public PaymentResponse requestPayment(PaymentClient.PaymentRequest request) {
 
         PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(request.userId(), pgRequest);
 
+        String errorCode = response.meta().errorCode();
+        if (errorCode != null && !errorCode.isBlank()) {
+            if (errorCode.equals("Bad Request")) {
+                throw new CoreException(ErrorType.BAD_REQUEST, response.meta().message());
+            }
 
-        try {
-            log.info("PG 결제 요청: orderKey={}, amount={}", request.orderId(), request.amount());
-
-//            PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
-//                    request.orderId(),
-//                    request.cardType(),
-//                    request.cardNo(),
-//                    request.amount(),
-//                    request.callbackUrl()
-//            );
-
-//            PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(userId, pgRequest);
-
-            if (response != null && response.data() != null) {
-                log.info("PG 결제 요청 성공: transactionKey={}, status={}",
-                        response.data().transactionKey(),
-                        response.data().status());
-
-                PaymentResponse paymentResponse = new PaymentResponse(
-                        response.data().transactionKey(),
-                        response.data().status(),
-                        response.data().reason()
-                );
-                return paymentResponse;
-            } else {
-                log.error("PG 결제 요청 실패: 응답이 null입니다.");
-                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 결제 요청이 실패했습니다.");
+            if (errorCode.equals("Internal Server Error")) {
+                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템에 일시적인 오류가 발생했습니다.");
             }
-        } catch (Exception e) {
-            log.error("PG 결제 요청 중 오류 발생: {}", e.getMessage(), e);
-            throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 결제 요청 중 오류가 발생했습니다: " + e.getMessage());
         }
+
+        return new PaymentResponse(
+                response.data().transactionKey(),
+                response.data().status(),
+                response.data().reason()
+        );
     }
 
     public CompletableFuture<PaymentResponse> requestPaymentRetryFallback(
             PaymentRequest request, String userId, Throwable t) {
         log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, userId={}, error={}", request.orderId(), userId, t.getMessage());
-        
+
         PaymentResponse response = new PaymentResponse(
                 null,
                 "PENDING",

From 4be40c91aa017bad350ca93fafa05f317070ba03 Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Wed, 10 Dec 2025 02:33:29 +0900
Subject: [PATCH 23/25] =?UTF-8?q?refactor:=20PgPaymentClient=20=EB=B0=8F?=
 =?UTF-8?q?=20PaymentClient=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81,=20?=
 =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C=EC=A7=81=20?=
 =?UTF-8?q?=EA=B0=9C=EC=84=A0?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../loopers/domain/payment/PaymentClient.java |  11 +-
 .../gateway/PgPaymentClient.java              |  47 ++---
 .../src/main/resources/application.yml        |  10 +-
 .../PgPaymentClientResilienceTest.java        | 173 +++++++-----------
 4 files changed, 93 insertions(+), 148 deletions(-)

diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
index 45bb45f3e..c78040252 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
@@ -21,8 +21,13 @@ record PaymentResponse(
         public boolean isSuccess() {
             return "SUCCESS".equalsIgnoreCase(status);
         }
-    }
-}
-
 
+        public static PaymentResponse createPaymentResponse(String transactionKey, String status, String reason) {
+            return new PaymentResponse(transactionKey, status, reason);
+        }
 
+        public static PaymentResponse createFailResponse(String reason) {
+            return new PaymentResponse(null, "FAILED", reason);
+        }
+    }
+}
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
index 70632c54e..5879ebf1a 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
@@ -1,12 +1,8 @@
 package com.loopers.infrastructure.gateway;
 
 import com.loopers.domain.payment.PaymentClient;
-import com.loopers.support.error.CoreException;
-import com.loopers.support.error.ErrorType;
 import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
 import io.github.resilience4j.retry.annotation.Retry;
-import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
-import java.util.concurrent.CompletableFuture;
 import lombok.RequiredArgsConstructor;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Value;
@@ -25,7 +21,6 @@ public class PgPaymentClient implements PaymentClient {
     @Override
     @Retry(name = "pgRetry", fallbackMethod = "requestPaymentRetryFallback")
     @CircuitBreaker(name = "pgPayment", fallbackMethod = "requestPaymentFallback")
-    @TimeLimiter(name = "pgPayment")
     public PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest request) {
 
         PgPaymentDto.PgPaymentRequest pgRequest = new PgPaymentDto.PgPaymentRequest(
@@ -38,40 +33,32 @@ public PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest
 
         PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(request.userId(), pgRequest);
 
-        String errorCode = response.meta().errorCode();
-        if (errorCode != null && !errorCode.isBlank()) {
-            if (errorCode.equals("Bad Request")) {
-                throw new CoreException(ErrorType.BAD_REQUEST, response.meta().message());
-            }
+//        String errorCode = response.meta().errorCode();
+//        if (errorCode != null && !errorCode.isBlank()) {
+//            if (errorCode.equals("Bad Request")) {
+//                throw new CoreException(ErrorType.BAD_REQUEST, response.meta().message());
+//            }
+//
+//            if (errorCode.equals("Internal Server Error")) {
+//                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템에 일시적인 오류가 발생했습니다.");
+//            }
+//        }
 
-            if (errorCode.equals("Internal Server Error")) {
-                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템에 일시적인 오류가 발생했습니다.");
-            }
-        }
-
-        return new PaymentResponse(
+        return PaymentClient.PaymentResponse.createPaymentResponse(
                 response.data().transactionKey(),
                 response.data().status(),
                 response.data().reason()
         );
     }
 
-    public CompletableFuture<PaymentResponse> requestPaymentRetryFallback(
-            PaymentRequest request, String userId, Throwable t) {
-        log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, userId={}, error={}", request.orderId(), userId, t.getMessage());
-
-        PaymentResponse response = new PaymentResponse(
-                null,
-                "PENDING",
-                "결제 요청이 재시도 후에도 실패했습니다. 잠시 후 다시 시도해주세요."
-        );
-        return CompletableFuture.completedFuture(response);
+    public PaymentClient.PaymentResponse requestPaymentRetryFallback(PaymentClient.PaymentRequest request, Throwable t) {
+        log.warn("PG 결제 요청 Retry Fallback 호출: orderKey={}, error={}", request.orderId(), t.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("결제 요청이 재시도 후에도 실패했습니다.");
     }
 
-    public CompletableFuture<PaymentResponse> requestPaymentFallback(
-            PaymentRequest request, String userId, Exception e) {
-        log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, userId={}, error={}", request.orderId(), userId, e.getMessage());
-        throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템이 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요.");
+    public PaymentClient.PaymentResponse requestPaymentFallback(PaymentClient.PaymentRequest request, Exception e) {
+        log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, error={}", request.orderId(), e.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("PG 시스템이 일시적으로 사용할 수 없습니다.");
     }
 }
 
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index b55d1cb08..d1564c9dd 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -53,14 +53,8 @@ resilience4j:
         failureRateThreshold: 60
         slidingWindowSize: 20
         minimumNumberOfCalls: 10
-  timelimiter:
-    configs:
-      default:
-        timeoutDuration: 3s
-    instances:
-      pgPayment:
-        baseConfig: default
-        timeoutDuration: 3s
+        ignoreExceptions:
+          - com.loopers.support.error.CoreException
   retry:
     instances:
       pgRetry:
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
index 464ab4d97..99eeddd5f 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
@@ -3,7 +3,6 @@
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.Mockito.doAnswer;
 import static org.mockito.Mockito.doReturn;
 import static org.mockito.Mockito.doThrow;
 import static org.mockito.Mockito.times;
@@ -16,8 +15,7 @@
 import com.loopers.infrastructure.gateway.PgPaymentFeignClient;
 import io.github.resilience4j.circuitbreaker.CircuitBreaker;
 import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
-import java.util.concurrent.CompletableFuture;
-import java.util.concurrent.TimeUnit;
+import java.net.SocketTimeoutException;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Nested;
@@ -50,14 +48,14 @@ void setUp() {
             circuitBreaker.reset();
         }
 
+        userId = "user123";
         paymentRequest = new PaymentRequest(
                 "order12345",
                 "SAMSUNG",
                 "1234-5678-9012-3456",
-                10000L,
-                "http://localhost:8080/api/v1/payments/callback"
+                10000,
+                userId
         );
-        userId = "user123";
     }
 
     @DisplayName("Circuit Breaker 동작 테스트")
@@ -74,8 +72,7 @@ void circuitBreakerStaysClosed_whenSuccess() throws Exception {
                     .requestPayment(anyString(), any(PgPaymentDto.PgPaymentRequest.class));
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isEqualTo("txn-key-123");
@@ -106,7 +103,7 @@ void circuitBreakerOpens_whenFailureRateExceeds60Percent() throws Exception {
             // when
             for (int i = 0; i < 10; i++) {
                 try {
-                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                    pgPaymentClient.requestPayment(paymentRequest);
                 } catch (Exception e) {
                 }
             }
@@ -114,12 +111,11 @@ void circuitBreakerOpens_whenFailureRateExceeds60Percent() throws Exception {
             // then
             verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
 
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-            
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
+
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
         }
@@ -146,7 +142,7 @@ void circuitBreakerTransitionsToClosed_whenHalfOpenSucceeds() throws Exception {
 
             for (int i = 0; i < 10; i++) {
                 try {
-                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                    pgPaymentClient.requestPayment(paymentRequest);
                 } catch (Exception e) {
                 }
             }
@@ -161,8 +157,7 @@ void circuitBreakerTransitionsToClosed_whenHalfOpenSucceeds() throws Exception {
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isEqualTo("txn-key-123");
@@ -193,7 +188,7 @@ void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
 
             for (int i = 0; i < 10; i++) {
                 try {
-                    pgPaymentClient.requestPayment(paymentRequest, userId).get(5, TimeUnit.SECONDS);
+                    pgPaymentClient.requestPayment(paymentRequest);
                 } catch (Exception e) {
                 }
             }
@@ -208,13 +203,12 @@ void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
         }
@@ -225,8 +219,8 @@ void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
     class RetryTest {
 
         @Test
-        @DisplayName("재시도 가능한 예외 발생 시 최대 2번 재시도한다 (총 3번 시도)")
-        void retriesUpTo2Times_whenRetryableException() throws Exception {
+        @DisplayName("재시도 가능한 예외 발생 시 CircuitBreaker Fallback이 호출된다")
+        void callsCircuitBreakerFallback_whenRetryableException() throws Exception {
             // given
             RuntimeException runtimeException = new RuntimeException("Internal Server Error");
 
@@ -235,82 +229,50 @@ void retriesUpTo2Times_whenRetryableException() throws Exception {
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
 
         @Test
-        @DisplayName("모든 재시도 실패 시 Retry Fallback이 호출된다")
-        void callsRetryFallback_whenAllRetriesFail() throws Exception {
+        @DisplayName("재시도 불가능한 예외(CoreException) 발생 시 재시도하지 않는다")
+        void throwsCoreException_whenNonRetryableException() {
             // given
-            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
-            doThrow(runtimeException)
+            PgPaymentDto.PgPaymentResponse badRequestResponse = createErrorResponse("Bad Request", "주문 ID는 6자리 이상 문자열이어야 합니다.");
+            doReturn(badRequestResponse)
                     .when(pgPaymentFeignClient)
                     .requestPayment(anyString(), any());
 
-            // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-
-            // then
-            assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
-
-            verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
-        }
-
-        @Test
-        @DisplayName("재시도 불가능한 예외(CoreException) 발생 시 재시도하지 않지만 Fallback이 호출된다")
-        void doesNotRetry_whenNonRetryableException() throws Exception {
-            // given
-            doReturn(null)
-                    .when(pgPaymentFeignClient)
-                    .requestPayment(anyString(), any());
-
-            // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-
-            // then
-            assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
-
+            // when & then
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
     }
 
-    @DisplayName("Time Limiter 동작 테스트")
+    @DisplayName("Feign 타임아웃 동작 테스트")
     @Nested
-    class TimeLimiterTest {
+    class FeignTimeoutTest {
 
         @Test
-        @DisplayName("3초를 초과하는 응답은 타임아웃된다")
-        void timesOut_whenResponseExceeds3Seconds() throws Exception {
+        @DisplayName("2초를 초과하는 응답은 Feign 타임아웃으로 인해 실패하고 CircuitBreaker Fallback이 호출된다")
+        void callsCircuitBreakerFallback_whenResponseExceeds2Seconds() throws Exception {
             // given
-            PgPaymentDto.PgPaymentResponse successResponse = createSuccessResponse("txn-key-123");
-            doAnswer(invocation -> {
-                        Thread.sleep(4000);
-                        return successResponse;
-                    })
+            SocketTimeoutException cause = new SocketTimeoutException("Read timed out");
+            RuntimeException timeoutException = new RuntimeException("Feign read timeout", cause);
+            doThrow(timeoutException)
                     .when(pgPaymentFeignClient)
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
+
             // then
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-            assertThat(response.transactionKey()).isEqualTo("txn-key-123");
-            assertThat(response.status()).isEqualTo("PENDING");
+            assertThat(response.transactionKey()).isNull();
+            assertThat(response.status()).isEqualTo("FAILED");
         }
     }
 
@@ -319,21 +281,16 @@ void timesOut_whenResponseExceeds3Seconds() throws Exception {
     class ExternalSystemResponseTest {
 
         @Test
-        @DisplayName("400 Bad Request 발생 시 재시도하지 않고 Fallback이 호출된다")
-        void doesNotRetry_when400BadRequest() throws Exception {
+        @DisplayName("400 Bad Request 발생 시 재시도하지 않는다")
+        void throwsCoreException_when400BadRequest() {
             // given
-            doReturn(null)
+            PgPaymentDto.PgPaymentResponse badRequestResponse = createErrorResponse("Bad Request", "주문 ID는 6자리 이상 문자열이어야 합니다.");
+            doReturn(badRequestResponse)
                     .when(pgPaymentFeignClient)
                     .requestPayment(anyString(), any());
 
-            // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-
-            // then
-            assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            // when & then
+            pgPaymentClient.requestPayment(paymentRequest);
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
@@ -348,8 +305,7 @@ void succeeds_when200OK() throws Exception {
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isEqualTo("txn-key-123");
@@ -358,30 +314,23 @@ void succeeds_when200OK() throws Exception {
         }
 
         @Test
-        @DisplayName("500 Internal Server Error 발생 시 재시도한다")
-        void retries_when500InternalServerError() throws Exception {
+        @DisplayName("500 Internal Server Error 발생 시 재시도하지 않는다.")
+        void throwsCoreException_when500InternalServerError() {
             // given
-            RuntimeException runtimeException = new RuntimeException("Internal Server Error");
-
-            doThrow(runtimeException)
+            PgPaymentDto.PgPaymentResponse internalServerErrorResponse = createErrorResponse("Internal Server Error", "PG 시스템에 일시적인 오류가 발생했습니다.");
+            doReturn(internalServerErrorResponse)
                     .when(pgPaymentFeignClient)
                     .requestPayment(anyString(), any());
 
-            // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
-
-            // then
-            assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            // when & then
+            pgPaymentClient.requestPayment(paymentRequest);
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
 
         @Test
-        @DisplayName("네트워크 연결 실패(RuntimeException) 발생 시 재시도한다")
-        void retries_whenRuntimeException() throws Exception {
+        @DisplayName("네트워크 연결 실패(RuntimeException) 발생 시 CircuitBreaker Fallback이 호출된다")
+        void callsCircuitBreakerFallback_whenRuntimeException() throws Exception {
             // given
             RuntimeException runtimeException = new RuntimeException("Connection refused");
 
@@ -390,13 +339,12 @@ void retries_whenRuntimeException() throws Exception {
                     .requestPayment(anyString(), any());
 
             // when
-            CompletableFuture<PaymentResponse> future = pgPaymentClient.requestPayment(paymentRequest, userId);
-            PaymentResponse response = future.get(5, TimeUnit.SECONDS);
+            PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("PENDING");
-            assertThat(response.reason()).contains("재시도 후에도 실패");
+            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
@@ -412,4 +360,15 @@ private PgPaymentDto.PgPaymentResponse createSuccessResponse(String transactionK
                 )
         );
     }
+
+    private PgPaymentDto.PgPaymentResponse createErrorResponse(String errorCode, String errorMessage) {
+        return new PgPaymentDto.PgPaymentResponse(
+                new PgPaymentDto.PgPaymentResponse.Meta("FAIL", errorCode, errorMessage),
+                new PgPaymentDto.PgPaymentResponse.TransactionData(
+                        null,
+                        null,
+                        null
+                )
+        );
+    }
 }

From 35fb3cd7e16e5b679ae12b89a1d7ba683acfad1f Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Wed, 10 Dec 2025 02:39:03 +0900
Subject: [PATCH 24/25] =?UTF-8?q?test:=20PgPaymentClientResilienceTest=20?=
 =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EB=B6=88=EA=B0=80=EB=8A=A5?=
 =?UTF-8?q?=ED=95=9C=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=ED=85=8C?=
 =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../payment/PgPaymentClientResilienceTest.java        | 11 ++++++++---
 1 file changed, 8 insertions(+), 3 deletions(-)

diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
index 99eeddd5f..d38419a4b 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
@@ -13,6 +13,8 @@
 import com.loopers.infrastructure.gateway.PgPaymentClient;
 import com.loopers.infrastructure.gateway.PgPaymentDto;
 import com.loopers.infrastructure.gateway.PgPaymentFeignClient;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
 import io.github.resilience4j.circuitbreaker.CircuitBreaker;
 import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
 import java.net.SocketTimeoutException;
@@ -243,12 +245,15 @@ void callsCircuitBreakerFallback_whenRetryableException() throws Exception {
         @DisplayName("재시도 불가능한 예외(CoreException) 발생 시 재시도하지 않는다")
         void throwsCoreException_whenNonRetryableException() {
             // given
-            PgPaymentDto.PgPaymentResponse badRequestResponse = createErrorResponse("Bad Request", "주문 ID는 6자리 이상 문자열이어야 합니다.");
-            doReturn(badRequestResponse)
+            CoreException coreException = new CoreException(ErrorType.BAD_REQUEST, "Bad Request");
+            doThrow(coreException)
                     .when(pgPaymentFeignClient)
                     .requestPayment(anyString(), any());
 
-            // when & then
+            // when
+            pgPaymentClient.requestPayment(paymentRequest);
+
+            // then
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
         }
     }

From e94bbda48f3f6e34eabb0d7f3da715b617de62ce Mon Sep 17 00:00:00 2001
From: Kim yeonsu <yeonsu0329@kyonggi.ac.kr>
Date: Wed, 10 Dec 2025 16:48:57 +0900
Subject: [PATCH 25/25] =?UTF-8?q?feat:=20PG=20=EA=B2=B0=EC=A0=9C=20?=
 =?UTF-8?q?=EC=83=81=ED=83=9C=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EC=8A=A4?=
 =?UTF-8?q?=EC=BC=80=EC=A4=84=EB=9F=AC=20=EB=B0=8F=20=EC=BD=9C=EB=B0=B1=20?=
 =?UTF-8?q?=EB=AF=B8=EC=88=98=EC=8B=A0=20=EB=B3=B5=EA=B5=AC=20=EA=B8=B0?=
 =?UTF-8?q?=EB=8A=A5=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

---
 .../application/payment/PaymentFacade.java    | 13 ++++
 .../com/loopers/domain/payment/Payment.java   |  8 +++
 .../loopers/domain/payment/PaymentClient.java | 22 +++++-
 .../domain/payment/PaymentRepository.java     |  4 ++
 .../domain/payment/PaymentService.java        | 36 +++++++++-
 .../loopers/domain/user/UserRepository.java   |  1 +
 .../com/loopers/domain/user/UserService.java  |  5 +-
 .../gateway/PgPaymentClient.java              | 69 ++++++++++++++++---
 .../infrastructure/gateway/PgPaymentDto.java  | 23 +++++++
 .../gateway/PgPaymentFeignClient.java         | 14 ++++
 .../payment/PaymentJpaRepository.java         | 11 ++-
 .../payment/PaymentRepositoryImpl.java        |  8 +++
 .../user/UserRepositoryImpl.java              |  4 ++
 .../scheduler/PaymentStatusSyncScheduler.java | 54 +++++++++++++++
 .../PgPaymentClientResilienceTest.java        | 10 +--
 15 files changed, 262 insertions(+), 20 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/PaymentStatusSyncScheduler.java

diff --git a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
index cc8d0326b..5ada2c67d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/payment/PaymentFacade.java
@@ -68,6 +68,19 @@ public void handlePaymentCallback(PaymentV1Dto.PaymentCallbackRequest request) {
         }
     }
 
+    @Transactional
+    public void syncPaymentStatus(Payment payment) {
+        Order order = orderService.getOrderByOrderKey(payment.getOrderKey());
+        User user = userService.getUserById(order.getUserId());
+        Payment updatedPayment = paymentService.checkPaymentStatusFromPg(payment, user.getLoginId().getId());
+
+        if (updatedPayment.isCompleted()) {
+            handleSuccess(updatedPayment, order);
+        } else if (updatedPayment.isFailed()) {
+            handleFailure(updatedPayment, order, "PG 상태 확인 결과 결제 실패");
+        }
+    }
+
     private void handleSuccess(Payment payment, Order order) {
         orderService.payOrder(order);
         log.info("결제 완료 처리: orderId={}, transactionKey={}", order.getId(), payment.getTransactionKey());
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
index d034a6bfe..9dc737948 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/Payment.java
@@ -118,5 +118,13 @@ public boolean isCompleted() {
     public boolean isPending() {
         return this.status == PaymentStatus.PENDING;
     }
+
+    public boolean isFailed() {
+        return this.status == PaymentStatus.FAILED;
+    }
+
+    public boolean hasTransactionKey() {
+        return this.transactionKey != null && !this.transactionKey.isBlank();
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
index c78040252..2c7d8735b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentClient.java
@@ -4,6 +4,10 @@ public interface PaymentClient {
 
     PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest request);
 
+    PaymentClient.PaymentResponse getPaymentStatusByTransactionKey(PaymentClient.PaymentStatusRequest request);
+
+    PaymentClient.PaymentResponse getPaymentStatusByOrderKey(PaymentClient.PaymentStatusByOrderKeyRequest request);
+
     record PaymentRequest(
             String orderId,
             String cardType,
@@ -13,6 +17,18 @@ record PaymentRequest(
     ) {
     }
 
+    record PaymentStatusRequest(
+            String transactionKey,
+            String userId
+    ) {
+    }
+
+    record PaymentStatusByOrderKeyRequest(
+            String orderKey,
+            String userId
+    ) {
+    }
+
     record PaymentResponse(
             String transactionKey,
             String status,
@@ -22,12 +38,16 @@ public boolean isSuccess() {
             return "SUCCESS".equalsIgnoreCase(status);
         }
 
+        public boolean isFail() {
+            return "FAIL".equalsIgnoreCase(status);
+        }
+
         public static PaymentResponse createPaymentResponse(String transactionKey, String status, String reason) {
             return new PaymentResponse(transactionKey, status, reason);
         }
 
         public static PaymentResponse createFailResponse(String reason) {
-            return new PaymentResponse(null, "FAILED", reason);
+            return new PaymentResponse(null, "PENDING", reason);
         }
     }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
index 18db950db..08b0354f8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentRepository.java
@@ -1,10 +1,14 @@
 package com.loopers.domain.payment;
 
+import java.time.ZonedDateTime;
+import java.util.List;
 import java.util.Optional;
 
 public interface PaymentRepository {
     void savePayment(Payment payment);
 
     Optional<Payment> findByOrderKey(String orderKey);
+
+    List<Payment> getPendingPaymentsCreatedBefore(ZonedDateTime before);
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
index 4fad04fbe..62e9cb72d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/payment/PaymentService.java
@@ -2,6 +2,8 @@
 
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import java.time.ZonedDateTime;
+import java.util.List;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Service;
 
@@ -50,7 +52,7 @@ public Payment requestPaymentToPg(Payment payment, String loginId) {
         if (paymentResponse.isSuccess()) {
             payment.updateStatus(PaymentStatus.COMPLETED);
             payment.updateTransactionKey(paymentResponse.transactionKey());
-        } else {
+        } else if (paymentResponse.isFail()) {
             payment.updateStatus(PaymentStatus.FAILED);
         }
 
@@ -67,5 +69,37 @@ public Payment getPendingPaymentByOrderKey(String orderKey) {
 
         return payment;
     }
+
+    public List<Payment> getPendingPaymentsCreatedBefore(ZonedDateTime before) {
+        return paymentRepository.getPendingPaymentsCreatedBefore(before);
+    }
+
+    public Payment checkPaymentStatusFromPg(Payment payment, String loginId) {
+        PaymentClient.PaymentResponse paymentResponse;
+
+        if (payment.hasTransactionKey()) {
+            PaymentClient.PaymentStatusRequest request = new PaymentClient.PaymentStatusRequest(
+                    payment.getTransactionKey(),
+                    loginId
+            );
+            paymentResponse = paymentClient.getPaymentStatusByTransactionKey(request);
+        } else {
+            PaymentClient.PaymentStatusByOrderKeyRequest request = new PaymentClient.PaymentStatusByOrderKeyRequest(
+                    payment.getOrderKey(),
+                    loginId
+            );
+            paymentResponse = paymentClient.getPaymentStatusByOrderKey(request);
+
+        }
+
+        if (paymentResponse.isSuccess()) {
+            payment.updateStatus(PaymentStatus.COMPLETED);
+            payment.updateTransactionKey(paymentResponse.transactionKey());
+        } else {
+            payment.updateStatus(PaymentStatus.FAILED);
+        }
+
+        return payment;
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
index 4ae239d01..d23f20acb 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserRepository.java
@@ -11,4 +11,5 @@ public interface UserRepository {
 
     Optional<User> findByLoginId(String loginId);
 
+    Optional<User> findById(Long id);
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
index a2668467f..41841bf4b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/user/UserService.java
@@ -33,10 +33,13 @@ public Optional<User> findUserByLoginId(String loginId) {
         return userRepository.findByLoginId(loginId);
     }
 
-    @Transactional(readOnly = true)
     public User getUserByLoginId(String loginId) {
         return userRepository.findByLoginId(loginId)
                 .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
     }
 
+    public User getUserById(Long userId) {
+        return userRepository.findById(userId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다."));
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
index 5879ebf1a..d50c5058b 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentClient.java
@@ -33,17 +33,6 @@ public PaymentClient.PaymentResponse requestPayment(PaymentClient.PaymentRequest
 
         PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.requestPayment(request.userId(), pgRequest);
 
-//        String errorCode = response.meta().errorCode();
-//        if (errorCode != null && !errorCode.isBlank()) {
-//            if (errorCode.equals("Bad Request")) {
-//                throw new CoreException(ErrorType.BAD_REQUEST, response.meta().message());
-//            }
-//
-//            if (errorCode.equals("Internal Server Error")) {
-//                throw new CoreException(ErrorType.INTERNAL_ERROR, "PG 시스템에 일시적인 오류가 발생했습니다.");
-//            }
-//        }
-
         return PaymentClient.PaymentResponse.createPaymentResponse(
                 response.data().transactionKey(),
                 response.data().status(),
@@ -60,5 +49,63 @@ public PaymentClient.PaymentResponse requestPaymentFallback(PaymentClient.Paymen
         log.error("PG 결제 요청 CircuitBreaker Fallback 호출: orderKey={}, error={}", request.orderId(), e.getMessage());
         return PaymentClient.PaymentResponse.createFailResponse("PG 시스템이 일시적으로 사용할 수 없습니다.");
     }
+
+    @Override
+    @Retry(name = "pgRetry", fallbackMethod = "getPaymentStatusRetryFallback")
+    @CircuitBreaker(name = "pgPayment", fallbackMethod = "getPaymentStatusFallback")
+    public PaymentClient.PaymentResponse getPaymentStatusByTransactionKey(PaymentClient.PaymentStatusRequest request) {
+        PgPaymentDto.PgPaymentResponse response = pgPaymentFeignClient.getPaymentStatus(
+                request.userId(),
+                request.transactionKey()
+        );
+
+        return PaymentClient.PaymentResponse.createPaymentResponse(
+                response.data().transactionKey(),
+                response.data().status(),
+                response.data().reason()
+        );
+    }
+
+    public PaymentClient.PaymentResponse getPaymentStatusRetryFallback(PaymentClient.PaymentStatusRequest request, Throwable t) {
+        log.warn("PG 결제 상태 조회 Retry Fallback 호출: transactionKey={}, error={}", request.transactionKey(), t.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("결제 상태 조회가 재시도 후에도 실패했습니다.");
+    }
+
+    public PaymentClient.PaymentResponse getPaymentStatusFallback(PaymentClient.PaymentStatusRequest request, Exception e) {
+        log.error("PG 결제 상태 조회 CircuitBreaker Fallback 호출: transactionKey={}, error={}", request.transactionKey(), e.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("PG 시스템이 일시적으로 사용할 수 없습니다.");
+    }
+
+    @Override
+    @Retry(name = "pgRetry", fallbackMethod = "getPaymentStatusByOrderKeyRetryFallback")
+    @CircuitBreaker(name = "pgPayment", fallbackMethod = "getPaymentStatusByOrderKeyFallback")
+    public PaymentClient.PaymentResponse getPaymentStatusByOrderKey(PaymentClient.PaymentStatusByOrderKeyRequest request) {
+        PgPaymentDto.PgPaymentOrderResponse response = pgPaymentFeignClient.getPaymentStatusByOrderKey(
+                request.userId(),
+                request.orderKey()
+        );
+
+        if (response.hasTransactionKey()) {
+            return PaymentClient.PaymentResponse.createFailResponse("결제 내역을 찾을 수 없습니다.");
+        }
+
+        PgPaymentDto.PgPaymentOrderResponse.TransactionData latestTransaction = response.data().transactions().get(0);
+
+        return PaymentClient.PaymentResponse.createPaymentResponse(
+                latestTransaction.transactionKey(),
+                latestTransaction.status(),
+                latestTransaction.reason()
+        );
+    }
+
+    public PaymentClient.PaymentResponse getPaymentStatusByOrderKeyRetryFallback(PaymentClient.PaymentStatusByOrderKeyRequest request, Throwable t) {
+        log.warn("PG 결제 상태 조회(OrderKey) Retry Fallback 호출: orderKey={}, error={}", request.orderKey(), t.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("결제 상태 조회가 재시도 후에도 실패했습니다.");
+    }
+
+    public PaymentClient.PaymentResponse getPaymentStatusByOrderKeyFallback(PaymentClient.PaymentStatusByOrderKeyRequest request, Exception e) {
+        log.error("PG 결제 상태 조회(OrderKey) CircuitBreaker Fallback 호출: orderKey={}, error={}", request.orderKey(), e.getMessage());
+        return PaymentClient.PaymentResponse.createFailResponse("PG 시스템이 일시적으로 사용할 수 없습니다.");
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
index 5f37eeaef..06e450dba 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentDto.java
@@ -1,6 +1,7 @@
 package com.loopers.infrastructure.gateway;
 
 import com.fasterxml.jackson.annotation.JsonProperty;
+import java.util.List;
 
 public class PgPaymentDto {
 
@@ -31,6 +32,28 @@ public record TransactionData(
         ) {
         }
     }
+
+    public record PgPaymentOrderResponse(
+            @JsonProperty("meta") PgPaymentResponse.Meta meta,
+            @JsonProperty("data") OrderData data
+    ) {
+        public record OrderData(
+                @JsonProperty("orderId") String orderId,
+                @JsonProperty("transactions") List<TransactionData> transactions
+        ) {
+        }
+
+        public record TransactionData(
+                @JsonProperty("transactionKey") String transactionKey,
+                @JsonProperty("status") String status,
+                @JsonProperty("reason") String reason
+        ) {
+        }
+
+        public boolean hasTransactionKey() {
+            return data.transactions() != null && !data.transactions().isEmpty();
+        }
+    }
 }
 
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
index ad32de4fe..30aa4474d 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/gateway/PgPaymentFeignClient.java
@@ -1,6 +1,8 @@
 package com.loopers.infrastructure.gateway;
 
 import org.springframework.cloud.openfeign.FeignClient;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
 import org.springframework.web.bind.annotation.RequestHeader;
@@ -18,5 +20,17 @@ PgPaymentDto.PgPaymentResponse requestPayment(
             @RequestHeader("X-USER-ID") String userId,
             @RequestBody PgPaymentDto.PgPaymentRequest request
     );
+
+    @GetMapping("/{transactionKey}")
+    PgPaymentDto.PgPaymentResponse getPaymentStatus(
+            @RequestHeader("X-USER-ID") String userId,
+            @PathVariable("transactionKey") String transactionKey
+    );
+
+    @GetMapping("/order/{orderKey}")
+    PgPaymentDto.PgPaymentOrderResponse getPaymentStatusByOrderKey(
+            @RequestHeader("X-USER-ID") String userId,
+            @PathVariable("orderKey") String orderKey
+    );
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
index 696626484..f0ae6c9f1 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentJpaRepository.java
@@ -1,12 +1,21 @@
 package com.loopers.infrastructure.payment;
 
 import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentStatus;
+import java.time.ZonedDateTime;
+import java.util.List;
 import java.util.Optional;
 import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
 
 public interface PaymentJpaRepository extends JpaRepository<Payment, Long> {
     Optional<Payment> findByOrderKey(String orderKey);
 
-    Optional<Payment> findByTransactionKey(String transactionKey);
+    @Query("SELECT p FROM Payment p WHERE p.status = :status AND p.createdAt < :before AND p.deletedAt IS NULL")
+    List<Payment> findPendingPaymentsCreatedBefore(
+            @Param("status") PaymentStatus status,
+            @Param("before") ZonedDateTime before
+    );
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
index d720008eb..2b9801823 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/payment/PaymentRepositoryImpl.java
@@ -2,6 +2,9 @@
 
 import com.loopers.domain.payment.Payment;
 import com.loopers.domain.payment.PaymentRepository;
+import com.loopers.domain.payment.PaymentStatus;
+import java.time.ZonedDateTime;
+import java.util.List;
 import java.util.Optional;
 import lombok.RequiredArgsConstructor;
 import org.springframework.stereotype.Repository;
@@ -21,5 +24,10 @@ public void savePayment(Payment payment) {
     public Optional<Payment> findByOrderKey(String orderKey) {
         return paymentJpaRepository.findByOrderKey(orderKey);
     }
+
+    @Override
+    public List<Payment> getPendingPaymentsCreatedBefore(ZonedDateTime before) {
+        return paymentJpaRepository.findPendingPaymentsCreatedBefore(PaymentStatus.PENDING, before);
+    }
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
index e7b45f544..7e7c956f8 100644
--- a/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/user/UserRepositoryImpl.java
@@ -32,4 +32,8 @@ public Optional<User> findByLoginId(String loginId) {
         return userJpaRepository.findByLoginId(loginId);
     }
 
+    @Override
+    public Optional<User> findById(Long id) {
+        return userJpaRepository.findById(id);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/PaymentStatusSyncScheduler.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/PaymentStatusSyncScheduler.java
new file mode 100644
index 000000000..fa591f3ce
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/scheduler/PaymentStatusSyncScheduler.java
@@ -0,0 +1,54 @@
+package com.loopers.interfaces.scheduler;
+
+import com.loopers.application.payment.PaymentFacade;
+import com.loopers.domain.payment.Payment;
+import com.loopers.domain.payment.PaymentService;
+import java.time.ZonedDateTime;
+import java.util.List;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentStatusSyncScheduler {
+
+    private final PaymentFacade paymentFacade;
+    private final PaymentService paymentService;
+
+    @Scheduled(cron = "0 */5 * * * *")
+    public void syncPendingPayments() {
+        log.info("PENDING 상태 Payment 동기화 스케줄러 시작");
+
+        ZonedDateTime thirtyMinutesAgo = ZonedDateTime.now().minusMinutes(30);
+        List<Payment> pendingPayments = paymentService.getPendingPaymentsCreatedBefore(thirtyMinutesAgo);
+
+        int successCount = 0;
+        int failureCount = 0;
+
+        for (Payment payment : pendingPayments) {
+            if (syncPayment(payment)) {
+                successCount++;
+            } else {
+                failureCount++;
+            }
+        }
+
+        log.info("PENDING 상태 Payment 동기화 완료: 전체={}, 성공={}, 실패={}",
+                pendingPayments.size(), successCount, failureCount);
+    }
+
+    private boolean syncPayment(Payment payment) {
+        try {
+            paymentFacade.syncPaymentStatus(payment);
+            return true;
+        } catch (Exception e) {
+            log.error("Payment 상태 동기화 실패: orderKey={}, transactionKey={}, error={}",
+                    payment.getOrderKey(), payment.getTransactionKey(), e.getMessage(), e);
+            return false;
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
index d38419a4b..0d34785ac 100644
--- a/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/payment/PgPaymentClientResilienceTest.java
@@ -116,7 +116,7 @@ void circuitBreakerOpens_whenFailureRateExceeds60Percent() throws Exception {
             PaymentResponse response = pgPaymentClient.requestPayment(paymentRequest);
 
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.status()).isEqualTo("PENDING");
             assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(10)).requestPayment(anyString(), any());
@@ -209,7 +209,7 @@ void circuitBreakerTransitionsToOpen_whenHalfOpenFails() throws Exception {
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.status()).isEqualTo("PENDING");
             assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(11)).requestPayment(anyString(), any());
@@ -235,7 +235,7 @@ void callsCircuitBreakerFallback_whenRetryableException() throws Exception {
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.status()).isEqualTo("PENDING");
             assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
@@ -277,7 +277,7 @@ void callsCircuitBreakerFallback_whenResponseExceeds2Seconds() throws Exception
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.status()).isEqualTo("PENDING");
         }
     }
 
@@ -348,7 +348,7 @@ void callsCircuitBreakerFallback_whenRuntimeException() throws Exception {
 
             // then
             assertThat(response.transactionKey()).isNull();
-            assertThat(response.status()).isEqualTo("FAILED");
+            assertThat(response.status()).isEqualTo("PENDING");
             assertThat(response.reason()).isEqualTo("PG 시스템이 일시적으로 사용할 수 없습니다.");
 
             verify(pgPaymentFeignClient, times(1)).requestPayment(anyString(), any());
