From 8557d647ab50fbf990ceac919a3ac33eb447f456 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 5 Dec 2025 05:19:52 +0900
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20PG=20=EB=AA=A8=EB=93=88=20=EC=B6=94?=
 =?UTF-8?q?=EA=B0=80=20(#24)?=
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 ++++++++++
 http/pg-simulator/payments.http               |  20 +++
 settings.gradle.kts                           |   1 +
 32 files changed, 1063 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
 create mode 100644 http/pg-simulator/payments.http

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/http/pg-simulator/payments.http b/http/pg-simulator/payments.http
new file mode 100644
index 000000000..096dd2f0a
--- /dev/null
+++ b/http/pg-simulator/payments.http
@@ -0,0 +1,20 @@
+### 결제 요청
+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
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 178a328ffe38b5034bf1df166ab2f9b2571705c0 Mon Sep 17 00:00:00 2001
From: minor7295 <44902090+minor7295@users.noreply.github.com>
Date: Fri, 5 Dec 2025 06:47:59 +0900
Subject: [PATCH 2/2] Feature/pg client (#27)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* test: PG 호출 테스트 코드 추가

* feat: PG 호출 로직 구현

* test: PG CircuitBreaker 테스트 코드 추가

* feat: CircuitBreaker 로직 구현
---
 apps/commerce-api/build.gradle.kts            |  12 +
 .../com/loopers/CommerceApiApplication.java   |   2 +
 .../purchasing/PaymentFailureHandler.java     | 107 +++
 .../purchasing/PaymentRecoveryScheduler.java  | 115 +++
 .../purchasing/PaymentRecoveryService.java    |  66 ++
 .../purchasing/PaymentRequest.java            |  20 +
 .../purchasing/PaymentRequestBuilder.java     | 167 ++++
 .../purchasing/PurchasingFacade.java          | 413 ++++++++--
 .../config/Resilience4jRetryConfig.java       | 129 ++++
 .../order/OrderCancellationService.java       | 106 +++
 .../loopers/domain/order/OrderRepository.java |   8 +
 .../domain/order/OrderStatusUpdater.java      | 100 +++
 .../order/PaymentFailureClassifier.java       |  74 ++
 .../domain/order/PaymentFailureType.java      |  25 +
 .../loopers/domain/order/PaymentResult.java   |  55 ++
 .../loopers/domain/user/UserRepository.java   |   8 +
 .../order/OrderJpaRepository.java             |   2 +
 .../order/OrderRepositoryImpl.java            |   5 +
 .../paymentgateway/DelayProvider.java         |  24 +
 .../paymentgateway/PaymentGatewayAdapter.java | 136 ++++
 .../paymentgateway/PaymentGatewayClient.java  |  85 +++
 .../paymentgateway/PaymentGatewayDto.java     | 106 +++
 .../paymentgateway/PaymentGatewayMetrics.java |  86 +++
 .../PaymentGatewaySchedulerClient.java        |  70 ++
 .../paymentgateway/ThreadDelayProvider.java   |  21 +
 .../user/UserRepositoryImpl.java              |   8 +
 .../loopers/interfaces/api/ApiResponse.java   |   4 +
 .../purchasing/PurchasingV1Controller.java    |  40 +-
 .../api/purchasing/PurchasingV1Dto.java       |  14 +-
 .../src/main/resources/application.yml        | 102 +++
 .../PurchasingFacadeCircuitBreakerTest.java   | 711 ++++++++++++++++++
 .../PurchasingFacadeConcurrencyTest.java      |  10 +-
 .../PurchasingFacadePaymentCallbackTest.java  | 307 ++++++++
 .../PurchasingFacadePaymentGatewayTest.java   | 280 +++++++
 .../purchasing/PurchasingFacadeTest.java      |  84 ++-
 .../PaymentGatewayClientTest.java             | 277 +++++++
 .../api/PurchasingV1ApiE2ETest.java           | 535 +++++++++++++
 .../testutil/CircuitBreakerTestUtil.java      | 152 ++++
 38 files changed, 4380 insertions(+), 86 deletions(-)
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java
 create mode 100644 apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
 create mode 100644 apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java

diff --git a/apps/commerce-api/build.gradle.kts b/apps/commerce-api/build.gradle.kts
index 969c6a1f0..83be16c09 100644
--- a/apps/commerce-api/build.gradle.kts
+++ b/apps/commerce-api/build.gradle.kts
@@ -10,6 +10,18 @@ dependencies {
     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"]}")
+    
+    // feign client
+    implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
+    
+    // resilience4j
+    implementation("io.github.resilience4j:resilience4j-spring-boot3")
+    implementation("io.github.resilience4j:resilience4j-core") // IntervalFunction을 위한 core 모듈
+    implementation("io.github.resilience4j:resilience4j-circuitbreaker")
+    implementation("io.github.resilience4j:resilience4j-retry")
+    implementation("io.github.resilience4j:resilience4j-timelimiter")
+    implementation("io.github.resilience4j:resilience4j-bulkhead") // Bulkheads 패턴 구현
+    implementation("org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j")
 
     // batch
     implementation("org.springframework.boot:spring-boot-starter-batch")
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 0b4b1cde4..659d8ccdb 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,14 @@
 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;
 
 @ConfigurationPropertiesScan
 @SpringBootApplication
 @EnableScheduling
+@EnableFeignClients
 public class CommerceApiApplication {
 
     @PostConstruct
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java
new file mode 100644
index 000000000..52f240e13
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentFailureHandler.java
@@ -0,0 +1,107 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.order.OrderCancellationService;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 결제 실패 처리 서비스.
+ * <p>
+ * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다.
+ * </p>
+ * <p>
+ * <b>트랜잭션 전략:</b>
+ * <ul>
+ *   <li>REQUIRES_NEW: 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리</li>
+ *   <li>결제 실패 처리 중 오류가 발생해도 기존 주문 생성 트랜잭션에 영향을 주지 않음</li>
+ *   <li>Self-invocation 문제 해결: 별도 서비스로 분리하여 Spring AOP 프록시가 정상적으로 적용되도록 함</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>주의사항:</b>
+ * <ul>
+ *   <li>주문이 이미 취소되었거나 존재하지 않는 경우 로그만 기록합니다.</li>
+ *   <li>결제 실패 처리 중 오류 발생 시에도 로그만 기록합니다.</li>
+ * </ul>
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentFailureHandler {
+    
+    private final UserRepository userRepository;
+    private final OrderRepository orderRepository;
+    private final OrderCancellationService orderCancellationService;
+    
+    /**
+     * 결제 실패 시 주문 취소 및 리소스 원복을 처리합니다.
+     * <p>
+     * 결제 요청이 실패한 경우, 이미 생성된 주문을 취소하고
+     * 차감된 포인트를 환불하며 재고를 원복합니다.
+     * </p>
+     * <p>
+     * <b>처리 내용:</b>
+     * <ul>
+     *   <li>주문 상태를 CANCELED로 변경</li>
+     *   <li>차감된 포인트 환불</li>
+     *   <li>차감된 재고 원복</li>
+     * </ul>
+     * </p>
+     *
+     * @param userId 사용자 ID (로그인 ID)
+     * @param orderId 주문 ID
+     * @param errorCode 오류 코드
+     * @param errorMessage 오류 메시지
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public void handle(String userId, Long orderId, String errorCode, String errorMessage) {
+        try {
+            // 사용자 조회
+            User user = userRepository.findByUserId(userId);
+            
+            if (user == null) {
+                log.warn("결제 실패 처리 시 사용자를 찾을 수 없습니다. (userId: {}, orderId: {})", userId, orderId);
+                return;
+            }
+            
+            // 주문 조회
+            Order order = orderRepository.findById(orderId)
+                .orElse(null);
+            
+            if (order == null) {
+                log.warn("결제 실패 처리 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
+                return;
+            }
+            
+            // 이미 취소된 주문인 경우 처리하지 않음
+            if (order.getStatus() == OrderStatus.CANCELED) {
+                log.info("이미 취소된 주문입니다. 결제 실패 처리를 건너뜁니다. (orderId: {})", orderId);
+                return;
+            }
+            
+            // 주문 취소 및 리소스 원복
+            orderCancellationService.cancel(order, user);
+            
+            log.info("결제 실패로 인한 주문 취소 완료. (orderId: {}, errorCode: {}, errorMessage: {})",
+                orderId, errorCode, errorMessage);
+        } catch (Exception e) {
+            // 결제 실패 처리 중 오류 발생 시에도 로그만 기록
+            // 이미 주문은 생성되어 있으므로, 나중에 수동으로 처리할 수 있도록 로그 기록
+            log.error("결제 실패 처리 중 오류 발생. (orderId: {}, errorCode: {})",
+                orderId, errorCode, e);
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java
new file mode 100644
index 000000000..6502a9cce
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryScheduler.java
@@ -0,0 +1,115 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.infrastructure.user.UserJpaRepository;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+
+/**
+ * 결제 상태 복구 스케줄러.
+ * <p>
+ * 콜백이 오지 않은 PENDING 상태의 주문들을 주기적으로 조회하여
+ * PG 시스템의 결제 상태 확인 API를 통해 상태를 복구합니다.
+ * </p>
+ * <p>
+ * <b>동작 원리:</b>
+ * <ol>
+ *   <li>주기적으로 실행 (기본: 1분마다)</li>
+ *   <li>PENDING 상태인 주문들을 조회</li>
+ *   <li>각 주문에 대해 PG 결제 상태 확인 API 호출</li>
+ *   <li>결제 상태에 따라 주문 상태 업데이트</li>
+ * </ol>
+ * </p>
+ * <p>
+ * <b>설계 근거:</b>
+ * <ul>
+ *   <li><b>주기적 복구:</b> 콜백이 오지 않아도 자동으로 상태 복구</li>
+ *   <li><b>Eventually Consistent:</b> 약간의 지연 허용 가능</li>
+ *   <li><b>안전한 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
+ *   <li><b>성능 고려:</b> 배치로 처리하여 PG 시스템 부하 최소화</li>
+ * </ul>
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@RequiredArgsConstructor
+@Component
+public class PaymentRecoveryScheduler {
+
+    private final OrderRepository orderRepository;
+    private final UserJpaRepository userJpaRepository;
+    private final PurchasingFacade purchasingFacade;
+
+    /**
+     * PENDING 상태인 주문들의 결제 상태를 복구합니다.
+     * <p>
+     * 1분마다 실행되어 PENDING 상태인 주문들을 조회하고,
+     * 각 주문에 대해 PG 결제 상태 확인 API를 호출하여 상태를 복구합니다.
+     * </p>
+     * <p>
+     * <b>처리 전략:</b>
+     * <ul>
+     *   <li><b>배치 처리:</b> 한 번에 여러 주문 처리</li>
+     *   <li><b>독립적 처리:</b> 각 주문별로 독립적으로 처리하여 실패 시에도 다른 주문에 영향 없음</li>
+     *   <li><b>안전한 예외 처리:</b> 개별 주문 처리 실패 시에도 계속 진행</li>
+     * </ul>
+     * </p>
+     */
+    @Scheduled(fixedDelay = 60000) // 1분마다 실행
+    public void recoverPendingOrders() {
+        try {
+            log.debug("결제 상태 복구 스케줄러 시작");
+
+            // PENDING 상태인 주문들 조회
+            List<Order> pendingOrders = orderRepository.findAllByStatus(OrderStatus.PENDING);
+
+            if (pendingOrders.isEmpty()) {
+                log.debug("복구할 PENDING 상태 주문이 없습니다.");
+                return;
+            }
+
+            log.info("PENDING 상태 주문 {}건에 대한 결제 상태 복구 시작", pendingOrders.size());
+
+            int successCount = 0;
+            int failureCount = 0;
+
+            // 각 주문에 대해 결제 상태 확인 및 복구
+            for (Order order : pendingOrders) {
+                try {
+                    // Order의 userId는 User의 id (Long)이므로 User를 조회하여 userId (String)를 가져옴
+                    var userOptional = userJpaRepository.findById(order.getUserId());
+                    if (userOptional.isEmpty()) {
+                        log.warn("주문의 사용자를 찾을 수 없습니다. 복구를 건너뜁니다. (orderId: {}, userId: {})",
+                            order.getId(), order.getUserId());
+                        failureCount++;
+                        continue;
+                    }
+
+                    String userId = userOptional.get().getUserId();
+                    
+                    // 결제 상태 확인 및 복구
+                    purchasingFacade.recoverOrderStatusByPaymentCheck(userId, order.getId());
+                    successCount++;
+                } catch (Exception e) {
+                    // 개별 주문 처리 실패 시에도 계속 진행
+                    log.error("주문 상태 복구 중 오류 발생. (orderId: {})", order.getId(), e);
+                    failureCount++;
+                }
+            }
+
+            log.info("결제 상태 복구 완료. 성공: {}건, 실패: {}건", successCount, failureCount);
+
+        } catch (Exception e) {
+            log.error("결제 상태 복구 스케줄러 실행 중 오류 발생", e);
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java
new file mode 100644
index 000000000..6c6ff0646
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRecoveryService.java
@@ -0,0 +1,66 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.order.OrderStatusUpdater;
+import com.loopers.infrastructure.paymentgateway.DelayProvider;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * 결제 복구 서비스.
+ * <p>
+ * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentRecoveryService {
+    
+    private final PaymentGatewayAdapter paymentGatewayAdapter;
+    private final OrderStatusUpdater orderStatusUpdater;
+    private final DelayProvider delayProvider;
+    
+    /**
+     * 타임아웃 발생 후 결제 상태를 확인하여 주문 상태를 복구합니다.
+     * <p>
+     * 타임아웃은 요청이 전송되었을 수 있으므로, 실제 결제 상태를 확인하여
+     * 결제가 성공했다면 주문을 완료하고, 실패했다면 주문을 취소합니다.
+     * </p>
+     *
+     * @param userId 사용자 ID
+     * @param orderId 주문 ID
+     */
+    public void recoverAfterTimeout(String userId, Long orderId) {
+        try {
+            // 잠시 대기 후 상태 확인 (PG 처리 시간 고려)
+            // 타임아웃이 발생했지만 요청은 전송되었을 수 있으므로,
+            // PG 시스템이 처리할 시간을 주기 위해 짧은 대기
+            delayProvider.delay(Duration.ofSeconds(1));
+            
+            // PG에서 주문별 결제 정보 조회
+            var status = paymentGatewayAdapter.getPaymentStatus(userId, String.valueOf(orderId));
+            
+            // 별도 트랜잭션으로 상태 업데이트
+            boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null);
+            
+            if (!updated) {
+                log.warn("타임아웃 후 상태 확인 실패. 주문 상태 업데이트에 실패했습니다. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId);
+            }
+            
+        } catch (InterruptedException e) {
+            Thread.currentThread().interrupt();
+            log.warn("타임아웃 후 상태 확인 중 인터럽트 발생. (orderId: {})", orderId);
+        } catch (Exception e) {
+            // 기타 오류: 나중에 스케줄러로 복구 가능
+            log.error("타임아웃 후 상태 확인 중 오류 발생. 나중에 스케줄러로 복구됩니다. (orderId: {})", orderId, e);
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java
new file mode 100644
index 000000000..4188fd2e6
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequest.java
@@ -0,0 +1,20 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+
+/**
+ * 결제 요청 도메인 모델.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public record PaymentRequest(
+    String userId,
+    String orderId,
+    PaymentGatewayDto.CardType cardType,
+    String cardNo,
+    Long amount,
+    String callbackUrl
+) {
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java
new file mode 100644
index 000000000..21b7869d0
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PaymentRequestBuilder.java
@@ -0,0 +1,167 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.support.error.CoreException;
+import com.loopers.support.error.ErrorType;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 결제 요청 빌더.
+ * <p>
+ * 결제 요청 도메인 모델을 생성합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentRequestBuilder {
+    
+    @Value("${payment.callback.base-url}")
+    private String callbackBaseUrl;
+    
+    /**
+     * 결제 요청을 생성합니다.
+     *
+     * @param userId 사용자 ID
+     * @param orderId 주문 ID
+     * @param cardType 카드 타입 문자열
+     * @param cardNo 카드 번호
+     * @param amount 결제 금액
+     * @return 결제 요청 도메인 모델
+     * @throws CoreException 잘못된 카드 타입인 경우
+     */
+    public PaymentRequest build(String userId, Long orderId, String cardType, String cardNo, Integer amount) {
+        // 카드 번호 유효성 검증
+        validateCardNo(cardNo);
+        
+        // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
+        String orderIdString = formatOrderId(orderId);
+        return new PaymentRequest(
+            userId,
+            orderIdString,
+            parseCardType(cardType),
+            cardNo,
+            amount.longValue(),
+            generateCallbackUrl(orderId)
+        );
+    }
+    
+    /**
+     * 카드 타입 문자열을 CardType enum으로 변환합니다.
+     *
+     * @param cardType 카드 타입 문자열
+     * @return CardType enum
+     * @throws CoreException 잘못된 카드 타입인 경우
+     */
+    private PaymentGatewayDto.CardType parseCardType(String cardType) {
+        try {
+            return PaymentGatewayDto.CardType.valueOf(cardType.toUpperCase());
+        } catch (IllegalArgumentException e) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                String.format("잘못된 카드 타입입니다. (cardType: %s)", cardType));
+        }
+    }
+    
+    /**
+     * 콜백 URL을 생성합니다.
+     * <p>
+     * 환경변수 {@code payment.callback.base-url}을 사용하여 프로덕션 환경에 적합한 URL을 생성합니다.
+     * </p>
+     *
+     * @param orderId 주문 ID
+     * @return 콜백 URL
+     */
+    private String generateCallbackUrl(Long orderId) {
+        return String.format("%s/api/v1/orders/%d/callback", callbackBaseUrl, orderId);
+    }
+    
+    /**
+     * 카드 번호 유효성 검증을 수행합니다.
+     * <p>
+     * 다음 사항들을 검증합니다:
+     * <ul>
+     *   <li>null/empty 체크</li>
+     *   <li>공백/하이픈 제거 및 정규화</li>
+     *   <li>길이 검증 (13-19자리)</li>
+     *   <li>숫자만 포함하는지 검증</li>
+     *   <li>Luhn 알고리즘 체크섬 검증</li>
+     * </ul>
+     * </p>
+     *
+     * @param cardNo 카드 번호
+     * @throws CoreException 유효하지 않은 카드 번호인 경우
+     */
+    private void validateCardNo(String cardNo) {
+        if (cardNo == null || cardNo.isEmpty()) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 필수입니다.");
+        }
+        
+        // 공백/하이픈 제거 및 정규화
+        String normalized = cardNo.replaceAll("[\\s-]", "");
+        
+        // 길이 검증 (13-19자리)
+        if (normalized.length() < 13 || normalized.length() > 19) {
+            throw new CoreException(ErrorType.BAD_REQUEST,
+                String.format("유효하지 않은 카드 번호 길이입니다. (길이: %d, 요구사항: 13-19자리)", normalized.length()));
+        }
+        
+        // 숫자만 포함하는지 검증
+        if (!normalized.matches("\\d+")) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "카드 번호는 숫자만 포함해야 합니다.");
+        }
+        
+        // Luhn 알고리즘 체크섬 검증
+        if (!isValidLuhn(normalized)) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "유효하지 않은 카드 번호입니다. (Luhn 알고리즘 검증 실패)");
+        }
+    }
+    
+    /**
+     * Luhn 알고리즘을 사용하여 카드 번호의 체크섬을 검증합니다.
+     * <p>
+     * Luhn 알고리즘은 신용카드 번호의 유효성을 검증하는 표준 알고리즘입니다.
+     * </p>
+     *
+     * @param cardNo 정규화된 카드 번호 (숫자만 포함)
+     * @return 유효한 경우 true, 그렇지 않으면 false
+     */
+    private boolean isValidLuhn(String cardNo) {
+        int sum = 0;
+        boolean alternate = false;
+        
+        // 오른쪽에서 왼쪽으로 순회
+        for (int i = cardNo.length() - 1; i >= 0; i--) {
+            int digit = Character.getNumericValue(cardNo.charAt(i));
+            
+            if (alternate) {
+                digit *= 2;
+                if (digit > 9) {
+                    digit = (digit % 10) + 1;
+                }
+            }
+            
+            sum += digit;
+            alternate = !alternate;
+        }
+        
+        return (sum % 10) == 0;
+    }
+    
+    /**
+     * 주문 ID를 6자리 이상 문자열로 변환합니다.
+     * <p>
+     * pg-simulator의 검증 요구사항에 맞추기 위해 최소 6자리로 패딩합니다.
+     * </p>
+     *
+     * @param orderId 주문 ID (Long)
+     * @return 6자리 이상의 주문 ID 문자열
+     */
+    public String formatOrderId(Long orderId) {
+        return String.format("%06d", orderId);
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
index 82e55c406..0a2b1b208 100644
--- a/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
+++ b/apps/commerce-api/src/main/java/com/loopers/application/purchasing/PurchasingFacade.java
@@ -8,16 +8,28 @@
 import com.loopers.domain.order.Order;
 import com.loopers.domain.order.OrderItem;
 import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
 import org.springframework.orm.ObjectOptimisticLockingFailureException;
 import com.loopers.domain.product.Product;
 import com.loopers.domain.product.ProductRepository;
 import com.loopers.domain.user.Point;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayAdapter;
+import com.loopers.domain.order.PaymentFailureClassifier;
+import com.loopers.domain.order.PaymentFailureType;
+import com.loopers.domain.order.OrderStatusUpdater;
+import com.loopers.domain.order.OrderCancellationService;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
+import feign.FeignException;
 import org.springframework.transaction.annotation.Transactional;
+import org.springframework.transaction.support.TransactionSynchronization;
+import org.springframework.transaction.support.TransactionSynchronizationManager;
 import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.stereotype.Component;
 
 import java.util.ArrayList;
@@ -32,6 +44,7 @@
  * 주문 생성과 결제(포인트 차감), 재고 조정, 외부 연동을 조율한다.
  * </p>
  */
+@Slf4j
 @RequiredArgsConstructor
 @Component
 public class PurchasingFacade {
@@ -42,6 +55,14 @@ public class PurchasingFacade {
     private final CouponRepository couponRepository;
     private final UserCouponRepository userCouponRepository;
     private final CouponDiscountStrategyFactory couponDiscountStrategyFactory;
+    private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient; // 스케줄러용 (Retry 적용)
+    private final PaymentRequestBuilder paymentRequestBuilder;
+    private final PaymentGatewayAdapter paymentGatewayAdapter;
+    private final PaymentFailureClassifier paymentFailureClassifier;
+    private final PaymentRecoveryService paymentRecoveryService;
+    private final OrderCancellationService orderCancellationService;
+    private final OrderStatusUpdater orderStatusUpdater;
+    private final PaymentFailureHandler paymentFailureHandler;
 
     /**
      * 주문을 생성한다.
@@ -49,7 +70,8 @@ public class PurchasingFacade {
      * 1. 사용자 조회 및 존재 여부 검증<br>
      * 2. 상품 재고 검증 및 차감<br>
      * 3. 사용자 포인트 검증 및 차감<br>
-     * 4. 주문 저장
+     * 4. 주문 저장<br>
+     * 5. PG 결제 요청 (비동기)
      * </p>
      * <p>
      * <b>동시성 제어 전략:</b>
@@ -81,10 +103,12 @@ public class PurchasingFacade {
      *
      * @param userId 사용자 식별자 (로그인 ID)
      * @param commands 주문 상품 정보
+     * @param cardType 카드 타입 (SAMSUNG, KB, HYUNDAI)
+     * @param cardNo 카드 번호 (xxxx-xxxx-xxxx-xxxx 형식)
      * @return 생성된 주문 정보
      */
     @Transactional
-    public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
+    public OrderInfo createOrder(String userId, List<OrderItemCommand> commands, String cardType, String cardNo) {
         if (userId == null || userId.isBlank()) {
             throw new CoreException(ErrorType.BAD_REQUEST, "사용자 ID는 필수입니다.");
         }
@@ -149,15 +173,49 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
         }
 
         Order order = Order.of(user.getId(), orderItems, couponCode, discountAmount);
+        // 주문은 PENDING 상태로 생성됨 (Order 생성자에서 기본값으로 설정)
+        // 결제 성공 후에만 COMPLETED로 변경됨
 
         decreaseStocksForOrderItems(order.getItems(), products);
         deductUserPoint(user, order.getTotalAmount());
-        order.complete();
+        // 주문은 PENDING 상태로 유지 (결제 요청 중 상태)
+        // 결제 성공 시 콜백이나 상태 확인 API를 통해 COMPLETED로 변경됨
 
         products.forEach(productRepository::save);
         userRepository.save(user);
 
         Order savedOrder = orderRepository.save(order);
+        // 주문은 PENDING 상태로 저장됨
+
+        // PG 결제 요청을 트랜잭션 커밋 후에 실행하여 DB 커넥션 풀 고갈 방지
+        // 트랜잭션 내에서 외부 HTTP 호출을 하면 PG 지연/타임아웃 시 DB 커넥션이 오래 유지되어 커넥션 풀 고갈 위험
+        Long orderId = savedOrder.getId();
+        Integer totalAmount = savedOrder.getTotalAmount();
+        TransactionSynchronizationManager.registerSynchronization(
+            new TransactionSynchronization() {
+                @Override
+                public void afterCommit() {
+                    // 트랜잭션 커밋 후 PG 호출 (DB 커넥션 해제 후 실행)
+                    try {
+                        String transactionKey = requestPaymentToGateway(userId, orderId, cardType, cardNo, totalAmount);
+                        if (transactionKey != null) {
+                            // 결제 성공: 별도 트랜잭션에서 주문 상태를 COMPLETED로 변경
+                            updateOrderStatusToCompleted(orderId, transactionKey);
+                            log.info("PG 결제 요청 완료. (orderId: {}, transactionKey: {})", orderId, transactionKey);
+                        } else {
+                            // PG 요청 실패: 외부 시스템 장애로 간주
+                            // 주문은 PENDING 상태로 유지되어 나중에 상태 확인 API나 콜백으로 복구 가능
+                            log.info("PG 결제 요청 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId);
+                        }
+                    } catch (Exception e) {
+                        // PG 요청 중 예외 발생 시에도 주문은 이미 저장되어 있으므로 유지
+                        // 외부 시스템 장애는 내부 시스템에 영향을 주지 않도록 함
+                        log.error("PG 결제 요청 중 예외 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", 
+                            orderId, e);
+                    }
+                }
+            }
+        );
 
         return OrderInfo.from(savedOrder);
     }
@@ -175,48 +233,18 @@ public OrderInfo createOrder(String userId, List<OrderItemCommand> commands) {
      * @param order 주문 엔티티
      * @param user 사용자 엔티티
      */
+    /**
+     * 주문을 취소하고 포인트를 환불하며 재고를 원복한다.
+     * <p>
+     * OrderCancellationService를 사용하여 처리합니다.
+     * </p>
+     *
+     * @param order 주문 엔티티
+     * @param user 사용자 엔티티
+     */
     @Transactional
     public void cancelOrder(Order order, User user) {
-        if (order == null || user == null) {
-            throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
-        }
-
-        // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장
-        // createOrder: User 락 → Product 락 (정렬됨)
-        // cancelOrder: User 락 → Product 락 (정렬됨) - 동일한 순서로 락 획득
-        User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId());
-        if (lockedUser == null) {
-            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
-        }
-
-        // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
-        List<Long> sortedProductIds = order.getItems().stream()
-            .map(OrderItem::getProductId)
-            .distinct()
-            .sorted()
-            .toList();
-
-        // 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
-        Map<Long, Product> productMap = new java.util.HashMap<>();
-        for (Long productId : sortedProductIds) {
-            Product product = productRepository.findByIdForUpdate(productId)
-                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
-                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
-            productMap.put(productId, product);
-        }
-
-        // OrderItem 순서대로 Product 리스트 생성
-        List<Product> products = order.getItems().stream()
-            .map(item -> productMap.get(item.getProductId()))
-            .toList();
-
-        order.cancel();
-        increaseStocksForOrderItems(order.getItems(), products);
-        lockedUser.receivePoint(Point.of((long) order.getTotalAmount()));
-
-        products.forEach(productRepository::save);
-        userRepository.save(lockedUser);
-        orderRepository.save(order);
+        orderCancellationService.cancel(order, user);
     }
 
     /**
@@ -268,19 +296,6 @@ private void decreaseStocksForOrderItems(List<OrderItem> items, List<Product> pr
         }
     }
 
-    private void increaseStocksForOrderItems(List<OrderItem> items, List<Product> products) {
-        Map<Long, Product> productMap = products.stream()
-            .collect(Collectors.toMap(Product::getId, product -> product));
-
-        for (OrderItem item : items) {
-            Product product = productMap.get(item.getProductId());
-            if (product == null) {
-                throw new CoreException(ErrorType.NOT_FOUND,
-                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()));
-            }
-            product.increaseStock(item.getQuantity());
-        }
-    }
 
     private void deductUserPoint(User user, Integer totalAmount) {
         if (Objects.requireNonNullElse(totalAmount, 0) <= 0) {
@@ -398,5 +413,291 @@ private Integer calculateSubtotal(List<OrderItem> orderItems) {
             .mapToInt(item -> item.getPrice() * item.getQuantity())
             .sum();
     }
+
+    /**
+     * 주문 상태를 COMPLETED로 업데이트합니다.
+     * <p>
+     * 트랜잭션 커밋 후 별도 트랜잭션에서 실행되어 주문 상태를 업데이트합니다.
+     * </p>
+     *
+     * @param orderId 주문 ID
+     * @param transactionKey 트랜잭션 키
+     */
+    @Transactional(propagation = org.springframework.transaction.annotation.Propagation.REQUIRES_NEW)
+    public void updateOrderStatusToCompleted(Long orderId, String transactionKey) {
+        Order order = orderRepository.findById(orderId).orElse(null);
+        if (order == null) {
+            log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
+            return;
+        }
+        
+        if (order.getStatus() == OrderStatus.COMPLETED) {
+            log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+            return;
+        }
+        
+        order.complete();
+        orderRepository.save(order);
+        log.info("주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})", orderId, transactionKey);
+    }
+    
+    /**
+     * PG 결제 게이트웨이에 결제 요청을 전송합니다.
+     * <p>
+     * 트랜잭션 커밋 후 실행되어 DB 커넥션 풀 고갈을 방지합니다.
+     * 실패 시에도 주문은 이미 저장되어 있으므로, 로그만 기록합니다.
+     * </p>
+     *
+     * @param userId 사용자 ID
+     * @param orderId 주문 ID
+     * @param cardType 카드 타입
+     * @param cardNo 카드 번호
+     * @param amount 결제 금액
+     * @return transactionKey (성공 시), null (실패 시)
+     */
+    private String requestPaymentToGateway(String userId, Long orderId, String cardType, String cardNo, Integer amount) {
+        try {
+            // 결제 요청 생성
+            PaymentRequest request = paymentRequestBuilder.build(userId, orderId, cardType, cardNo, amount);
+            
+            // PG 결제 요청 전송
+            var result = paymentGatewayAdapter.requestPayment(request);
+            
+            // 결과 처리
+            return result.handle(
+                success -> {
+                    log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})",
+                        orderId, success.transactionKey());
+                    return success.transactionKey();
+                },
+                failure -> {
+                    PaymentFailureType failureType = paymentFailureClassifier.classify(failure.errorCode());
+                    
+                    if (failureType == PaymentFailureType.BUSINESS_FAILURE) {
+                        // 비즈니스 실패: 주문 취소 (별도 트랜잭션으로 처리)
+                        paymentFailureHandler.handle(userId, orderId, failure.errorCode(), failure.message());
+                    } else if (failure.isTimeout()) {
+                        // 타임아웃: 상태 확인 후 복구
+                        log.info("타임아웃 발생. PG 결제 상태 확인 API를 호출하여 실제 결제 상태를 확인합니다. (orderId: {})", orderId);
+                        paymentRecoveryService.recoverAfterTimeout(userId, orderId);
+                    } else {
+                        // 외부 시스템 장애: 주문은 PENDING 상태로 유지
+                        log.info("외부 시스템 장애로 인한 실패. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, errorCode: {})",
+                            orderId, failure.errorCode());
+                    }
+                    return null;
+                }
+            );
+        } catch (CoreException e) {
+            // 잘못된 카드 타입 등 검증 오류
+            log.warn("결제 요청 생성 실패. (orderId: {}, error: {})", orderId, e.getMessage());
+            return null;
+        } catch (Exception e) {
+            // 기타 예외 처리
+            log.error("PG 결제 요청 중 예상치 못한 오류 발생. (orderId: {})", orderId, e);
+            log.info("예상치 못한 오류 발생. 주문은 PENDING 상태로 유지됩니다. (orderId: {})", orderId);
+            return null;
+        }
+    }
+
+
+    /**
+     * PG 결제 콜백을 처리합니다.
+     * <p>
+     * PG 시스템에서 결제 처리 완료 후 콜백으로 전송된 결제 결과를 받아
+     * 주문 상태를 업데이트합니다.
+     * </p>
+     * <p>
+     * <b>보안 및 정합성 강화:</b>
+     * <ul>
+     *   <li>콜백 정보를 직접 신뢰하지 않고 PG 조회 API로 교차 검증</li>
+     *   <li>불일치 시 PG 원장을 우선시하여 처리</li>
+     *   <li>콜백 정보와 PG 조회 결과가 일치하는지 검증</li>
+     * </ul>
+     * </p>
+     * <p>
+     * <b>처리 내용:</b>
+     * <ul>
+     *   <li>결제 성공 (SUCCESS): 주문 상태를 COMPLETED로 변경</li>
+     *   <li>결제 실패 (FAILED): 주문 상태를 CANCELED로 변경하고 리소스 원복</li>
+     *   <li>결제 대기 (PENDING): 상태 유지 (추가 처리 없음)</li>
+     * </ul>
+     * </p>
+     *
+     * @param orderId 주문 ID
+     * @param callbackRequest 콜백 요청 정보
+     */
+    @Transactional
+    public void handlePaymentCallback(Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) {
+        try {
+            // 주문 조회
+            Order order = orderRepository.findById(orderId)
+                .orElse(null);
+            
+            if (order == null) {
+                log.warn("콜백 처리 시 주문을 찾을 수 없습니다. (orderId: {}, transactionKey: {})",
+                    orderId, callbackRequest.transactionKey());
+                return;
+            }
+            
+            // 이미 완료되거나 취소된 주문인 경우 처리하지 않음
+            if (order.getStatus() == OrderStatus.COMPLETED) {
+                log.info("이미 완료된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
+                    orderId, callbackRequest.transactionKey());
+                return;
+            }
+            
+            if (order.getStatus() == OrderStatus.CANCELED) {
+                log.info("이미 취소된 주문입니다. 콜백 처리를 건너뜁니다. (orderId: {}, transactionKey: {})",
+                    orderId, callbackRequest.transactionKey());
+                return;
+            }
+            
+            // 콜백 정보와 PG 원장 교차 검증
+            // 보안 및 정합성을 위해 PG 조회 API로 실제 결제 상태 확인
+            PaymentGatewayDto.TransactionStatus verifiedStatus = verifyCallbackWithPgInquiry(
+                order.getUserId(), orderId, callbackRequest);
+            
+            // OrderStatusUpdater를 사용하여 상태 업데이트
+            boolean updated = orderStatusUpdater.updateByPaymentStatus(
+                orderId,
+                verifiedStatus,
+                callbackRequest.transactionKey(),
+                callbackRequest.reason()
+            );
+            
+            if (updated) {
+                log.info("PG 결제 콜백 처리 완료 (PG 원장 검증 완료). (orderId: {}, transactionKey: {}, status: {})",
+                    orderId, callbackRequest.transactionKey(), verifiedStatus);
+            } else {
+                log.warn("PG 결제 콜백 처리 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {}, transactionKey: {}, status: {})",
+                    orderId, callbackRequest.transactionKey(), verifiedStatus);
+            }
+        } catch (Exception e) {
+            log.error("콜백 처리 중 오류 발생. (orderId: {}, transactionKey: {})",
+                orderId, callbackRequest.transactionKey(), e);
+            throw e; // 콜백 실패는 재시도 가능하도록 예외를 다시 던짐
+        }
+    }
+
+    /**
+     * 콜백 정보를 PG 조회 API로 교차 검증합니다.
+     * <p>
+     * 보안 및 정합성을 위해 콜백 정보를 직접 신뢰하지 않고,
+     * PG 원장(조회 API)을 기준으로 검증합니다.
+     * </p>
+     * <p>
+     * <b>검증 전략:</b>
+     * <ul>
+     *   <li>PG 조회 API로 실제 결제 상태 확인</li>
+     *   <li>콜백 정보와 PG 조회 결과 비교</li>
+     *   <li>불일치 시 PG 원장을 우선시하여 처리</li>
+     *   <li>PG 조회 실패 시 콜백 정보를 사용하되 경고 로그 기록</li>
+     * </ul>
+     * </p>
+     *
+     * @param userId 사용자 ID (Long)
+     * @param orderId 주문 ID
+     * @param callbackRequest 콜백 요청 정보
+     * @return 검증된 결제 상태 (PG 원장 기준)
+     */
+    private PaymentGatewayDto.TransactionStatus verifyCallbackWithPgInquiry(
+        Long userId, Long orderId, PaymentGatewayDto.CallbackRequest callbackRequest) {
+        
+        try {
+            // User의 userId (String)를 가져오기 위해 User 조회
+            User user = userRepository.findById(userId);
+            if (user == null) {
+                log.warn("콜백 검증 시 사용자를 찾을 수 없습니다. 콜백 정보를 사용합니다. (orderId: {}, userId: {})",
+                    orderId, userId);
+                return callbackRequest.status(); // 사용자를 찾을 수 없으면 콜백 정보 사용
+            }
+            
+            String userIdString = user.getUserId();
+            
+            // PG에서 주문별 결제 정보 조회 (스케줄러 전용 클라이언트 사용 - Retry 적용)
+            // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
+            String orderIdString = paymentRequestBuilder.formatOrderId(orderId);
+            PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> response =
+                paymentGatewaySchedulerClient.getTransactionsByOrder(userIdString, orderIdString);
+            
+            if (response == null || response.meta() == null
+                || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
+                || response.data() == null || response.data().transactions() == null
+                || response.data().transactions().isEmpty()) {
+                // PG 조회 실패: 콜백 정보를 사용하되 경고 로그 기록
+                log.warn("콜백 검증 시 PG 조회 API 호출 실패. 콜백 정보를 사용합니다. (orderId: {}, transactionKey: {})",
+                    orderId, callbackRequest.transactionKey());
+                return callbackRequest.status();
+            }
+            
+            // 가장 최근 트랜잭션의 상태 확인 (PG 원장 기준)
+            PaymentGatewayDto.TransactionResponse latestTransaction = 
+                response.data().transactions().get(response.data().transactions().size() - 1);
+            
+            PaymentGatewayDto.TransactionStatus pgStatus = latestTransaction.status();
+            PaymentGatewayDto.TransactionStatus callbackStatus = callbackRequest.status();
+            
+            // 콜백 정보와 PG 조회 결과 비교
+            if (pgStatus != callbackStatus) {
+                // 불일치 시 PG 원장을 우선시하여 처리
+                log.warn("콜백 정보와 PG 원장이 불일치합니다. PG 원장을 우선시하여 처리합니다. " +
+                    "(orderId: {}, transactionKey: {}, 콜백 상태: {}, PG 원장 상태: {})",
+                    orderId, callbackRequest.transactionKey(), callbackStatus, pgStatus);
+                return pgStatus; // PG 원장 기준으로 처리
+            }
+            
+            // 일치하는 경우: 정상 처리
+            log.debug("콜백 정보와 PG 원장이 일치합니다. (orderId: {}, transactionKey: {}, 상태: {})",
+                orderId, callbackRequest.transactionKey(), pgStatus);
+            return pgStatus;
+            
+        } catch (FeignException e) {
+            // PG 조회 API 호출 실패: 콜백 정보를 사용하되 경고 로그 기록
+            log.warn("콜백 검증 시 PG 조회 API 호출 중 Feign 예외 발생. 콜백 정보를 사용합니다. " +
+                "(orderId: {}, transactionKey: {}, status: {})",
+                orderId, callbackRequest.transactionKey(), e.status(), e);
+            return callbackRequest.status();
+        } catch (Exception e) {
+            // 기타 예외: 콜백 정보를 사용하되 경고 로그 기록
+            log.warn("콜백 검증 시 예상치 못한 오류 발생. 콜백 정보를 사용합니다. " +
+                "(orderId: {}, transactionKey: {})",
+                orderId, callbackRequest.transactionKey(), e);
+            return callbackRequest.status();
+        }
+    }
+
+    /**
+     * 결제 상태 확인 API를 통해 주문 상태를 복구합니다.
+     * <p>
+     * 콜백이 오지 않았거나 타임아웃된 경우, PG 시스템의 결제 상태 확인 API를 호출하여
+     * 실제 결제 상태를 확인하고 주문 상태를 업데이트합니다.
+     * </p>
+     *
+     * @param userId 사용자 ID (String - PG API 요구사항)
+     * @param orderId 주문 ID
+     */
+    @Transactional
+    public void recoverOrderStatusByPaymentCheck(String userId, Long orderId) {
+        try {
+            // PG에서 결제 상태 조회
+            // 주문 ID를 6자리 이상 문자열로 변환 (pg-simulator 검증 요구사항)
+            String orderIdString = paymentRequestBuilder.formatOrderId(orderId);
+            PaymentGatewayDto.TransactionStatus status = 
+                paymentGatewayAdapter.getPaymentStatus(userId, orderIdString);
+            
+            // OrderStatusUpdater를 사용하여 상태 업데이트
+            boolean updated = orderStatusUpdater.updateByPaymentStatus(orderId, status, null, null);
+            
+            if (!updated) {
+                log.warn("상태 복구 실패. 주문 상태 업데이트에 실패했습니다. (orderId: {})", orderId);
+            }
+            
+        } catch (Exception e) {
+            log.error("상태 복구 중 오류 발생. (orderId: {})", orderId, e);
+            // 기타 오류도 로그만 기록
+        }
+    }
+
 }
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
new file mode 100644
index 000000000..90923e2f3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/config/Resilience4jRetryConfig.java
@@ -0,0 +1,129 @@
+package com.loopers.config;
+
+import io.github.resilience4j.retry.RetryConfig;
+import io.github.resilience4j.retry.RetryRegistry;
+import io.github.resilience4j.core.IntervalFunction;
+import lombok.extern.slf4j.Slf4j;
+import feign.FeignException;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.time.Duration;
+import java.util.concurrent.TimeoutException;
+import java.net.SocketTimeoutException;
+
+/**
+ * Resilience4j Retry 설정 커스터마이징.
+ * <p>
+ * 실무 권장 패턴에 따라 메서드별로 다른 Retry 정책을 적용합니다:
+ * </p>
+ * <p>
+ * <b>Retry 정책:</b>
+ * <ul>
+ *   <li><b>결제 요청 API (requestPayment):</b> Retry 없음 (유저 요청 경로 - 빠른 실패)</li>
+ *   <li><b>조회 API (getTransactionsByOrder, getTransaction):</b> Exponential Backoff 적용 (스케줄러 - 안전)</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>Exponential Backoff 전략 (조회 API용):</b>
+ * <ul>
+ *   <li><b>초기 대기 시간:</b> 500ms</li>
+ *   <li><b>배수(multiplier):</b> 2 (각 재시도마다 2배씩 증가)</li>
+ *   <li><b>최대 대기 시간:</b> 5초 (너무 길어지지 않도록 제한)</li>
+ *   <li><b>랜덤 jitter:</b> 활성화 (thundering herd 문제 방지)</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>재시도 시퀀스 예시 (조회 API):</b>
+ * <ol>
+ *   <li>1차 시도: 즉시 실행</li>
+ *   <li>2차 시도: 500ms 후 (500ms * 2^0)</li>
+ *   <li>3차 시도: 1000ms 후 (500ms * 2^1)</li>
+ * </ol>
+ * </p>
+ * <p>
+ * <b>설계 근거:</b>
+ * <ul>
+ *   <li><b>유저 요청 경로:</b> 긴 Retry는 스레드 점유 비용이 크므로 Retry 없이 빠르게 실패</li>
+ *   <li><b>스케줄러 경로:</b> 비동기/배치 기반이므로 Retry가 안전하게 적용 가능 (Nice-to-Have 요구사항 충족)</li>
+ *   <li><b>Eventually Consistent:</b> 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구</li>
+ *   <li><b>일시적 오류 복구:</b> 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구</li>
+ * </ul>
+ * </p>
+ *
+ * @author Loopers
+ * @version 2.0
+ */
+@Slf4j
+@Configuration
+public class Resilience4jRetryConfig {
+
+    /**
+     * PaymentGatewayClient용 Retry 설정을 커스터마이징합니다.
+     * <p>
+     * Exponential Backoff 전략을 적용하여 재시도 간격을 점진적으로 증가시킵니다.
+     * </p>
+     *
+     * @return RetryRegistry (커스터마이징된 설정이 적용됨)
+     */
+    @Bean
+    public RetryRegistry retryRegistry() {
+        RetryRegistry retryRegistry = io.github.resilience4j.retry.RetryRegistry.ofDefaults();
+        // Exponential Backoff 설정
+        // - 초기 대기 시간: 500ms
+        // - 배수: 2 (각 재시도마다 2배씩 증가)
+        // - 최대 대기 시간: 5초
+        // - 랜덤 jitter: 활성화 (thundering herd 문제 방지)
+        IntervalFunction intervalFunction = IntervalFunction
+            .ofExponentialRandomBackoff(
+                Duration.ofMillis(500),  // 초기 대기 시간
+                2.0,                      // 배수 (exponential multiplier)
+                Duration.ofSeconds(5)     // 최대 대기 시간
+            );
+
+        // RetryConfig 커스터마이징
+        RetryConfig retryConfig = RetryConfig.custom()
+            .maxAttempts(3)  // 최대 재시도 횟수 (초기 시도 포함)
+            .intervalFunction(intervalFunction)  // Exponential Backoff 적용
+            .retryOnException(throwable -> {
+                // 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류
+                if (throwable instanceof FeignException feignException) {
+                    int status = feignException.status();
+                    // 5xx 서버 오류만 재시도
+                    if (status >= 500 && status < 600) {
+                        log.debug("재시도 대상 예외: FeignException (status: {})", status);
+                        return true;
+                    }
+                    return false;
+                }
+                if (throwable instanceof SocketTimeoutException ||
+                    throwable instanceof TimeoutException) {
+                    log.debug("재시도 대상 예외: {}", throwable.getClass().getSimpleName());
+                    return true;
+                }
+                return false;
+            })
+            // ignoreExceptions는 사용하지 않음
+            // retryOnException에서 5xx만 재시도하고 4xx는 제외하므로,
+            // 별도로 ignoreExceptions를 설정할 필요가 없음
+            .build();
+
+        // 결제 요청 API: 유저 요청 경로에서 사용되므로 Retry 비활성화 (빠른 실패)
+        // 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구됨
+        RetryConfig noRetryConfig = RetryConfig.custom()
+            .maxAttempts(1)  // 재시도 없음 (초기 시도만)
+            .build();
+        retryRegistry.addConfiguration("paymentGatewayClient", noRetryConfig);
+
+        // 스케줄러 전용 클라이언트: 비동기/배치 기반으로 Retry 적용
+        // Exponential Backoff 적용하여 일시적 오류 자동 복구
+        retryRegistry.addConfiguration("paymentGatewaySchedulerClient", retryConfig);
+
+        log.info("Resilience4j Retry 설정 완료:");
+        log.info("  - 결제 요청 API (paymentGatewayClient): Retry 없음 (유저 요청 경로 - 빠른 실패)");
+        log.info("  - 조회 API (paymentGatewaySchedulerClient): Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)");
+
+        return retryRegistry;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java
new file mode 100644
index 000000000..643644749
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderCancellationService.java
@@ -0,0 +1,106 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+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;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 주문 취소 도메인 서비스.
+ * <p>
+ * 주문 취소 및 리소스 원복을 처리합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderCancellationService {
+    
+    private final OrderRepository orderRepository;
+    private final UserRepository userRepository;
+    private final ProductRepository productRepository;
+    
+    /**
+     * 주문을 취소하고 포인트를 환불하며 재고를 원복합니다.
+     * <p>
+     * <b>동시성 제어:</b>
+     * <ul>
+     *   <li><b>비관적 락 사용:</b> 재고 원복 시 동시성 제어를 위해 findByIdForUpdate 사용</li>
+     *   <li><b>Deadlock 방지:</b> 상품 ID를 정렬하여 일관된 락 획득 순서 보장</li>
+     * </ul>
+     * </p>
+     *
+     * @param order 주문 엔티티
+     * @param user 사용자 엔티티
+     */
+    @Transactional
+    public void cancel(Order order, User user) {
+        if (order == null || user == null) {
+            throw new CoreException(ErrorType.BAD_REQUEST, "취소할 주문과 사용자 정보는 필수입니다.");
+        }
+        
+        // ✅ Deadlock 방지: User 락을 먼저 획득하여 createOrder와 동일한 락 획득 순서 보장
+        User lockedUser = userRepository.findByUserIdForUpdate(user.getUserId());
+        if (lockedUser == null) {
+            throw new CoreException(ErrorType.NOT_FOUND, "사용자를 찾을 수 없습니다.");
+        }
+        
+        // ✅ Deadlock 방지: 상품 ID를 정렬하여 일관된 락 획득 순서 보장
+        List<Long> sortedProductIds = order.getItems().stream()
+            .map(OrderItem::getProductId)
+            .distinct()
+            .sorted()
+            .toList();
+        
+        // 정렬된 순서대로 상품 락 획득 (Deadlock 방지)
+        Map<Long, Product> productMap = new HashMap<>();
+        for (Long productId : sortedProductIds) {
+            Product product = productRepository.findByIdForUpdate(productId)
+                .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", productId)));
+            productMap.put(productId, product);
+        }
+        
+        // OrderItem 순서대로 Product 리스트 생성
+        List<Product> products = order.getItems().stream()
+            .map(item -> productMap.get(item.getProductId()))
+            .toList();
+        
+        order.cancel();
+        increaseStocksForOrderItems(order.getItems(), products);
+        lockedUser.receivePoint(Point.of((long) order.getTotalAmount()));
+        
+        products.forEach(productRepository::save);
+        userRepository.save(lockedUser);
+        orderRepository.save(order);
+    }
+    
+    private void increaseStocksForOrderItems(List<OrderItem> items, List<Product> products) {
+        Map<Long, Product> productMap = products.stream()
+            .collect(java.util.stream.Collectors.toMap(Product::getId, product -> product));
+        
+        for (OrderItem item : items) {
+            Product product = productMap.get(item.getProductId());
+            if (product == null) {
+                throw new CoreException(ErrorType.NOT_FOUND,
+                    String.format("상품을 찾을 수 없습니다. (상품 ID: %d)", item.getProductId()));
+            }
+            product.increaseStock(item.getQuantity());
+        }
+    }
+}
+
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 a6f9870dc..10b80fc16 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
@@ -34,6 +34,14 @@ public interface OrderRepository {
      * @return 해당 사용자의 주문 목록
      */
     List<Order> findAllByUserId(Long userId);
+
+    /**
+     * 주문 상태로 주문 목록을 조회합니다.
+     *
+     * @param status 주문 상태
+     * @return 해당 상태의 주문 목록
+     */
+    List<Order> findAllByStatus(OrderStatus status);
 }
 
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java
new file mode 100644
index 000000000..6e9abcc1f
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/OrderStatusUpdater.java
@@ -0,0 +1,100 @@
+package com.loopers.domain.order;
+
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+/**
+ * 주문 상태 업데이트 도메인 서비스.
+ * <p>
+ * 결제 상태에 따라 주문 상태를 업데이트합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class OrderStatusUpdater {
+    
+    private final OrderRepository orderRepository;
+    private final UserRepository userRepository;
+    private final OrderCancellationService orderCancellationService;
+    
+    /**
+     * 결제 상태에 따라 주문 상태를 업데이트합니다.
+     * <p>
+     * 별도 트랜잭션으로 실행하여 외부 시스템 호출과 독립적으로 처리합니다.
+     * </p>
+     *
+     * @param orderId 주문 ID
+     * @param status 결제 상태
+     * @param transactionKey 트랜잭션 키
+     * @param reason 실패 사유 (실패 시)
+     * @return 업데이트 성공 여부 (true: 성공, false: 실패)
+     */
+    @Transactional(propagation = Propagation.REQUIRES_NEW)
+    public boolean updateByPaymentStatus(
+        Long orderId,
+        PaymentGatewayDto.TransactionStatus status,
+        String transactionKey,
+        String reason
+    ) {
+        try {
+            Order order = orderRepository.findById(orderId)
+                .orElse(null);
+            
+            if (order == null) {
+                log.warn("주문 상태 업데이트 시 주문을 찾을 수 없습니다. (orderId: {})", orderId);
+                return false;
+            }
+            
+            // 이미 완료되거나 취소된 주문인 경우 처리하지 않음 (정상적인 경우이므로 true 반환)
+            if (order.getStatus() == OrderStatus.COMPLETED) {
+                log.info("이미 완료된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+                return true;
+            }
+            
+            if (order.getStatus() == OrderStatus.CANCELED) {
+                log.info("이미 취소된 주문입니다. 상태 업데이트를 건너뜁니다. (orderId: {})", orderId);
+                return true;
+            }
+            
+            if (status == PaymentGatewayDto.TransactionStatus.SUCCESS) {
+                // 결제 성공: 주문 완료
+                order.complete();
+                orderRepository.save(order);
+                log.info("결제 상태 확인 결과, 주문 상태를 COMPLETED로 업데이트했습니다. (orderId: {}, transactionKey: {})",
+                    orderId, transactionKey);
+                return true;
+            } else if (status == PaymentGatewayDto.TransactionStatus.FAILED) {
+                // 결제 실패: 주문 취소 및 리소스 원복
+                User user = userRepository.findById(order.getUserId());
+                if (user == null) {
+                    log.warn("주문 상태 업데이트 시 사용자를 찾을 수 없습니다. (orderId: {}, userId: {})",
+                        orderId, order.getUserId());
+                    return false;
+                }
+                orderCancellationService.cancel(order, user);
+                log.info("결제 상태 확인 결과, 주문 상태를 CANCELED로 업데이트했습니다. (orderId: {}, transactionKey: {}, reason: {})",
+                    orderId, transactionKey, reason);
+                return true;
+            } else {
+                // PENDING 상태: 아직 처리 중 (정상적인 경우이므로 true 반환)
+                log.info("결제 상태 확인 결과, 아직 처리 중입니다. 주문은 PENDING 상태로 유지됩니다. (orderId: {}, transactionKey: {})",
+                    orderId, transactionKey);
+                return true;
+            }
+        } catch (Exception e) {
+            log.error("주문 상태 업데이트 중 오류 발생. (orderId: {})", orderId, e);
+            return false;
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java
new file mode 100644
index 000000000..804b9c123
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureClassifier.java
@@ -0,0 +1,74 @@
+package com.loopers.domain.order;
+
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+import java.util.Set;
+
+/**
+ * 결제 실패 분류 도메인 서비스.
+ * <p>
+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 분류합니다.
+ * </p>
+ * <p>
+ * <b>비즈니스 실패 예시:</b>
+ * <ul>
+ *   <li>카드 한도 초과 (LIMIT_EXCEEDED)</li>
+ *   <li>잘못된 카드 번호 (INVALID_CARD)</li>
+ *   <li>카드 오류 (CARD_ERROR)</li>
+ *   <li>잔액 부족 (INSUFFICIENT_FUNDS)</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>외부 시스템 장애 예시:</b>
+ * <ul>
+ *   <li>CircuitBreaker Open (CIRCUIT_BREAKER_OPEN)</li>
+ *   <li>서버 오류 (5xx)</li>
+ *   <li>타임아웃</li>
+ *   <li>네트워크 오류</li>
+ * </ul>
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentFailureClassifier {
+    
+    private static final Set<String> BUSINESS_FAILURE_CODES = Set.of(
+        "LIMIT_EXCEEDED",
+        "INVALID_CARD",
+        "CARD_ERROR",
+        "INSUFFICIENT_FUNDS",
+        "PAYMENT_FAILED"
+    );
+    
+    private static final String CIRCUIT_BREAKER_OPEN = "CIRCUIT_BREAKER_OPEN";
+    
+    /**
+     * 오류 코드를 기반으로 결제 실패 유형을 분류합니다.
+     *
+     * @param errorCode 오류 코드
+     * @return 결제 실패 유형
+     */
+    public PaymentFailureType classify(String errorCode) {
+        if (errorCode == null) {
+            return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+        }
+        
+        // CircuitBreaker Open 상태는 명시적으로 외부 시스템 장애로 간주
+        if (CIRCUIT_BREAKER_OPEN.equals(errorCode)) {
+            return PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+        }
+        
+        // 명확한 비즈니스 실패 오류 코드만 취소 처리
+        boolean isBusinessFailure = BUSINESS_FAILURE_CODES.stream()
+            .anyMatch(errorCode::contains);
+        
+        return isBusinessFailure
+            ? PaymentFailureType.BUSINESS_FAILURE
+            : PaymentFailureType.EXTERNAL_SYSTEM_FAILURE;
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java
new file mode 100644
index 000000000..2cc7f03af
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentFailureType.java
@@ -0,0 +1,25 @@
+package com.loopers.domain.order;
+
+/**
+ * 결제 실패 유형.
+ * <p>
+ * 결제 실패를 비즈니스 실패와 외부 시스템 장애로 구분합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public enum PaymentFailureType {
+    /**
+     * 비즈니스 실패: 주문 취소 필요
+     * 예: 카드 한도 초과, 잘못된 카드 번호 등
+     */
+    BUSINESS_FAILURE,
+    
+    /**
+     * 외부 시스템 장애: 주문 PENDING 상태 유지
+     * 예: CircuitBreaker Open, 서버 오류, 타임아웃 등
+     */
+    EXTERNAL_SYSTEM_FAILURE
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java
new file mode 100644
index 000000000..f1c953c70
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/domain/order/PaymentResult.java
@@ -0,0 +1,55 @@
+package com.loopers.domain.order;
+
+import java.util.function.Function;
+
+/**
+ * 결제 결과 도메인 모델.
+ * <p>
+ * 결제 요청의 성공/실패 결과를 표현합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public sealed interface PaymentResult {
+    
+    /**
+     * 성공 결과.
+     */
+    record Success(String transactionKey) implements PaymentResult {
+    }
+    
+    /**
+     * 실패 결과.
+     */
+    record Failure(
+        String errorCode,
+        String message,
+        boolean isTimeout,
+        boolean isServerError,
+        boolean isClientError
+    ) implements PaymentResult {
+    }
+    
+    /**
+     * 결과에 따라 처리합니다.
+     *
+     * @param successHandler 성공 시 처리 함수
+     * @param failureHandler 실패 시 처리 함수
+     * @param <T> 반환 타입
+     * @return 처리 결과
+     */
+    default <T> T handle(
+        Function<Success, T> successHandler,
+        Function<Failure, T> failureHandler
+    ) {
+        if (this instanceof Success success) {
+            return successHandler.apply(success);
+        } else if (this instanceof Failure failure) {
+            return failureHandler.apply(failure);
+        } else {
+            throw new IllegalStateException("Unknown PaymentResult type: " + this.getClass());
+        }
+    }
+}
+
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 09d47afe2..88ac6434c 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
@@ -44,4 +44,12 @@ public interface UserRepository {
      * @return 조회된 사용자, 없으면 null
      */
     User findByUserIdForUpdate(String userId);
+    
+    /**
+     * 사용자 ID (PK)로 사용자를 조회합니다.
+     *
+     * @param id 사용자 ID (PK)
+     * @return 조회된 사용자, 없으면 null
+     */
+    User findById(Long id);
 }
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 02808e69c..0c91bd190 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
@@ -10,6 +10,8 @@
  */
 public interface OrderJpaRepository extends JpaRepository<Order, Long> {
     List<Order> findAllByUserId(Long userId);
+    
+    List<Order> findAllByStatus(com.loopers.domain.order.OrderStatus status);
 }
 
 
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 9440a7aa8..763d6e927 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
@@ -31,6 +31,11 @@ public Optional<Order> findById(Long orderId) {
     public List<Order> findAllByUserId(Long userId) {
         return orderJpaRepository.findAllByUserId(userId);
     }
+
+    @Override
+    public List<Order> findAllByStatus(com.loopers.domain.order.OrderStatus status) {
+        return orderJpaRepository.findAllByStatus(status);
+    }
 }
 
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java
new file mode 100644
index 000000000..11cc69f71
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/DelayProvider.java
@@ -0,0 +1,24 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import java.time.Duration;
+
+/**
+ * 지연 제공자 인터페이스.
+ * <p>
+ * 테스트 가능성을 위해 Thread.sleep을 추상화합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+public interface DelayProvider {
+    
+    /**
+     * 지정된 시간만큼 대기합니다.
+     *
+     * @param duration 대기 시간
+     * @throws InterruptedException 인터럽트 발생 시
+     */
+    void delay(Duration duration) throws InterruptedException;
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java
new file mode 100644
index 000000000..2084f2139
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayAdapter.java
@@ -0,0 +1,136 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import com.loopers.application.purchasing.PaymentRequest;
+import com.loopers.domain.order.PaymentResult;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+/**
+ * 결제 게이트웨이 어댑터.
+ * <p>
+ * 인프라 관심사(FeignClient 호출, 예외 처리)를 도메인 모델로 변환합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class PaymentGatewayAdapter {
+    
+    private final PaymentGatewayClient paymentGatewayClient;
+    private final PaymentGatewaySchedulerClient paymentGatewaySchedulerClient;
+    private final PaymentGatewayMetrics metrics;
+    
+    /**
+     * 결제 요청을 전송합니다.
+     *
+     * @param request 결제 요청
+     * @return 결제 결과 (성공 또는 실패)
+     */
+    @CircuitBreaker(name = "pgCircuit", fallbackMethod = "fallback")
+    public PaymentResult requestPayment(PaymentRequest request) {
+        PaymentGatewayDto.PaymentRequest dtoRequest = toDto(request);
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> response =
+            paymentGatewayClient.requestPayment(request.userId(), dtoRequest);
+        
+        return toDomainResult(response, request.orderId());
+    }
+    
+    /**
+     * Circuit Breaker fallback 메서드.
+     *
+     * @param request 결제 요청
+     * @param t 발생한 예외
+     * @return 결제 대기 상태의 실패 결과
+     */
+    public PaymentResult fallback(PaymentRequest request, Throwable t) {
+        log.warn("Circuit Breaker fallback 호출됨. (orderId: {}, exception: {})", 
+            request.orderId(), t.getClass().getSimpleName(), t);
+        metrics.recordFallback("paymentGatewayClient");
+        return new PaymentResult.Failure(
+            "CIRCUIT_BREAKER_OPEN",
+            "결제 대기 상태",
+            false,
+            false,
+            false
+        );
+    }
+    
+    /**
+     * Circuit Breaker fallback 메서드 (결제 상태 조회).
+     *
+     * @param userId 사용자 ID
+     * @param orderId 주문 ID
+     * @param t 발생한 예외
+     * @return PENDING 상태 반환
+     */
+    public PaymentGatewayDto.TransactionStatus getPaymentStatusFallback(String userId, String orderId, Throwable t) {
+        log.warn("Circuit Breaker fallback 호출됨 (결제 상태 조회). (orderId: {}, exception: {})", 
+            orderId, t.getClass().getSimpleName(), t);
+        metrics.recordFallback("paymentGatewaySchedulerClient");
+        return PaymentGatewayDto.TransactionStatus.PENDING;
+    }
+    
+    /**
+     * 결제 상태를 조회합니다.
+     *
+     * @param userId 사용자 ID
+     * @param orderId 주문 ID
+     * @return 결제 상태 (SUCCESS, FAILED, PENDING)
+     */
+    @CircuitBreaker(name = "pgCircuit", fallbackMethod = "getPaymentStatusFallback")
+    public PaymentGatewayDto.TransactionStatus getPaymentStatus(String userId, String orderId) {
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> response =
+            paymentGatewaySchedulerClient.getTransactionsByOrder(userId, orderId);
+        
+        if (response == null || response.meta() == null
+            || response.meta().result() != PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
+            || response.data() == null || response.data().transactions() == null
+            || response.data().transactions().isEmpty()) {
+            return PaymentGatewayDto.TransactionStatus.PENDING;
+        }
+        
+        // 가장 최근 트랜잭션의 상태 반환
+        PaymentGatewayDto.TransactionResponse latestTransaction =
+            response.data().transactions().get(response.data().transactions().size() - 1);
+        return latestTransaction.status();
+    }
+    
+    private PaymentGatewayDto.PaymentRequest toDto(PaymentRequest request) {
+        return new PaymentGatewayDto.PaymentRequest(
+            request.orderId(),
+            request.cardType(),
+            request.cardNo(),
+            request.amount(),
+            request.callbackUrl()
+        );
+    }
+    
+    private PaymentResult toDomainResult(
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> response,
+        String orderId
+    ) {
+        if (response != null && response.meta() != null
+            && response.meta().result() == PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS
+            && response.data() != null) {
+            String transactionKey = response.data().transactionKey();
+            log.info("PG 결제 요청 성공. (orderId: {}, transactionKey: {})", orderId, transactionKey);
+            metrics.recordSuccess("paymentGatewayClient");
+            return new PaymentResult.Success(transactionKey);
+        } else {
+            String errorCode = response != null && response.meta() != null
+                ? response.meta().errorCode() : "UNKNOWN";
+            String message = response != null && response.meta() != null
+                ? response.meta().message() : "응답이 null입니다.";
+            log.warn("PG 결제 요청 실패. (orderId: {}, errorCode: {}, message: {})",
+                orderId, errorCode, message);
+            return new PaymentResult.Failure(errorCode, message, false, false, false);
+        }
+    }
+    
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java
new file mode 100644
index 000000000..9357ec6d3
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClient.java
@@ -0,0 +1,85 @@
+package com.loopers.infrastructure.paymentgateway;
+
+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;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * PG 결제 게이트웨이 FeignClient.
+ * <p>
+ * CircuitBreaker, Bulkhead가 적용되어 있습니다.
+ * </p>
+ * <p>
+ * <b>Bulkhead 패턴:</b>
+ * <ul>
+ *   <li>동시 호출 최대 20개로 제한 (Building Resilient Distributed Systems: 격벽 패턴)</li>
+ *   <li>PG 호출 실패가 다른 API에 영향을 주지 않도록 격리</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>Retry 정책:</b>
+ * <ul>
+ *   <li><b>결제 요청 API (requestPayment):</b> 5xx 서버 오류만 재시도, 4xx 클라이언트 오류는 재시도하지 않음</li>
+ *   <li><b>조회 API (getTransactionsByOrder, getTransaction):</b> Exponential Backoff 적용 (스케줄러 - 비동기/배치 기반 Retry)</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>설계 근거:</b>
+ * <ul>
+ *   <li><b>5xx 서버 오류:</b> 일시적 오류이므로 재시도하여 복구 가능</li>
+ *   <li><b>4xx 클라이언트 오류:</b> 비즈니스 로직 오류이므로 재시도해도 성공하지 않음</li>
+ *   <li><b>Eventually Consistent:</b> 실패 시 주문은 PENDING 상태로 유지되어 스케줄러에서 복구</li>
+ * </ul>
+ * </p>
+ */
+@FeignClient(
+    name = "paymentGatewayClient",
+    url = "${payment-gateway.url}",
+    path = "/api/v1/payments"
+)
+public interface PaymentGatewayClient {
+
+    /**
+     * 결제 요청.
+     *
+     * @param userId 사용자 ID (X-USER-ID 헤더)
+     * @param request 결제 요청 정보
+     * @return 결제 응답
+     */
+    @PostMapping
+    PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> requestPayment(
+        @RequestHeader("X-USER-ID") String userId,
+        @RequestBody PaymentGatewayDto.PaymentRequest request
+    );
+
+    /**
+     * 결제 정보 확인 (트랜잭션 키로 조회).
+     *
+     * @param userId 사용자 ID (X-USER-ID 헤더)
+     * @param transactionKey 트랜잭션 키
+     * @return 결제 상세 정보
+     */
+    @GetMapping("/{transactionKey}")
+    PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionDetailResponse> getTransaction(
+        @RequestHeader("X-USER-ID") String userId,
+        @PathVariable("transactionKey") String transactionKey
+    );
+
+    /**
+     * 주문에 엮인 결제 정보 조회.
+     *
+     * @param userId 사용자 ID (X-USER-ID 헤더)
+     * @param orderId 주문 ID
+     * @return 주문별 결제 목록
+     */
+    @GetMapping
+    PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> getTransactionsByOrder(
+        @RequestHeader("X-USER-ID") String userId,
+        @RequestParam("orderId") String orderId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java
new file mode 100644
index 000000000..812fbed96
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayDto.java
@@ -0,0 +1,106 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * PG 결제 게이트웨이 DTO.
+ */
+public class PaymentGatewayDto {
+
+    /**
+     * PG 결제 요청 DTO.
+     */
+    public record PaymentRequest(
+        @JsonProperty("orderId") String orderId,
+        @JsonProperty("cardType") CardType cardType,
+        @JsonProperty("cardNo") String cardNo,
+        @JsonProperty("amount") Long amount,
+        @JsonProperty("callbackUrl") String callbackUrl
+    ) {
+    }
+
+    /**
+     * PG 결제 응답 DTO.
+     */
+    public record TransactionResponse(
+        @JsonProperty("transactionKey") String transactionKey,
+        @JsonProperty("status") TransactionStatus status,
+        @JsonProperty("reason") String reason
+    ) {
+    }
+
+    /**
+     * PG 결제 상세 응답 DTO.
+     */
+    public record TransactionDetailResponse(
+        @JsonProperty("transactionKey") String transactionKey,
+        @JsonProperty("orderId") String orderId,
+        @JsonProperty("cardType") CardType cardType,
+        @JsonProperty("cardNo") String cardNo,
+        @JsonProperty("amount") Long amount,
+        @JsonProperty("status") TransactionStatus status,
+        @JsonProperty("reason") String reason
+    ) {
+    }
+
+    /**
+     * PG 주문별 결제 목록 응답 DTO.
+     */
+    public record OrderResponse(
+        @JsonProperty("orderId") String orderId,
+        @JsonProperty("transactions") java.util.List<TransactionResponse> transactions
+    ) {
+    }
+
+    /**
+     * 카드 타입.
+     */
+    public enum CardType {
+        SAMSUNG,
+        KB,
+        HYUNDAI
+    }
+
+    /**
+     * 거래 상태.
+     */
+    public enum TransactionStatus {
+        PENDING,
+        SUCCESS,
+        FAILED
+    }
+
+    /**
+     * PG 콜백 요청 DTO (PG에서 보내는 TransactionInfo).
+     */
+    public record CallbackRequest(
+        @JsonProperty("transactionKey") String transactionKey,
+        @JsonProperty("orderId") String orderId,
+        @JsonProperty("cardType") CardType cardType,
+        @JsonProperty("cardNo") String cardNo,
+        @JsonProperty("amount") Long amount,
+        @JsonProperty("status") TransactionStatus status,
+        @JsonProperty("reason") String reason
+    ) {
+    }
+
+    /**
+     * PG API 응답 래퍼.
+     */
+    public record ApiResponse<T>(
+        @JsonProperty("meta") Metadata meta,
+        @JsonProperty("data") T data
+    ) {
+        public record Metadata(
+            @JsonProperty("result") Result result,
+            @JsonProperty("errorCode") String errorCode,
+            @JsonProperty("message") String message
+        ) {
+            public enum Result {
+                SUCCESS,
+                FAIL
+            }
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java
new file mode 100644
index 000000000..57c97df10
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayMetrics.java
@@ -0,0 +1,86 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import io.micrometer.core.instrument.MeterRegistry;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 결제 게이트웨이 메트릭.
+ * <p>
+ * PG 서버 오류, 타임아웃, Fallback 등의 이벤트를 Prometheus 메트릭으로 기록합니다.
+ * </p>
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+@RequiredArgsConstructor
+public class PaymentGatewayMetrics {
+    
+    private final MeterRegistry meterRegistry;
+    
+    /**
+     * PG 서버 오류(5xx) 발생 횟수를 기록합니다.
+     *
+     * @param clientName 클라이언트 이름 (paymentGatewayClient, paymentGatewaySchedulerClient)
+     * @param status HTTP 상태 코드
+     */
+    public void recordServerError(String clientName, int status) {
+        meterRegistry.counter(
+            "payment.gateway.server.error",
+            "client", clientName,
+            "status", String.valueOf(status)
+        ).increment();
+    }
+    
+    /**
+     * PG 타임아웃 발생 횟수를 기록합니다.
+     *
+     * @param clientName 클라이언트 이름
+     */
+    public void recordTimeout(String clientName) {
+        meterRegistry.counter(
+            "payment.gateway.timeout",
+            "client", clientName
+        ).increment();
+    }
+    
+    /**
+     * PG 클라이언트 오류(4xx) 발생 횟수를 기록합니다.
+     *
+     * @param clientName 클라이언트 이름
+     * @param status HTTP 상태 코드
+     */
+    public void recordClientError(String clientName, int status) {
+        meterRegistry.counter(
+            "payment.gateway.client.error",
+            "client", clientName,
+            "status", String.valueOf(status)
+        ).increment();
+    }
+    
+    /**
+     * Fallback 호출 횟수를 기록합니다.
+     *
+     * @param clientName 클라이언트 이름
+     */
+    public void recordFallback(String clientName) {
+        meterRegistry.counter(
+            "payment.gateway.fallback",
+            "client", clientName
+        ).increment();
+    }
+    
+    /**
+     * PG 결제 요청 성공 횟수를 기록합니다.
+     *
+     * @param clientName 클라이언트 이름
+     */
+    public void recordSuccess(String clientName) {
+        meterRegistry.counter(
+            "payment.gateway.request.success",
+            "client", clientName
+        ).increment();
+    }
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java
new file mode 100644
index 000000000..44d693912
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/PaymentGatewaySchedulerClient.java
@@ -0,0 +1,70 @@
+package com.loopers.infrastructure.paymentgateway;
+
+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.RequestHeader;
+import org.springframework.web.bind.annotation.RequestParam;
+
+/**
+ * PG 결제 게이트웨이 FeignClient (스케줄러 전용).
+ * <p>
+ * 스케줄러에서 사용하는 조회 API에 Retry를 적용합니다.
+ * </p>
+ * <p>
+ * <b>Retry 정책:</b>
+ * <ul>
+ *   <li><b>Exponential Backoff 적용:</b> 초기 500ms → 1000ms (최대 5초)</li>
+ *   <li><b>최대 재시도 횟수:</b> 3회 (초기 시도 포함)</li>
+ *   <li><b>재시도 대상:</b> 5xx 서버 오류, 타임아웃, 네트워크 오류</li>
+ * </ul>
+ * </p>
+ * <p>
+ * <b>설계 근거:</b>
+ * <ul>
+ *   <li><b>비동기/배치 기반:</b> 스케줄러는 배치 작업이므로 Retry가 안전하게 적용 가능</li>
+ *   <li><b>일시적 오류 복구:</b> 네트워크 일시적 오류나 PG 서버 일시적 장애 시 자동 복구</li>
+ *   <li><b>유저 요청 스레드 점유 없음:</b> 스케줄러 스레드에서 실행되므로 유저 경험에 영향 없음</li>
+ * </ul>
+ * </p>
+ */
+@FeignClient(
+    name = "paymentGatewaySchedulerClient",
+    url = "${payment-gateway.url}",
+    path = "/api/v1/payments"
+)
+public interface PaymentGatewaySchedulerClient {
+
+    /**
+     * 결제 정보 확인 (트랜잭션 키로 조회).
+     * <p>
+     * 스케줄러에서 사용하며, Retry가 적용됩니다.
+     * </p>
+     *
+     * @param userId 사용자 ID (X-USER-ID 헤더)
+     * @param transactionKey 트랜잭션 키
+     * @return 결제 상세 정보
+     */
+    @GetMapping("/{transactionKey}")
+    PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionDetailResponse> getTransaction(
+        @RequestHeader("X-USER-ID") String userId,
+        @PathVariable("transactionKey") String transactionKey
+    );
+
+    /**
+     * 주문에 엮인 결제 정보 조회.
+     * <p>
+     * 스케줄러에서 사용하며, Retry가 적용됩니다.
+     * </p>
+     *
+     * @param userId 사용자 ID (X-USER-ID 헤더)
+     * @param orderId 주문 ID
+     * @return 주문별 결제 목록
+     */
+    @GetMapping
+    PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> getTransactionsByOrder(
+        @RequestHeader("X-USER-ID") String userId,
+        @RequestParam("orderId") String orderId
+    );
+}
+
diff --git a/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java
new file mode 100644
index 000000000..803a6f304
--- /dev/null
+++ b/apps/commerce-api/src/main/java/com/loopers/infrastructure/paymentgateway/ThreadDelayProvider.java
@@ -0,0 +1,21 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import org.springframework.stereotype.Component;
+
+import java.time.Duration;
+
+/**
+ * Thread.sleep을 사용하는 DelayProvider 구현체.
+ *
+ * @author Loopers
+ * @version 1.0
+ */
+@Component
+public class ThreadDelayProvider implements DelayProvider {
+    
+    @Override
+    public void delay(Duration duration) throws InterruptedException {
+        Thread.sleep(duration.toMillis());
+    }
+}
+
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 62d2512cf..defb715e9 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
@@ -43,4 +43,12 @@ public User findByUserId(String userId) {
     public User findByUserIdForUpdate(String userId) {
         return userJpaRepository.findByUserIdForUpdate(userId).orElse(null);
     }
+    
+    /**
+     * {@inheritDoc}
+     */
+    @Override
+    public User findById(Long id) {
+        return userJpaRepository.findById(id).orElse(null);
+    }
 }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
index 33b77b529..0eba8be22 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/ApiResponse.java
@@ -19,6 +19,10 @@ public static ApiResponse<Object> success() {
         return new ApiResponse<>(Metadata.success(), null);
     }
 
+    public static ApiResponse<Void> successVoid() {
+        return new ApiResponse<>(Metadata.success(), null);
+    }
+
     public static <T> ApiResponse<T> success(T data) {
         return new ApiResponse<>(Metadata.success(), data);
     }
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
index 744ca1b15..fcea74ac2 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Controller.java
@@ -2,6 +2,7 @@
 
 import com.loopers.application.purchasing.OrderInfo;
 import com.loopers.application.purchasing.PurchasingFacade;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
 import com.loopers.interfaces.api.ApiResponse;
 import jakarta.validation.Valid;
 import lombok.RequiredArgsConstructor;
@@ -37,7 +38,12 @@ public ApiResponse<PurchasingV1Dto.OrderResponse> createOrder(
         @RequestHeader("X-USER-ID") String userId,
         @Valid @RequestBody PurchasingV1Dto.CreateRequest request
     ) {
-        OrderInfo orderInfo = purchasingFacade.createOrder(userId, request.toCommands());
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            userId,
+            request.toCommands(),
+            request.payment().cardType(),
+            request.payment().cardNo()
+        );
         return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo));
     }
 
@@ -70,6 +76,38 @@ public ApiResponse<PurchasingV1Dto.OrderResponse> getOrder(
         OrderInfo orderInfo = purchasingFacade.getOrder(userId, orderId);
         return ApiResponse.success(PurchasingV1Dto.OrderResponse.from(orderInfo));
     }
+
+    /**
+     * PG 결제 콜백을 처리합니다.
+     *
+     * @param orderId 주문 ID
+     * @param callbackRequest 콜백 요청 정보
+     * @return 성공 응답
+     */
+    @PostMapping("/{orderId}/callback")
+    public ApiResponse<Void> handlePaymentCallback(
+        @PathVariable Long orderId,
+        @RequestBody PaymentGatewayDto.CallbackRequest callbackRequest
+    ) {
+        purchasingFacade.handlePaymentCallback(orderId, callbackRequest);
+        return ApiResponse.successVoid();
+    }
+
+    /**
+     * 결제 상태 확인 API를 통해 주문 상태를 복구합니다.
+     *
+     * @param userId X-USER-ID 헤더
+     * @param orderId 주문 ID
+     * @return 성공 응답
+     */
+    @PostMapping("/{orderId}/recover")
+    public ApiResponse<Void> recoverOrderStatus(
+        @RequestHeader("X-USER-ID") String userId,
+        @PathVariable Long orderId
+    ) {
+        purchasingFacade.recoverOrderStatusByPaymentCheck(userId, orderId);
+        return ApiResponse.successVoid();
+    }
 }
 
 
diff --git a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
index ce278fc49..e1307ca42 100644
--- a/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
+++ b/apps/commerce-api/src/main/java/com/loopers/interfaces/api/purchasing/PurchasingV1Dto.java
@@ -21,7 +21,8 @@ private PurchasingV1Dto() {
      */
     public record CreateRequest(
         @NotEmpty(message = "주문 상품은 1개 이상이어야 합니다.")
-        List<@Valid ItemRequest> items
+        List<@Valid ItemRequest> items,
+        @Valid PaymentRequest payment
     ) {
         public List<OrderItemCommand> toCommands() {
             return items.stream()
@@ -30,6 +31,17 @@ public List<OrderItemCommand> toCommands() {
         }
     }
 
+    /**
+     * 결제 정보 요청 DTO.
+     */
+    public record PaymentRequest(
+        @NotNull(message = "카드 타입은 필수입니다.")
+        String cardType,
+        @NotNull(message = "카드 번호는 필수입니다.")
+        String cardNo
+    ) {
+    }
+
     /**
      * 주문 생성 요청 아이템 DTO.
      */
diff --git a/apps/commerce-api/src/main/resources/application.yml b/apps/commerce-api/src/main/resources/application.yml
index 0f9239776..f8971a2f0 100644
--- a/apps/commerce-api/src/main/resources/application.yml
+++ b/apps/commerce-api/src/main/resources/application.yml
@@ -29,6 +29,108 @@ spring:
     job:
       enabled: false # 스케줄러에서 수동 실행하므로 자동 실행 비활성화
 
+payment-gateway:
+  url: http://localhost:8082
+
+payment:
+  callback:
+    base-url: ${PAYMENT_CALLBACK_BASE_URL:http://localhost:8080}
+
+feign:
+  client:
+    config:
+      default:
+        connectTimeout: 2000 # 연결 타임아웃 (2초)
+        readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려
+      paymentGatewayClient:
+        connectTimeout: 2000 # 연결 타임아웃 (2초)
+        readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려
+        loggerLevel: full # 로깅 레벨 (디버깅용)
+      paymentGatewaySchedulerClient:
+        connectTimeout: 2000 # 연결 타임아웃 (2초)
+        readTimeout: 6000 # 읽기 타임아웃 (6초) - PG 처리 지연 1s~5s 고려
+        loggerLevel: full # 로깅 레벨 (디버깅용)
+  circuitbreaker:
+    enabled: false # FeignClient 자동 Circuit Breaker 비활성화 (어댑터 레벨에서 pgCircuit 사용)
+  resilience4j:
+    enabled: true # Resilience4j 활성화
+
+resilience4j:
+  circuitbreaker:
+    configs:
+      default:
+        registerHealthIndicator: true
+        slidingWindowSize: 20 # 슬라이딩 윈도우 크기 (Building Resilient Distributed Systems 권장: 20~50)
+        minimumNumberOfCalls: 1 # 최소 호출 횟수 (첫 호출부터 통계 수집하여 메트릭 즉시 노출)
+        permittedNumberOfCallsInHalfOpenState: 3 # Half-Open 상태에서 허용되는 호출 수
+        automaticTransitionFromOpenToHalfOpenEnabled: true # 자동으로 Half-Open으로 전환
+        waitDurationInOpenState: 10s # Open 상태 유지 시간 (10초 후 Half-Open으로 전환)
+        failureRateThreshold: 50 # 실패율 임계값 (50% 이상 실패 시 Open)
+        slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70%
+        slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_SCHEDULER_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능)
+        recordExceptions:
+          - feign.FeignException
+          - feign.FeignException$InternalServerError
+          - feign.FeignException$ServiceUnavailable
+          - feign.FeignException$GatewayTimeout
+          - feign.FeignException$BadGateway
+          - java.net.SocketTimeoutException
+          - java.util.concurrent.TimeoutException
+        ignoreExceptions: [] # 모든 예외를 기록 (무시할 예외 없음)
+    instances:
+      pgCircuit:
+        baseConfig: default
+        slidingWindowSize: 20 # Building Resilient Distributed Systems 권장: 20 (과제 권장값)
+        minimumNumberOfCalls: 1 # 첫 호출부터 통계 수집하여 메트릭 즉시 노출
+        waitDurationInOpenState: 10s
+        failureRateThreshold: 50
+        slowCallRateThreshold: 50 # 느린 호출 비율 임계값 (50% 이상 느리면 Open) - Release It! 권장: 50~70%
+        slowCallDurationThreshold: ${RESILIENCE4J_CIRCUITBREAKER_PAYMENT_GATEWAY_SLOW_CALL_DURATION_THRESHOLD:2s} # 느린 호출 기준 시간 (2초 이상) - Building Resilient Distributed Systems 권장: 2s (환경 변수로 동적 설정 가능)
+  retry:
+    configs:
+      default:
+        maxAttempts: 3 # 최대 재시도 횟수 (초기 시도 포함)
+        waitDuration: 500ms # 재시도 대기 시간 (기본값, paymentGatewayClient는 Java Config에서 Exponential Backoff 적용)
+        retryExceptions:
+          # 일시적 오류만 재시도: 5xx 서버 오류, 타임아웃, 네트워크 오류
+          - feign.FeignException$InternalServerError # 500 에러
+          - feign.FeignException$ServiceUnavailable   # 503 에러
+          - feign.FeignException$GatewayTimeout      # 504 에러
+          - java.net.SocketTimeoutException
+          - java.util.concurrent.TimeoutException
+        ignoreExceptions:
+          # 클라이언트 오류(4xx)는 재시도하지 않음: 비즈니스 로직 오류이므로 재시도해도 성공하지 않음
+          - feign.FeignException$BadRequest      # 400 에러
+          - feign.FeignException$Unauthorized    # 401 에러
+          - feign.FeignException$Forbidden       # 403 에러
+          - feign.FeignException$NotFound         # 404 에러
+  timelimiter:
+    configs:
+      default:
+        timeoutDuration: 6s # 타임아웃 시간 (Feign readTimeout과 동일)
+        cancelRunningFuture: true # 실행 중인 Future 취소
+    instances:
+      paymentGatewayClient:
+        baseConfig: default
+        timeoutDuration: 6s
+      paymentGatewaySchedulerClient:
+        baseConfig: default
+        timeoutDuration: 6s
+  bulkhead:
+    configs:
+      default:
+        maxConcurrentCalls: 20 # 동시 호출 최대 수 (Building Resilient Distributed Systems: 격벽 패턴)
+        maxWaitDuration: 5s # 대기 시간 (5초 초과 시 BulkheadFullException 발생)
+    instances:
+      paymentGatewayClient:
+        baseConfig: default
+        maxConcurrentCalls: 20 # PG 호출용 전용 격벽: 동시 호출 최대 20개로 제한
+        maxWaitDuration: 5s
+      paymentGatewaySchedulerClient:
+        baseConfig: default
+        maxConcurrentCalls: 10 # 스케줄러용 격벽: 동시 호출 최대 10개로 제한 (배치 작업이므로 더 보수적)
+        maxWaitDuration: 5s
+
 springdoc:
   use-fqn: true
   swagger-ui:
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
new file mode 100644
index 000000000..72b6052a8
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeCircuitBreakerTest.java
@@ -0,0 +1,711 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.infrastructure.order.OrderJpaRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.utils.DatabaseCleanUp;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+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.MockitoBean;
+import feign.FeignException;
+import feign.Request;
+
+import java.util.Collections;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.List;
+
+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.*;
+
+/**
+ * PurchasingFacade 서킷 브레이커 테스트.
+ * <p>
+ * 서킷 브레이커의 동작을 검증합니다.
+ * - CLOSED → OPEN 전환 (실패율 임계값 초과)
+ * - OPEN → HALF_OPEN 전환 (일정 시간 후)
+ * - HALF_OPEN → CLOSED 전환 (성공 시)
+ * - HALF_OPEN → OPEN 전환 (실패 시)
+ * - 서킷 브레이커 OPEN 상태에서 Fallback 동작
+ * </p>
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("PurchasingFacade 서킷 브레이커 테스트")
+class PurchasingFacadeCircuitBreakerTest {
+
+    @Autowired
+    private PurchasingFacade purchasingFacade;
+
+    @Autowired
+    private UserRepository userRepository;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private BrandRepository brandRepository;
+
+    @Autowired
+    private OrderRepository orderRepository;
+
+    @Autowired
+    private OrderJpaRepository orderJpaRepository;
+
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @Autowired
+    private CircuitBreakerRegistry circuitBreakerRegistry;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+        // 서킷 브레이커 상태 초기화
+        if (circuitBreakerRegistry != null) {
+            circuitBreakerRegistry.getAllCircuitBreakers().forEach(cb -> cb.reset());
+        }
+    }
+
+    private User createAndSaveUser(String userId, String email, long point) {
+        User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point));
+        return userRepository.save(user);
+    }
+
+    private Brand createAndSaveBrand(String brandName) {
+        Brand brand = Brand.of(brandName);
+        return brandRepository.save(brand);
+    }
+
+    private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) {
+        Product product = Product.of(productName, price, stock, brandId);
+        return productRepository.save(product);
+    }
+
+    @Test
+    @DisplayName("PG 연속 실패 시 서킷 브레이커가 CLOSED에서 OPEN으로 전환된다")
+    void createOrder_consecutiveFailures_circuitBreakerOpens() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 연속 실패 시뮬레이션
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.ServiceUnavailable(
+                "Service unavailable",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // CircuitBreaker를 리셋하여 초기 상태로 만듦
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.reset();
+            }
+        }
+
+        // act
+        // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50%
+        // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨
+        // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고,
+        // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출됨
+        int numberOfCalls = 5;
+        for (int i = 0; i < numberOfCalls; i++) {
+            purchasingFacade.createOrder(
+                user.getUserId(),
+                commands,
+                "SAMSUNG",
+                "4111-1111-1111-1111"
+            );
+        }
+
+        // assert
+        // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3)
+        // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음
+        // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음
+        verify(paymentGatewayClient, atMost(3))
+            .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+        
+        // 서킷 브레이커 상태 확인
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // 연속 실패 후에는 OPEN 상태여야 함
+                // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨
+                assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+            }
+        }
+    }
+
+    @Test
+    @DisplayName("서킷 브레이커가 OPEN 상태일 때 Fallback이 동작한다")
+    void createOrder_circuitBreakerOpen_fallbackExecuted() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 서킷 브레이커를 OPEN 상태로 만듦
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.transitionToOpenState();
+            }
+        }
+
+        // act
+        purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // 서킷 브레이커가 OPEN 상태일 때는 PG API가 호출되지 않아야 함 (Fallback 동작)
+        verify(paymentGatewayClient, never())
+            .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+    }
+
+    @Test
+    @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 성공 시 CLOSED로 전환된다")
+    void createOrder_circuitBreakerHalfOpen_success_transitionsToClosed() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 서킷 브레이커를 HALF_OPEN 상태로 만듦
+        // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // 먼저 OPEN 상태로 전환
+                circuitBreaker.transitionToOpenState();
+                // 그 다음 HALF_OPEN 상태로 전환
+                circuitBreaker.transitionToHalfOpenState();
+            }
+        }
+
+        // PG 성공 응답
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> successResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionResponse(
+                    "TXN123456",
+                    PaymentGatewayDto.TransactionStatus.SUCCESS,
+                    null
+                )
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(successResponse);
+
+        // act
+        purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // 서킷 브레이커 상태가 CLOSED로 전환되었는지 확인
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // 성공 시 CLOSED로 전환될 수 있음
+                assertThat(circuitBreaker.getState()).isIn(
+                    CircuitBreaker.State.CLOSED,
+                    CircuitBreaker.State.HALF_OPEN
+                );
+            }
+        }
+    }
+
+    @Test
+    @DisplayName("서킷 브레이커가 HALF_OPEN 상태에서 실패 시 OPEN으로 전환된다")
+    void createOrder_circuitBreakerHalfOpen_failure_transitionsToOpen() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 서킷 브레이커를 HALF_OPEN 상태로 만듦
+        // 서킷 브레이커는 CLOSED → OPEN → HALF_OPEN 순서로만 전환 가능
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // 먼저 OPEN 상태로 전환
+                circuitBreaker.transitionToOpenState();
+                // 그 다음 HALF_OPEN 상태로 전환
+                circuitBreaker.transitionToHalfOpenState();
+            }
+        }
+
+        // PG 실패 응답
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.ServiceUnavailable(
+                "Service unavailable",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 서킷 브레이커 상태가 OPEN으로 전환되었는지 확인
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // HALF_OPEN 상태에서 실패 시 OPEN으로 전환되어야 함
+                assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+            }
+        }
+    }
+
+    @Test
+    @DisplayName("서킷 브레이커가 OPEN 상태일 때도 내부 시스템은 정상적으로 응답한다")
+    void createOrder_circuitBreakerOpen_internalSystemRespondsNormally() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 서킷 브레이커를 OPEN 상태로 만듦
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.transitionToOpenState();
+            }
+        }
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함)
+        assertThat(orderInfo).isNotNull();
+        assertThat(orderInfo.orderId()).isNotNull();
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 재고와 포인트는 정상적으로 차감되어야 함
+        Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
+        assertThat(savedProduct.getStock()).isEqualTo(9);
+        
+        User savedUser = userRepository.findByUserId(user.getUserId());
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+    }
+
+    @Test
+    @DisplayName("Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어 주문이 PENDING 상태로 유지된다")
+    void createOrder_fallbackResponseWithCircuitBreakerOpen_orderRemainsPending() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 서킷 브레이커를 OPEN 상태로 만들어 Fallback이 호출되도록 함
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.transitionToOpenState();
+            }
+        }
+
+        // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 Mock 설정
+        // (실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 명시적으로 설정)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> fallbackResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                    "CIRCUIT_BREAKER_OPEN",
+                    "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요."
+                ),
+                null
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(fallbackResponse);
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지되어야 함
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+        
+        // 비즈니스 실패 처리(주문 취소)가 호출되지 않았는지 확인
+        // 주문이 CANCELED 상태가 아니어야 함
+        assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED);
+    }
+
+    @Test
+    @DisplayName("Retry 실패 후 CircuitBreaker가 OPEN 상태가 되어 Fallback이 호출된다")
+    void createOrder_retryFailure_circuitBreakerOpens_fallbackExecuted() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 100_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 모든 재시도가 실패하도록 설정 (5xx 서버 오류)
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.InternalServerError(
+                "Internal Server Error",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // CircuitBreaker를 리셋하여 초기 상태로 만듦
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.reset();
+            }
+        }
+
+        // act
+        // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50%
+        // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨
+        // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고 (재시도 포함하여 3번),
+        // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음
+        int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인
+        for (int i = 0; i < numberOfCalls; i++) {
+            purchasingFacade.createOrder(
+                user.getUserId(),
+                commands,
+                "SAMSUNG",
+                "4111-1111-1111-1111"
+            );
+        }
+
+        // assert
+        // 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3)
+        // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음
+        // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음
+        verify(paymentGatewayClient, atMost(3))
+            .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // CircuitBreaker 상태 확인
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함
+                // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨
+                assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+            }
+        }
+
+        // 모든 주문이 PENDING 상태로 생성되었는지 확인
+        // Circuit Breaker가 언제 OPEN 상태로 전환될지 정확히 예측하기 어려우므로,
+        // 최소 1개 이상의 주문이 생성되었는지 확인
+        List<Order> orders = orderJpaRepository.findAll();
+        assertThat(orders.size()).isGreaterThanOrEqualTo(1);
+        orders.forEach(order -> {
+            assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
+        });
+    }
+
+    @Test
+    @DisplayName("Retry 실패 후 Fallback이 호출되고 CIRCUIT_BREAKER_OPEN 응답이 올바르게 처리된다")
+    void createOrder_retryFailure_fallbackCalled_circuitBreakerOpenHandled() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.transitionToOpenState();
+            }
+        }
+
+        // Fallback이 CIRCUIT_BREAKER_OPEN 에러 코드를 반환하도록 설정
+        // 실제로는 PaymentGatewayClientFallback이 호출되지만, 테스트를 위해 Mock으로 시뮬레이션
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> fallbackResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                    "CIRCUIT_BREAKER_OPEN",
+                    "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요."
+                ),
+                null
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(fallbackResponse);
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // 1. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 2. 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+        
+        // 3. CIRCUIT_BREAKER_OPEN은 외부 시스템 장애로 간주되므로 주문 취소가 발생하지 않아야 함
+        assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED);
+        
+        // 4. 재고와 포인트는 정상적으로 차감되어야 함 (주문은 생성되었지만 결제는 PENDING)
+        Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
+        assertThat(savedProduct.getStock()).isEqualTo(9);
+        
+        User savedUser = userRepository.findByUserId(user.getUserId());
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+    }
+
+    @Test
+    @DisplayName("Fallback 응답 처리 로직: CIRCUIT_BREAKER_OPEN 에러 코드는 외부 시스템 장애로 간주되어 주문이 PENDING 상태로 유지된다")
+    void createOrder_fallbackResponse_circuitBreakerOpenErrorCode_orderRemainsPending() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // CircuitBreaker를 OPEN 상태로 만들어 Fallback이 호출되도록 함
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.transitionToOpenState();
+            }
+        }
+
+        // Fallback 응답 시뮬레이션: CIRCUIT_BREAKER_OPEN 에러 코드 반환
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> fallbackResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                    "CIRCUIT_BREAKER_OPEN",  // Fallback이 반환하는 에러 코드
+                    "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요."
+                ),
+                null
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(fallbackResponse);
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // assert
+        // 1. Fallback 응답의 CIRCUIT_BREAKER_OPEN 에러 코드가 올바르게 처리되어야 함
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 2. 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+        
+        // 3. CIRCUIT_BREAKER_OPEN은 비즈니스 실패가 아니므로 주문 취소가 발생하지 않아야 함
+        // PurchasingFacade의 isBusinessFailure() 메서드는 CIRCUIT_BREAKER_OPEN을 false로 반환해야 함
+        assertThat(savedOrder.getStatus()).isNotEqualTo(OrderStatus.CANCELED);
+        
+        // 4. 외부 시스템 장애로 인한 실패이므로 주문은 PENDING 상태로 유지되어 나중에 복구 가능해야 함
+        // (상태 확인 API나 콜백을 통해 나중에 상태를 업데이트할 수 있어야 함)
+    }
+
+    @Test
+    @DisplayName("Retry가 모두 실패한 후 CircuitBreaker가 OPEN 상태가 되면 Fallback이 호출되어 주문이 PENDING 상태로 유지된다")
+    void createOrder_retryExhausted_circuitBreakerOpens_fallbackCalled_orderPending() {
+        // arrange
+        // 6번의 주문 생성 + fallback 테스트 1번 = 총 7번의 주문 생성
+        // 각 주문마다 10,000 포인트가 필요하므로 최소 70,000 포인트 필요
+        // 여유를 두고 100,000 포인트로 설정
+        User user = createAndSaveUser("testuser", "test@example.com", 100_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // 모든 재시도가 실패하도록 설정 (5xx 서버 오류)
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.InternalServerError(
+                "Internal Server Error",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // CircuitBreaker를 리셋하여 초기 상태로 만듦
+        if (circuitBreakerRegistry != null) {
+            CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+            if (circuitBreaker != null) {
+                circuitBreaker.reset();
+            }
+        }
+
+        // act
+        // 서킷 브레이커 설정: minimumNumberOfCalls=1, failureRateThreshold=50%
+        // 첫 번째 호출이 실패하면 100% 실패율이 되어 임계값 50%를 초과하므로 OPEN 상태로 전환됨
+        // 따라서 첫 번째 호출만 실제로 PaymentGatewayClient를 호출하고,
+        // 이후 호출들은 서킷 브레이커가 OPEN 상태이므로 fallback이 호출되어 실제 호출되지 않음
+        int numberOfCalls = 6; // 여러 번 호출하여 서킷 브레이커 동작 확인
+        
+        for (int i = 0; i < numberOfCalls; i++) {
+            purchasingFacade.createOrder(
+                user.getUserId(),
+                commands,
+                "SAMSUNG",
+                "4111-1111-1111-1111"
+            );
+        }
+
+        // CircuitBreaker 상태 확인
+        CircuitBreaker circuitBreaker = null;
+        if (circuitBreakerRegistry != null) {
+            circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+        }
+
+        // assert
+        // 1. 재시도 정책에 따라 5xx 에러는 최대 3번까지 재시도됨 (maxAttempts: 3)
+        // 첫 번째 createOrder 호출에서 재시도가 일어나면서 최대 3번 호출될 수 있음
+        // Circuit Breaker가 OPEN 상태가 되면 이후 호출들은 fallback이 호출되어 실제 호출되지 않음
+        verify(paymentGatewayClient, atMost(3))
+            .requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // 2. CircuitBreaker가 OPEN 상태로 전환되었는지 확인
+        if (circuitBreaker != null) {
+            // 실패율이 임계값을 초과했으므로 OPEN 상태로 전환되어야 함
+            assertThat(circuitBreaker.getState()).isEqualTo(CircuitBreaker.State.OPEN);
+        }
+
+        // 3. CircuitBreaker가 OPEN 상태가 되면 다음 호출에서 Fallback이 호출되어야 함
+        // Fallback 응답 시뮬레이션
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> fallbackResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                    "CIRCUIT_BREAKER_OPEN",
+                    "PG 서비스가 일시적으로 사용할 수 없습니다. 잠시 후 다시 시도해주세요."
+                ),
+                null
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(fallbackResponse);
+
+        // CircuitBreaker를 강제로 OPEN 상태로 만듦 (Fallback 호출 보장)
+        if (circuitBreaker != null) {
+            circuitBreaker.transitionToOpenState();
+        }
+
+        // Fallback이 호출되는 시나리오 테스트
+        OrderInfo fallbackOrderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111"
+        );
+
+        // 4. Fallback 응답이 올바르게 처리되어 주문이 PENDING 상태로 유지되어야 함
+        assertThat(fallbackOrderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 5. 모든 주문이 PENDING 상태로 생성되었는지 확인
+        List<Order> orders = orderJpaRepository.findAll();
+        assertThat(orders.size()).isGreaterThanOrEqualTo(numberOfCalls + 1); // numberOfCalls + fallback 테스트 1번
+        orders.forEach(order -> {
+            assertThat(order.getStatus()).isEqualTo(OrderStatus.PENDING);
+            assertThat(order.getStatus()).isNotEqualTo(OrderStatus.CANCELED);
+        });
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
index 3220b41c0..bdfda839d 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeConcurrencyTest.java
@@ -128,7 +128,7 @@ void concurrencyTest_pointShouldProperlyDecreaseWhenOrderCreated() throws Interr
                     List<OrderItemCommand> commands = List.of(
                         OrderItemCommand.of(products.get(index).getId(), 1)
                     );
-                    purchasingFacade.createOrder(userId, commands);
+                    purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
                     successCount.incrementAndGet();
                 } catch (Exception e) {
                     synchronized (exceptions) {
@@ -176,7 +176,7 @@ void concurrencyTest_stockShouldBeProperlyDecreasedWhenOrdersCreated() throws In
                     List<OrderItemCommand> commands = List.of(
                         OrderItemCommand.of(productId, quantityPerOrder)
                     );
-                    purchasingFacade.createOrder(userId, commands);
+                    purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
                     successCount.incrementAndGet();
                 } catch (Exception e) {
                     synchronized (exceptions) {
@@ -228,7 +228,7 @@ void concurrencyTest_couponShouldBeUsedOnlyOnceWhenOrdersCreated() throws Interr
                     List<OrderItemCommand> commands = List.of(
                         new OrderItemCommand(product.getId(), 1, couponCode)
                     );
-                    purchasingFacade.createOrder(userId, commands);
+                    purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
                     successCount.incrementAndGet();
                 } catch (Exception e) {
                     synchronized (exceptions) {
@@ -277,7 +277,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc
         List<OrderItemCommand> commands = List.of(
             OrderItemCommand.of(productId, orderQuantity)
         );
-        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands);
+        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
         Long orderId = orderInfo.orderId();
         
         // 주문 취소 전 재고 확인 (100 - 5 = 95)
@@ -317,7 +317,7 @@ void concurrencyTest_cancelOrderShouldRestoreStockAccuratelyDuringConcurrentStoc
                     List<OrderItemCommand> otherCommands = List.of(
                         OrderItemCommand.of(productId, 3)
                     );
-                    purchasingFacade.createOrder(userId, otherCommands);
+                    purchasingFacade.createOrder(userId, otherCommands, "SAMSUNG", "4111-1111-1111-1111");
                     orderSuccess.incrementAndGet();
                 } catch (Exception e) {
                     synchronized (exceptions) {
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
new file mode 100644
index 000000000..24195fab4
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentCallbackTest.java
@@ -0,0 +1,307 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+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.MockitoBean;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * PurchasingFacade 결제 콜백 및 상태 확인 테스트.
+ * <p>
+ * PG 결제 콜백 처리 및 상태 확인 API를 통한 복구 로직을 검증합니다.
+ * - 콜백 수신 시 주문 상태 업데이트
+ * - 콜백 미수신 시 상태 확인 API로 복구
+ * - 타임아웃 후 상태 확인 API로 복구
+ * </p>
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("PurchasingFacade 결제 콜백 및 상태 확인 테스트")
+class PurchasingFacadePaymentCallbackTest {
+
+    @Autowired
+    private PurchasingFacade purchasingFacade;
+
+    @Autowired
+    private UserRepository userRepository;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private BrandRepository brandRepository;
+
+    @Autowired
+    private OrderRepository orderRepository;
+
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @MockitoBean
+    private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+    }
+
+    private User createAndSaveUser(String userId, String email, long point) {
+        User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point));
+        return userRepository.save(user);
+    }
+
+    private Brand createAndSaveBrand(String brandName) {
+        Brand brand = Brand.of(brandName);
+        return brandRepository.save(brand);
+    }
+
+    private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) {
+        Product product = Product.of(productName, price, stock, brandId);
+        return productRepository.save(product);
+    }
+
+    @Test
+    @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경된다")
+    void handlePaymentCallback_successCallback_orderStatusUpdatedToCompleted() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청 성공 (트랜잭션 키 반환)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> paymentResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionResponse(
+                    "TXN123456",
+                    PaymentGatewayDto.TransactionStatus.PENDING,
+                    null
+                )
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(paymentResponse);
+
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+        Long orderId = orderInfo.orderId();
+
+        // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> pgInquiryResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.OrderResponse(
+                    String.format("%06d", orderId),
+                    List.of(
+                        new PaymentGatewayDto.TransactionResponse(
+                            "TXN123456",
+                            PaymentGatewayDto.TransactionStatus.SUCCESS,
+                            null
+                        )
+                    )
+                )
+            );
+        when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId))))
+            .thenReturn(pgInquiryResponse);
+
+        // act
+        PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest(
+            "TXN123456",
+            String.format("%06d", orderId),
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            PaymentGatewayDto.TransactionStatus.SUCCESS,
+            null
+        );
+        purchasingFacade.handlePaymentCallback(orderId, callbackRequest);
+
+        // assert
+        Order savedOrder = orderRepository.findById(orderId).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+    }
+
+    @Test
+    @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경된다")
+    void handlePaymentCallback_failureCallback_orderStatusUpdatedToCanceled() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청 성공 (트랜잭션 키 반환)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> paymentResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionResponse(
+                    "TXN123456",
+                    PaymentGatewayDto.TransactionStatus.PENDING,
+                    null
+                )
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(paymentResponse);
+
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+        Long orderId = orderInfo.orderId();
+
+        // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> pgInquiryResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.OrderResponse(
+                    String.format("%06d", orderId),
+                    List.of(
+                        new PaymentGatewayDto.TransactionResponse(
+                            "TXN123456",
+                            PaymentGatewayDto.TransactionStatus.FAILED,
+                            "카드 한도 초과"
+                        )
+                    )
+                )
+            );
+        when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId))))
+            .thenReturn(pgInquiryResponse);
+
+        // act
+        PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest(
+            "TXN123456",
+            String.format("%06d", orderId),
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            PaymentGatewayDto.TransactionStatus.FAILED,
+            "카드 한도 초과"
+        );
+        purchasingFacade.handlePaymentCallback(orderId, callbackRequest);
+
+        // assert
+        Order savedOrder = orderRepository.findById(orderId).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.CANCELED);
+        
+        // 재고와 포인트가 원복되었는지 확인
+        Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
+        assertThat(savedProduct.getStock()).isEqualTo(10);
+        
+        User savedUser = userRepository.findByUserId(user.getUserId());
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(50_000L);
+    }
+
+    @Test
+    @DisplayName("타임아웃 후 상태 확인 API로 주문 상태를 복구할 수 있다")
+    void recoverOrderStatus_afterTimeout_statusRecoveredByStatusCheck() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청 타임아웃
+        String transactionKey = "TXN123456";
+        doThrow(new RuntimeException(new java.net.SocketTimeoutException("Request timeout")))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+        Long orderId = orderInfo.orderId();
+
+        // 상태 확인 API 응답 (결제 성공) - 주문 ID로 조회
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> orderResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.OrderResponse(
+                    String.valueOf(orderId),
+                    List.of(
+                        new PaymentGatewayDto.TransactionResponse(
+                            transactionKey,
+                            PaymentGatewayDto.TransactionStatus.SUCCESS,
+                            null
+                        )
+                    )
+                )
+            );
+        when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(user.getUserId()), eq(String.format("%06d", orderId))))
+            .thenReturn(orderResponse);
+
+        // act
+        purchasingFacade.recoverOrderStatusByPaymentCheck(user.getUserId(), orderId);
+
+        // assert
+        Order savedOrder = orderRepository.findById(orderId).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.COMPLETED);
+    }
+
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
new file mode 100644
index 000000000..6a3f9f837
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadePaymentGatewayTest.java
@@ -0,0 +1,280 @@
+package com.loopers.application.purchasing;
+
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.order.Order;
+import com.loopers.domain.order.OrderRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.Point;
+import com.loopers.domain.user.User;
+import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+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.MockitoBean;
+import feign.FeignException;
+import feign.Request;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.net.SocketTimeoutException;
+import java.util.Collections;
+
+import java.util.List;
+
+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.*;
+
+/**
+ * PurchasingFacade PG 연동 테스트.
+ * <p>
+ * PG 결제 게이트웨이와의 연동에서 발생할 수 있는 다양한 시나리오를 검증합니다.
+ * - PG 연동 실패 시 주문 처리
+ * - 타임아웃 발생 시 주문 상태
+ * - 서킷 브레이커 동작
+ * - 재시도 정책 동작
+ * </p>
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("PurchasingFacade PG 연동 테스트")
+class PurchasingFacadePaymentGatewayTest {
+
+    @Autowired
+    private PurchasingFacade purchasingFacade;
+
+    @Autowired
+    private UserRepository userRepository;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private BrandRepository brandRepository;
+
+    @Autowired
+    private OrderRepository orderRepository;
+
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+        reset(paymentGatewayClient); // Mock 초기화
+    }
+
+    private User createAndSaveUser(String userId, String email, long point) {
+        User user = User.of(userId, email, "1990-01-01", Gender.MALE, Point.of(point));
+        return userRepository.save(user);
+    }
+
+    private Brand createAndSaveBrand(String brandName) {
+        Brand brand = Brand.of(brandName);
+        return brandRepository.save(brand);
+    }
+
+    private Product createAndSaveProduct(String productName, int price, int stock, Long brandId) {
+        Product product = Product.of(productName, price, stock, brandId);
+        return productRepository.save(product);
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청이 타임아웃되어도 주문은 생성되고 PENDING 상태로 유지된다")
+    void createOrder_paymentGatewayTimeout_orderCreatedWithPendingStatus() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청이 타임아웃 발생
+        doThrow(new RuntimeException(new SocketTimeoutException("Request timeout")))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 재고는 차감되었는지 확인
+        Product savedProduct = productRepository.findById(product.getId()).orElseThrow();
+        assertThat(savedProduct.getStock()).isEqualTo(9);
+        
+        // 포인트는 차감되었는지 확인
+        User savedUser = userRepository.findByUserId(user.getUserId());
+        assertThat(savedUser.getPoint().getValue()).isEqualTo(40_000L);
+        
+        // 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청이 실패해도 주문은 생성되고 PENDING 상태로 유지된다")
+    void createOrder_paymentGatewayFailure_orderCreatedWithPendingStatus() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청이 실패 (외부 시스템 장애 - 주문은 PENDING 상태로 유지)
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> failureResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                    "INTERNAL_SERVER_ERROR",  // 외부 시스템 장애로 분류되어 주문이 PENDING 상태로 유지됨
+                    "서버 오류가 발생했습니다"
+                ),
+                null
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(failureResponse);
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+    }
+
+    @Test
+    @DisplayName("PG 서버가 500 에러를 반환해도 주문은 생성되고 PENDING 상태로 유지된다")
+    void createOrder_paymentGatewayServerError_orderCreatedWithPendingStatus() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 서버가 500 에러 반환
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.InternalServerError(
+                "Internal Server Error",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+    }
+
+    @Test
+    @DisplayName("PG 연결이 실패해도 주문은 생성되고 PENDING 상태로 유지된다")
+    void createOrder_paymentGatewayConnectionFailure_orderCreatedWithPendingStatus() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 연결 실패
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.ServiceUnavailable(
+                "Service unavailable",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+
+        // assert
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+        
+        // 주문이 저장되었는지 확인
+        Order savedOrder = orderRepository.findById(orderInfo.orderId()).orElseThrow();
+        assertThat(savedOrder.getStatus()).isEqualTo(OrderStatus.PENDING);
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청이 타임아웃되어도 내부 시스템은 정상적으로 응답한다")
+    void createOrder_paymentGatewayTimeout_internalSystemRespondsNormally() {
+        // arrange
+        User user = createAndSaveUser("testuser", "test@example.com", 50_000L);
+        Brand brand = createAndSaveBrand("브랜드");
+        Product product = createAndSaveProduct("상품", 10_000, 10, brand.getId());
+
+        List<OrderItemCommand> commands = List.of(
+            OrderItemCommand.of(product.getId(), 1)
+        );
+
+        // PG 결제 요청이 타임아웃 발생
+        doThrow(new RuntimeException(new SocketTimeoutException("Request timeout")))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // act
+        OrderInfo orderInfo = purchasingFacade.createOrder(
+            user.getUserId(),
+            commands,
+            "SAMSUNG",
+            "4111-1111-1111-1111" // 유효한 Luhn 알고리즘 통과 카드 번호
+        );
+
+        // assert
+        // 내부 시스템은 정상적으로 응답해야 함 (예외가 발생하지 않아야 함)
+        assertThat(orderInfo).isNotNull();
+        assertThat(orderInfo.orderId()).isNotNull();
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
index 6464e9552..e9b01e55e 100644
--- a/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
+++ b/apps/commerce-api/src/test/java/com/loopers/application/purchasing/PurchasingFacadeTest.java
@@ -15,17 +15,25 @@
 import com.loopers.domain.user.Point;
 import com.loopers.domain.user.User;
 import com.loopers.domain.user.UserRepository;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
 import com.loopers.support.error.CoreException;
 import com.loopers.support.error.ErrorType;
 import com.loopers.utils.DatabaseCleanUp;
 import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 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.MockitoBean;
 
 import java.util.List;
 
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.when;
+
 import static org.assertj.core.api.Assertions.assertThat;
 import static org.assertj.core.api.Assertions.assertThatThrownBy;
 
@@ -56,6 +64,30 @@ class PurchasingFacadeTest {
     
     @Autowired
     private DatabaseCleanUp databaseCleanUp;
+    
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @BeforeEach
+    void setUp() {
+        // 기본적으로 모든 테스트에서 결제 성공 응답을 반환하도록 설정
+        // 개별 테스트에서 필요시 재설정 가능
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> successResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionResponse(
+                    "TXN123456",
+                    PaymentGatewayDto.TransactionStatus.SUCCESS,
+                    null
+                )
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(successResponse);
+    }
 
     @AfterEach
     void tearDown() {
@@ -121,10 +153,11 @@ void createOrder_successFlow() {
         );
 
         // act
-        OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands);
+        OrderInfo orderInfo = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // assert
-        assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
         
         // 재고 차감 확인
         Product savedProduct1 = productRepository.findById(product1.getId()).orElseThrow();
@@ -145,7 +178,7 @@ void createOrder_emptyItems_throwsException() {
         List<OrderItemCommand> emptyCommands = List.of();
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, emptyCommands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
     }
@@ -160,7 +193,7 @@ void createOrder_userNotFound() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(unknownUserId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
     }
@@ -182,7 +215,7 @@ void createOrder_stockNotEnough() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
 
@@ -212,7 +245,7 @@ void createOrder_stockZero() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
 
@@ -242,7 +275,7 @@ void createOrder_pointNotEnough() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
 
@@ -272,7 +305,7 @@ void createOrder_duplicateProducts_throwsException() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
 
@@ -292,14 +325,15 @@ void getOrders_returnsUserOrders() {
         List<OrderItemCommand> commands = List.of(
             OrderItemCommand.of(product.getId(), 1)
         );
-        purchasingFacade.createOrder(user.getUserId(), commands);
+        purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // act
         List<OrderInfo> orders = purchasingFacade.getOrders(user.getUserId());
 
         // assert
         assertThat(orders).hasSize(1);
-        assertThat(orders.get(0).status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(orders.get(0).status()).isEqualTo(OrderStatus.PENDING);
     }
 
     @Test
@@ -313,14 +347,15 @@ void getOrder_returnsSingleOrder() {
         List<OrderItemCommand> commands = List.of(
             OrderItemCommand.of(product.getId(), 1)
         );
-        OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands);
+        OrderInfo createdOrder = purchasingFacade.createOrder(user.getUserId(), commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // act
         OrderInfo found = purchasingFacade.getOrder(user.getUserId(), createdOrder.orderId());
 
         // assert
         assertThat(found.orderId()).isEqualTo(createdOrder.orderId());
-        assertThat(found.status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(found.status()).isEqualTo(OrderStatus.PENDING);
     }
 
     @Test
@@ -338,7 +373,7 @@ void getOrder_withDifferentUser_throwsException() {
         List<OrderItemCommand> commands = List.of(
             OrderItemCommand.of(product.getId(), 1)
         );
-        OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands);
+        OrderInfo user1Order = purchasingFacade.createOrder(user1Id, commands, "SAMSUNG", "4111-1111-1111-1111");
         final Long orderId = user1Order.orderId();
 
         // act & assert
@@ -370,7 +405,7 @@ void createOrder_atomicityGuaranteed() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
 
@@ -412,11 +447,12 @@ void createOrder_success_allOperationsReflected() {
         final int totalAmount = (10_000 * 3) + (15_000 * 2);
 
         // act
-        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands);
+        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // assert
         // 주문이 정상적으로 생성되었는지 확인
-        assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
         assertThat(orderInfo.items()).hasSize(2);
         
         // 재고가 정상적으로 차감되었는지 확인
@@ -452,10 +488,11 @@ void createOrder_withFixedAmountCoupon_success() {
         );
 
         // act
-        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands);
+        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // assert
-        assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
         assertThat(orderInfo.totalAmount()).isEqualTo(5_000); // 10,000 - 5,000 = 5,000
 
         // 쿠폰이 사용되었는지 확인
@@ -481,10 +518,11 @@ void createOrder_withPercentageCoupon_success() {
         );
 
         // act
-        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands);
+        OrderInfo orderInfo = purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111");
 
         // assert
-        assertThat(orderInfo.status()).isEqualTo(OrderStatus.COMPLETED);
+        // createOrder는 주문을 PENDING 상태로 생성하고, PG 결제 요청은 afterCommit 콜백에서 비동기로 실행됨
+        assertThat(orderInfo.status()).isEqualTo(OrderStatus.PENDING);
         assertThat(orderInfo.totalAmount()).isEqualTo(8_000); // 10,000 - (10,000 * 20%) = 8,000
 
         // 쿠폰이 사용되었는지 확인
@@ -507,7 +545,7 @@ void createOrder_withNonExistentCoupon_shouldFail() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
     }
@@ -530,7 +568,7 @@ void createOrder_withCouponNotOwnedByUser_shouldFail() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.NOT_FOUND);
     }
@@ -554,7 +592,7 @@ void createOrder_withUsedCoupon_shouldFail() {
         );
 
         // act & assert
-        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands))
+        assertThatThrownBy(() -> purchasingFacade.createOrder(userId, commands, "SAMSUNG", "4111-1111-1111-1111"))
             .isInstanceOf(CoreException.class)
             .hasFieldOrPropertyWithValue("errorType", ErrorType.BAD_REQUEST);
     }
diff --git a/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java b/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java
new file mode 100644
index 000000000..89b3eef61
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/infrastructure/paymentgateway/PaymentGatewayClientTest.java
@@ -0,0 +1,277 @@
+package com.loopers.infrastructure.paymentgateway;
+
+import com.loopers.utils.DatabaseCleanUp;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.DisplayName;
+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.MockitoBean;
+import feign.FeignException;
+import feign.Request;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.net.SocketTimeoutException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.*;
+
+/**
+ * PaymentGatewayClient 타임아웃 및 실패 처리 테스트.
+ * <p>
+ * 외부 PG 시스템과의 통신에서 발생할 수 있는 다양한 장애 시나리오를 검증합니다.
+ * - 타임아웃 처리
+ * - 네트워크 오류 처리
+ * - 서버 오류 처리
+ * </p>
+ */
+@SpringBootTest
+@ActiveProfiles("test")
+@DisplayName("PaymentGatewayClient 타임아웃 및 실패 처리 테스트")
+class PaymentGatewayClientTest {
+
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+        reset(paymentGatewayClient);
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청 시 타임아웃이 발생하면 적절한 예외가 발생한다")
+    void requestPayment_timeout_throwsException() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 타임아웃 예외 발생
+        SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout");
+        doThrow(new RuntimeException(timeoutException))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+            .isInstanceOf(RuntimeException.class)
+            .hasCauseInstanceOf(SocketTimeoutException.class)
+            .hasMessageContaining("Request timeout");
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청 시 연결 타임아웃이 발생하면 적절한 예외가 발생한다")
+    void requestPayment_connectionTimeout_throwsException() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 연결 실패 예외 발생
+        SocketTimeoutException timeoutException = new SocketTimeoutException("Connection timeout");
+        doThrow(new RuntimeException(timeoutException))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+            .isInstanceOf(RuntimeException.class)
+            .hasCauseInstanceOf(SocketTimeoutException.class)
+            .hasMessageContaining("Connection timeout");
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청 시 읽기 타임아웃이 발생하면 적절한 예외가 발생한다")
+    void requestPayment_readTimeout_throwsException() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 읽기 타임아웃 예외 발생
+        SocketTimeoutException timeoutException = new SocketTimeoutException("Read timed out");
+        doThrow(new RuntimeException(timeoutException))
+            .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+            .isInstanceOf(RuntimeException.class)
+            .hasCauseInstanceOf(SocketTimeoutException.class)
+            .hasMessageContaining("Read timed out");
+    }
+
+    @Test
+    @DisplayName("PG 결제 상태 확인 API 호출 시 타임아웃이 발생하면 적절한 예외가 발생한다")
+    void getTransaction_timeout_throwsException() {
+        // arrange
+        String userId = "testuser";
+        String transactionKey = "TXN123456";
+
+        // Mock 서버에서 타임아웃 예외 발생
+        SocketTimeoutException timeoutException = new SocketTimeoutException("Request timeout");
+        doThrow(new RuntimeException(timeoutException))
+            .when(paymentGatewayClient).getTransaction(anyString(), anyString());
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.getTransaction(userId, transactionKey))
+            .isInstanceOf(RuntimeException.class)
+            .hasCauseInstanceOf(SocketTimeoutException.class)
+            .hasMessageContaining("Request timeout");
+    }
+
+    @Test
+    @DisplayName("PG 서버가 500 에러를 반환하면 적절한 예외가 발생한다")
+    void requestPayment_serverError_throwsException() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 500 에러 반환
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.InternalServerError(
+                "Internal Server Error",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+            .isInstanceOf(FeignException.class)
+            .matches(e -> ((FeignException) e).status() == 500);
+    }
+
+    @Test
+    @DisplayName("PG 서버가 400 에러를 반환하면 적절한 예외가 발생한다")
+    void requestPayment_badRequest_throwsException() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "INVALID_CARD", // 잘못된 카드 번호
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 400 에러 반환
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenThrow(new FeignException.BadRequest(
+                "Bad Request",
+                Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                null,
+                Collections.emptyMap()
+            ));
+
+        // act & assert
+        assertThatThrownBy(() -> paymentGatewayClient.requestPayment(userId, request))
+            .isInstanceOf(FeignException.class)
+            .matches(e -> ((FeignException) e).status() == 400);
+    }
+
+    @Test
+    @DisplayName("PG 결제 요청이 성공하면 정상적인 응답을 받는다")
+    void requestPayment_success_returnsResponse() {
+        // arrange
+        String userId = "testuser";
+        PaymentGatewayDto.PaymentRequest request = new PaymentGatewayDto.PaymentRequest(
+            "ORDER001",
+            PaymentGatewayDto.CardType.SAMSUNG,
+            "4111-1111-1111-1111",
+            10_000L,
+            "http://localhost:8080/api/v1/orders/1/callback"
+        );
+
+        // Mock 서버에서 성공 응답 반환
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> successResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionResponse(
+                    "TXN123456",
+                    PaymentGatewayDto.TransactionStatus.PENDING,
+                    null
+                )
+            );
+        when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+            .thenReturn(successResponse);
+
+        // act
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> response =
+            paymentGatewayClient.requestPayment(userId, request);
+
+        // assert
+        assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS);
+        assertThat(response.data()).isNotNull();
+        assertThat(response.data().transactionKey()).isNotNull();
+    }
+
+    @Test
+    @DisplayName("PG 결제 상태 확인 API가 성공하면 정상적인 응답을 받는다")
+    void getTransaction_success_returnsResponse() {
+        // arrange
+        String userId = "testuser";
+        String transactionKey = "TXN123456";
+
+        // Mock 서버에서 성공 응답 반환
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionDetailResponse> successResponse =
+            new PaymentGatewayDto.ApiResponse<>(
+                new PaymentGatewayDto.ApiResponse.Metadata(
+                    PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                    null,
+                    null
+                ),
+                new PaymentGatewayDto.TransactionDetailResponse(
+                    transactionKey,
+                    "ORDER001",
+                    PaymentGatewayDto.CardType.SAMSUNG,
+                    "4111-1111-1111-1111",
+                    10_000L,
+                    PaymentGatewayDto.TransactionStatus.SUCCESS,
+                    null
+                )
+            );
+        when(paymentGatewayClient.getTransaction(anyString(), anyString()))
+            .thenReturn(successResponse);
+
+        // act
+        PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionDetailResponse> response =
+            paymentGatewayClient.getTransaction(userId, transactionKey);
+
+        // assert
+        assertThat(response.meta().result()).isEqualTo(PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS);
+        assertThat(response.data()).isNotNull();
+        assertThat(response.data().transactionKey()).isEqualTo(transactionKey);
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
new file mode 100644
index 000000000..46e6f964a
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/interfaces/api/PurchasingV1ApiE2ETest.java
@@ -0,0 +1,535 @@
+package com.loopers.interfaces.api;
+
+import com.loopers.application.pointwallet.PointWalletFacade;
+import com.loopers.application.signup.SignUpFacade;
+import com.loopers.domain.brand.Brand;
+import com.loopers.domain.brand.BrandRepository;
+import com.loopers.domain.order.OrderStatus;
+import com.loopers.domain.product.Product;
+import com.loopers.domain.product.ProductRepository;
+import com.loopers.domain.user.Gender;
+import com.loopers.domain.user.UserTestFixture;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayClient;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewayDto;
+import com.loopers.infrastructure.paymentgateway.PaymentGatewaySchedulerClient;
+import com.loopers.interfaces.api.ApiResponse;
+import com.loopers.interfaces.api.purchasing.PurchasingV1Dto;
+import com.loopers.utils.DatabaseCleanUp;
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import org.junit.jupiter.api.AfterEach;
+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.MockitoBean;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import feign.FeignException;
+import feign.Request;
+
+import java.net.SocketTimeoutException;
+import java.util.Collections;
+import org.springframework.core.ParameterizedTypeReference;
+import static org.mockito.Mockito.doThrow;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpMethod;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.*;
+
+/**
+ * PurchasingV1Api E2E 테스트.
+ * <p>
+ * PG 연동 관련 E2E 시나리오를 검증합니다.
+ * - PG 타임아웃 시나리오
+ * - PG 실패 시나리오
+ * - 서킷 브레이커 동작
+ * </p>
+ */
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+@ActiveProfiles("test")
+@DisplayName("PurchasingV1Api E2E 테스트")
+public class PurchasingV1ApiE2ETest {
+
+    private static final String ENDPOINT_ORDERS = "/api/v1/orders";
+
+    @Autowired
+    private TestRestTemplate testRestTemplate;
+
+    @Autowired
+    private SignUpFacade signUpFacade;
+
+    @Autowired
+    private PointWalletFacade pointWalletFacade;
+
+    @Autowired
+    private ProductRepository productRepository;
+
+    @Autowired
+    private BrandRepository brandRepository;
+
+    @MockitoBean
+    private PaymentGatewayClient paymentGatewayClient;
+
+    @MockitoBean
+    private PaymentGatewaySchedulerClient paymentGatewaySchedulerClient;
+
+    @Autowired
+    private CircuitBreakerRegistry circuitBreakerRegistry;
+
+    @Autowired
+    private DatabaseCleanUp databaseCleanUp;
+
+    @AfterEach
+    void tearDown() {
+        databaseCleanUp.truncateAllTables();
+        reset(paymentGatewayClient, paymentGatewaySchedulerClient);
+        // 서킷 브레이커 상태 초기화
+        if (circuitBreakerRegistry != null) {
+            circuitBreakerRegistry.getAllCircuitBreakers().forEach(CircuitBreaker::reset);
+        }
+    }
+
+    // 테스트 데이터 준비 헬퍼 메서드
+    private HttpEntity<PurchasingV1Dto.CreateRequest> createOrderRequest(Long productId) {
+        String userId = UserTestFixture.ValidUser.USER_ID;
+        String email = UserTestFixture.ValidUser.EMAIL;
+        String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+        signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
+        pointWalletFacade.chargePoint(userId, 500_000L);
+
+        Brand brand = Brand.of("테스트 브랜드");
+        Brand savedBrand = brandRepository.save(brand);
+        Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId());
+        Product savedProduct = productRepository.save(product);
+
+        PurchasingV1Dto.CreateRequest requestBody = new PurchasingV1Dto.CreateRequest(
+            List.of(
+                new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
+            ),
+            new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+        );
+
+        HttpHeaders headers = new HttpHeaders();
+        headers.setContentType(MediaType.APPLICATION_JSON);
+        headers.add("X-USER-ID", userId);
+        return new HttpEntity<>(requestBody, headers);
+    }
+
+    @DisplayName("POST /api/v1/orders")
+    @Nested
+    class CreateOrder {
+        @DisplayName("외부 시스템(PG) 장애 시에도 항상 200 응답을 반환한다")
+        @Nested
+        class ExternalSystemFailureIsolation {
+            @DisplayName("PG 타임아웃 시에도 200 응답을 반환한다")
+            @Test
+            void returns200_whenPaymentGatewayTimeout() {
+                // arrange
+                HttpEntity<PurchasingV1Dto.CreateRequest> httpEntity = createOrderRequest(null);
+                doThrow(new RuntimeException(new SocketTimeoutException("Request timeout")))
+                    .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+                // act
+                ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> responseType =
+                    new ParameterizedTypeReference<>() {};
+                ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> response =
+                    testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType);
+
+                // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답
+                assertAll(
+                    () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                    () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS),
+                    () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING)
+                );
+            }
+
+            @DisplayName("PG 서버 500 에러 시에도 200 응답을 반환한다")
+            @Test
+            void returns200_whenPaymentGatewayServerError() {
+                // arrange
+                HttpEntity<PurchasingV1Dto.CreateRequest> httpEntity = createOrderRequest(null);
+                
+                // 서킷 브레이커를 리셋하여 CLOSED 상태로 시작
+                if (circuitBreakerRegistry != null) {
+                    CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker("pgCircuit");
+                    if (circuitBreaker != null) {
+                        circuitBreaker.reset();
+                    }
+                }
+
+                when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+                    .thenThrow(new FeignException.InternalServerError(
+                        "Internal Server Error",
+                        Request.create(Request.HttpMethod.POST, "/api/v1/payments", Collections.emptyMap(), null, null, null),
+                        null,
+                        Collections.emptyMap()
+                    ));
+
+                // act
+                ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> responseType =
+                    new ParameterizedTypeReference<>() {};
+                ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> response =
+                    testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType);
+
+                // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답
+                assertAll(
+                    () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                    () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS),
+                    () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING)
+                );
+            }
+
+            @DisplayName("PG 실패 응답 시에도 200 응답을 반환한다")
+            @Test
+            void returns200_whenPaymentGatewayFailure() {
+                // arrange
+                HttpEntity<PurchasingV1Dto.CreateRequest> httpEntity = createOrderRequest(null);
+                
+                PaymentGatewayDto.ApiResponse<PaymentGatewayDto.TransactionResponse> failureResponse =
+                    new PaymentGatewayDto.ApiResponse<>(
+                        new PaymentGatewayDto.ApiResponse.Metadata(
+                            PaymentGatewayDto.ApiResponse.Metadata.Result.FAIL,
+                            "INTERNAL_SERVER_ERROR",
+                            "PG 서버 내부 오류가 발생했습니다"
+                        ),
+                        null
+                    );
+                when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+                    .thenReturn(failureResponse);
+
+                // act
+                ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> responseType =
+                    new ParameterizedTypeReference<>() {};
+                ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> response =
+                    testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, httpEntity, responseType);
+
+                // assert - 외부 시스템 장애 격리 원칙: 항상 200 응답
+                assertAll(
+                    () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                    () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS),
+                    () -> assertThat(response.getBody().data().status()).isEqualTo(OrderStatus.PENDING)
+                );
+            }
+        }
+
+    }
+
+    @DisplayName("POST /api/v1/orders/{orderId}/callback")
+    @Nested
+    class HandlePaymentCallback {
+        private static final String ENDPOINT_CALLBACK = "/api/v1/orders/{orderId}/callback";
+
+        @DisplayName("PG 결제 성공 콜백을 수신하면 주문 상태가 COMPLETED로 변경되고 200 응답을 반환한다")
+        @Test
+        void returns200_whenPaymentCallbackSuccess() {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
+            pointWalletFacade.chargePoint(userId, 500_000L);
+
+            Brand brand = Brand.of("테스트 브랜드");
+            Brand savedBrand = brandRepository.save(brand);
+            Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId());
+            Product savedProduct = productRepository.save(product);
+
+            // 주문 생성 (PENDING 상태)
+            PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest(
+                List.of(
+                    new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
+                ),
+                new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+            );
+
+            HttpHeaders createHeaders = new HttpHeaders();
+            createHeaders.setContentType(MediaType.APPLICATION_JSON);
+            createHeaders.add("X-USER-ID", userId);
+            HttpEntity<PurchasingV1Dto.CreateRequest> createHttpEntity = new HttpEntity<>(createRequest, createHeaders);
+
+            // PG 결제 요청 성공 Mock
+            when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+                .thenReturn(new PaymentGatewayDto.ApiResponse<>(
+                    new PaymentGatewayDto.ApiResponse.Metadata(
+                        PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                        null,
+                        null
+                    ),
+                    new PaymentGatewayDto.TransactionResponse(
+                        "TXN123456",
+                        PaymentGatewayDto.TransactionStatus.SUCCESS,
+                        null
+                    )
+                ));
+
+            ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponseType =
+                new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponse =
+                testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType);
+
+            Long orderId = createResponse.getBody().data().orderId();
+            String transactionKey = "TXN123456";
+
+            // 콜백 검증을 위한 PG 조회 API Mock (SUCCESS 상태 반환)
+            PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> pgInquiryResponse =
+                new PaymentGatewayDto.ApiResponse<>(
+                    new PaymentGatewayDto.ApiResponse.Metadata(
+                        PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                        null,
+                        null
+                    ),
+                    new PaymentGatewayDto.OrderResponse(
+                        String.format("%06d", orderId),
+                        List.of(
+                            new PaymentGatewayDto.TransactionResponse(
+                                transactionKey,
+                                PaymentGatewayDto.TransactionStatus.SUCCESS,
+                                null
+                            )
+                        )
+                    )
+                );
+            when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId))))
+                .thenReturn(pgInquiryResponse);
+
+            // 콜백 요청 생성
+            PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest(
+                transactionKey,
+                String.format("%06d", orderId),
+                PaymentGatewayDto.CardType.SAMSUNG,
+                "4111-1111-1111-1111",
+                10_000L,
+                PaymentGatewayDto.TransactionStatus.SUCCESS,
+                null
+            );
+
+            HttpHeaders callbackHeaders = new HttpHeaders();
+            callbackHeaders.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<PaymentGatewayDto.CallbackRequest> callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders);
+
+            // act
+            ResponseEntity<ApiResponse<Void>> response = testRestTemplate.exchange(
+                ENDPOINT_CALLBACK,
+                HttpMethod.POST,
+                callbackHttpEntity,
+                new ParameterizedTypeReference<ApiResponse<Void>>() {},
+                orderId
+            );
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                () -> assertThat(response.getBody()).isNotNull(),
+                () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS)
+            );
+        }
+
+        @DisplayName("PG 결제 실패 콜백을 수신하면 주문 상태가 CANCELED로 변경되고 200 응답을 반환한다")
+        @Test
+        void returns200_whenPaymentCallbackFailure() {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
+            pointWalletFacade.chargePoint(userId, 500_000L);
+
+            Brand brand = Brand.of("테스트 브랜드");
+            Brand savedBrand = brandRepository.save(brand);
+            Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId());
+            Product savedProduct = productRepository.save(product);
+
+            // 주문 생성 (PENDING 상태)
+            PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest(
+                List.of(
+                    new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
+                ),
+                new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+            );
+
+            HttpHeaders createHeaders = new HttpHeaders();
+            createHeaders.setContentType(MediaType.APPLICATION_JSON);
+            createHeaders.add("X-USER-ID", userId);
+            HttpEntity<PurchasingV1Dto.CreateRequest> createHttpEntity = new HttpEntity<>(createRequest, createHeaders);
+
+            // PG 결제 요청 성공 Mock
+            when(paymentGatewayClient.requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class)))
+                .thenReturn(new PaymentGatewayDto.ApiResponse<>(
+                    new PaymentGatewayDto.ApiResponse.Metadata(
+                        PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                        null,
+                        null
+                    ),
+                    new PaymentGatewayDto.TransactionResponse(
+                        "TXN123456",
+                        PaymentGatewayDto.TransactionStatus.SUCCESS,
+                        null
+                    )
+                ));
+
+            ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponseType =
+                new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponse =
+                testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType);
+
+            Long orderId = createResponse.getBody().data().orderId();
+            String transactionKey = "TXN123456";
+
+            // 콜백 검증을 위한 PG 조회 API Mock (FAILED 상태 반환)
+            PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> pgInquiryResponse =
+                new PaymentGatewayDto.ApiResponse<>(
+                    new PaymentGatewayDto.ApiResponse.Metadata(
+                        PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                        null,
+                        null
+                    ),
+                    new PaymentGatewayDto.OrderResponse(
+                        String.format("%06d", orderId),
+                        List.of(
+                            new PaymentGatewayDto.TransactionResponse(
+                                transactionKey,
+                                PaymentGatewayDto.TransactionStatus.FAILED,
+                                "카드 한도 초과"
+                            )
+                        )
+                    )
+                );
+            when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId))))
+                .thenReturn(pgInquiryResponse);
+
+            // 콜백 요청 생성 (FAILED 상태)
+            PaymentGatewayDto.CallbackRequest callbackRequest = new PaymentGatewayDto.CallbackRequest(
+                transactionKey,
+                String.format("%06d", orderId),
+                PaymentGatewayDto.CardType.SAMSUNG,
+                "4111-1111-1111-1111",
+                10_000L,
+                PaymentGatewayDto.TransactionStatus.FAILED,
+                "카드 한도 초과"
+            );
+
+            HttpHeaders callbackHeaders = new HttpHeaders();
+            callbackHeaders.setContentType(MediaType.APPLICATION_JSON);
+            HttpEntity<PaymentGatewayDto.CallbackRequest> callbackHttpEntity = new HttpEntity<>(callbackRequest, callbackHeaders);
+
+            // act
+            ResponseEntity<ApiResponse<Void>> response = testRestTemplate.exchange(
+                ENDPOINT_CALLBACK,
+                HttpMethod.POST,
+                callbackHttpEntity,
+                new ParameterizedTypeReference<ApiResponse<Void>>() {},
+                orderId
+            );
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                () -> assertThat(response.getBody()).isNotNull(),
+                () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS)
+            );
+        }
+    }
+
+    @DisplayName("POST /api/v1/orders/{orderId}/recover")
+    @Nested
+    class RecoverOrderStatus {
+        private static final String ENDPOINT_RECOVER = "/api/v1/orders/{orderId}/recover";
+
+        @DisplayName("수동으로 주문 상태를 복구할 수 있다")
+        @Test
+        void returns200_whenOrderStatusRecovered() {
+            // arrange
+            String userId = UserTestFixture.ValidUser.USER_ID;
+            String email = UserTestFixture.ValidUser.EMAIL;
+            String birthDate = UserTestFixture.ValidUser.BIRTH_DATE;
+            signUpFacade.signUp(userId, email, birthDate, Gender.MALE.name());
+            pointWalletFacade.chargePoint(userId, 500_000L);
+
+            Brand brand = Brand.of("테스트 브랜드");
+            Brand savedBrand = brandRepository.save(brand);
+            Product product = Product.of("테스트 상품", 10_000, 10, savedBrand.getId());
+            Product savedProduct = productRepository.save(product);
+
+            // 주문 생성 (타임아웃으로 인해 PENDING 상태 유지)
+            PurchasingV1Dto.CreateRequest createRequest = new PurchasingV1Dto.CreateRequest(
+                List.of(
+                    new PurchasingV1Dto.ItemRequest(savedProduct.getId(), 1)
+                ),
+                new PurchasingV1Dto.PaymentRequest("SAMSUNG", "4111-1111-1111-1111")
+            );
+
+            HttpHeaders createHeaders = new HttpHeaders();
+            createHeaders.setContentType(MediaType.APPLICATION_JSON);
+            createHeaders.add("X-USER-ID", userId);
+            HttpEntity<PurchasingV1Dto.CreateRequest> createHttpEntity = new HttpEntity<>(createRequest, createHeaders);
+
+            // PG 결제 요청 타임아웃 Mock (주문은 PENDING 상태로 유지)
+            doThrow(new RuntimeException(new SocketTimeoutException("Request timeout")))
+                .when(paymentGatewayClient).requestPayment(anyString(), any(PaymentGatewayDto.PaymentRequest.class));
+
+            ParameterizedTypeReference<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponseType =
+                new ParameterizedTypeReference<>() {};
+            ResponseEntity<ApiResponse<PurchasingV1Dto.OrderResponse>> createResponse =
+                testRestTemplate.exchange(ENDPOINT_ORDERS, HttpMethod.POST, createHttpEntity, createResponseType);
+
+            Long orderId = createResponse.getBody().data().orderId();
+            String transactionKey = "TXN123456";
+
+            // 상태 복구를 위한 PG 조회 API Mock (SUCCESS 상태 반환)
+            PaymentGatewayDto.ApiResponse<PaymentGatewayDto.OrderResponse> pgInquiryResponse =
+                new PaymentGatewayDto.ApiResponse<>(
+                    new PaymentGatewayDto.ApiResponse.Metadata(
+                        PaymentGatewayDto.ApiResponse.Metadata.Result.SUCCESS,
+                        null,
+                        null
+                    ),
+                    new PaymentGatewayDto.OrderResponse(
+                        String.format("%06d", orderId),
+                        List.of(
+                            new PaymentGatewayDto.TransactionResponse(
+                                transactionKey,
+                                PaymentGatewayDto.TransactionStatus.SUCCESS,
+                                null
+                            )
+                        )
+                    )
+                );
+            when(paymentGatewaySchedulerClient.getTransactionsByOrder(eq(userId), eq(String.format("%06d", orderId))))
+                .thenReturn(pgInquiryResponse);
+
+            HttpHeaders recoverHeaders = new HttpHeaders();
+            recoverHeaders.add("X-USER-ID", userId);
+            HttpEntity<Void> recoverHttpEntity = new HttpEntity<>(recoverHeaders);
+
+            // act
+            ResponseEntity<ApiResponse<Void>> response = testRestTemplate.exchange(
+                ENDPOINT_RECOVER,
+                HttpMethod.POST,
+                recoverHttpEntity,
+                new ParameterizedTypeReference<ApiResponse<Void>>() {},
+                orderId
+            );
+
+            // assert
+            assertAll(
+                () -> assertTrue(response.getStatusCode().is2xxSuccessful()),
+                () -> assertThat(response.getBody()).isNotNull(),
+                () -> assertThat(response.getBody().meta().result()).isEqualTo(ApiResponse.Metadata.Result.SUCCESS)
+            );
+        }
+    }
+}
+
diff --git a/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java
new file mode 100644
index 000000000..96ce26e96
--- /dev/null
+++ b/apps/commerce-api/src/test/java/com/loopers/testutil/CircuitBreakerTestUtil.java
@@ -0,0 +1,152 @@
+package com.loopers.testutil;
+
+import io.github.resilience4j.circuitbreaker.CircuitBreaker;
+import io.github.resilience4j.circuitbreaker.CircuitBreakerRegistry;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.atomic.AtomicInteger;
+
+/**
+ * Circuit Breaker 테스트 유틸리티.
+ * <p>
+ * Circuit Breaker를 특정 상태로 만들거나, 실패를 유발하여 Circuit Breaker를 열리게 하는 유틸리티 메서드를 제공합니다.
+ * </p>
+ */
+@Component
+public class CircuitBreakerTestUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(CircuitBreakerTestUtil.class);
+    private final CircuitBreakerRegistry circuitBreakerRegistry;
+
+    @Autowired
+    public CircuitBreakerTestUtil(CircuitBreakerRegistry circuitBreakerRegistry) {
+        this.circuitBreakerRegistry = circuitBreakerRegistry;
+    }
+
+    /**
+     * Circuit Breaker를 OPEN 상태로 전환합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름 (예: "paymentGatewayClient")
+     */
+    public void openCircuitBreaker(String circuitBreakerName) {
+        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+        if (circuitBreaker != null) {
+            circuitBreaker.transitionToOpenState();
+            log.info("Circuit Breaker '{}'를 OPEN 상태로 전환했습니다.", circuitBreakerName);
+        } else {
+            log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName);
+        }
+    }
+
+    /**
+     * Circuit Breaker를 HALF_OPEN 상태로 전환합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     */
+    public void halfOpenCircuitBreaker(String circuitBreakerName) {
+        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+        if (circuitBreaker != null) {
+            circuitBreaker.transitionToHalfOpenState();
+            log.info("Circuit Breaker '{}'를 HALF_OPEN 상태로 전환했습니다.", circuitBreakerName);
+        } else {
+            log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName);
+        }
+    }
+
+    /**
+     * Circuit Breaker를 CLOSED 상태로 리셋합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     */
+    public void resetCircuitBreaker(String circuitBreakerName) {
+        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+        if (circuitBreaker != null) {
+            circuitBreaker.reset();
+            log.info("Circuit Breaker '{}'를 리셋했습니다.", circuitBreakerName);
+        } else {
+            log.warn("Circuit Breaker '{}'를 찾을 수 없습니다.", circuitBreakerName);
+        }
+    }
+
+    /**
+     * Circuit Breaker의 현재 상태를 반환합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     * @return Circuit Breaker 상태 (CLOSED, OPEN, HALF_OPEN)
+     */
+    public CircuitBreaker.State getCircuitBreakerState(String circuitBreakerName) {
+        CircuitBreaker circuitBreaker = circuitBreakerRegistry.circuitBreaker(circuitBreakerName);
+        if (circuitBreaker != null) {
+            return circuitBreaker.getState();
+        }
+        return null;
+    }
+
+    /**
+     * 실패를 유발하여 Circuit Breaker를 OPEN 상태로 만듭니다.
+     * <p>
+     * 이 메서드는 실패 임계값을 초과하도록 여러 번 실패를 유발합니다.
+     * </p>
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     * @param failureFunction 실패를 유발하는 함수 (예: PG API 호출)
+     * @param minFailures 최소 실패 횟수 (실패율 임계값을 초과하기 위해 필요한 실패 횟수)
+     */
+    public void triggerCircuitBreakerOpen(String circuitBreakerName, Runnable failureFunction, int minFailures) {
+        log.info("Circuit Breaker '{}'를 OPEN 상태로 만들기 위해 {}번의 실패를 유발합니다.", circuitBreakerName, minFailures);
+        
+        // Circuit Breaker 리셋
+        resetCircuitBreaker(circuitBreakerName);
+        
+        // 실패 유발
+        AtomicInteger failureCount = new AtomicInteger(0);
+        for (int i = 0; i < minFailures; i++) {
+            try {
+                failureFunction.run();
+            } catch (Exception e) {
+                failureCount.incrementAndGet();
+                log.debug("실패 {}번 발생: {}", failureCount.get(), e.getMessage());
+            }
+        }
+        
+        log.info("총 {}번의 실패를 유발했습니다. Circuit Breaker 상태: {}", 
+            failureCount.get(), getCircuitBreakerState(circuitBreakerName));
+    }
+
+    /**
+     * Circuit Breaker가 OPEN 상태인지 확인합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     * @return OPEN 상태이면 true
+     */
+    public boolean isCircuitBreakerOpen(String circuitBreakerName) {
+        CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName);
+        return state == CircuitBreaker.State.OPEN;
+    }
+
+    /**
+     * Circuit Breaker가 HALF_OPEN 상태인지 확인합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     * @return HALF_OPEN 상태이면 true
+     */
+    public boolean isCircuitBreakerHalfOpen(String circuitBreakerName) {
+        CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName);
+        return state == CircuitBreaker.State.HALF_OPEN;
+    }
+
+    /**
+     * Circuit Breaker가 CLOSED 상태인지 확인합니다.
+     *
+     * @param circuitBreakerName Circuit Breaker 이름
+     * @return CLOSED 상태이면 true
+     */
+    public boolean isCircuitBreakerClosed(String circuitBreakerName) {
+        CircuitBreaker.State state = getCircuitBreakerState(circuitBreakerName);
+        return state == CircuitBreaker.State.CLOSED;
+    }
+}
+
