From ac1069e7278e91174a0527e0b389924816794384 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Fri, 13 Jun 2025 23:37:29 +0900 Subject: [PATCH 01/13] =?UTF-8?q?Rename:=20WebClientService=20DTO=20?= =?UTF-8?q?=ED=81=B4=EB=9E=98=EC=8A=A4=EB=AA=85=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20MerchantRequest:=20PG=20->=20=EA=B0=80=EB=A7=B9=EC=A0=90(?= =?UTF-8?q?=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8)=20-=20WebhookReq?= =?UTF-8?q?uest:=20PG=20->=20=EC=99=B8=EB=B6=80=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B2=8C=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20(=EC=9B=B9?= =?UTF-8?q?=ED=9B=85=20=EC=9A=94=EC=B2=AD)=20-=20WebhookResponse:=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EA=B2=B0=EC=A0=9C=20->=20PG=20(=EC=9B=B9?= =?UTF-8?q?=ED=9B=85=20=EC=9D=91=EB=8B=B5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/mock/MockController.java | 16 ++-- .../controller/webhook/WebhookController.java | 6 +- .../domain/dto/webhook/MerchantRequest.java | 18 ++++ .../domain/dto/webhook/WebhookRequest.java | 18 +++- .../domain/dto/webhook/WebhookResponse.java | 18 ++-- .../service/webclient/WebClientService.java | 90 ++++++++++--------- .../service/webhook/WebhookFailedService.java | 8 +- .../service/webhook/WebhookService.java | 3 +- .../service/webhook/WebhookServiceImpl.java | 51 ++++++----- .../webhook/WebhookSuccessService.java | 8 +- 10 files changed, 132 insertions(+), 104 deletions(-) create mode 100644 src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java diff --git a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java index 3c3a79d..fc2bc47 100644 --- a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java +++ b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java @@ -3,8 +3,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; -import me.jaesung.simplepg.domain.dto.webhook.ExternalApiDTO; import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -22,13 +22,19 @@ public class MockController { private final WebClient webClient; + /** + * 외부 서버 역할을 하는 Controller입니다. + * @param webhookRequest + * @return + */ @PostMapping("/request") - public ResponseEntity handleMockData(@RequestBody ExternalApiDTO externalApiDTO) { + public ResponseEntity handleMockData(@RequestBody WebhookRequest webhookRequest) { + // transactionId는 랜덤으로 생성 String transactionId = UUID.randomUUID().toString(); - String paymentKey = externalApiDTO.getPaymentKey(); + String paymentKey = webhookRequest.getPaymentKey(); - WebhookRequest webhookRequest = WebhookRequest.builder() + WebhookResponse webhookResponse = WebhookResponse.builder() .transactionId(transactionId) .paymentStatus("APPROVED") .approvedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) @@ -40,7 +46,7 @@ public ResponseEntity handleMockData(@RequestBody ExternalApiDTO externa webClient.post() .uri("http://localhost:8080/api/protected/webhook/" + paymentKey + "/success") - .bodyValue(webhookRequest) + .bodyValue(webhookResponse) .retrieve() .bodyToMono(String.class) .subscribe( diff --git a/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java b/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java index 576648c..ca41f7a 100644 --- a/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java +++ b/src/main/java/me/jaesung/simplepg/controller/webhook/WebhookController.java @@ -1,6 +1,6 @@ package me.jaesung.simplepg.controller.webhook; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.service.webhook.WebhookService; import me.jaesung.simplepg.service.webhook.WebhookServiceFactory; import org.springframework.web.bind.annotation.*; @@ -20,11 +20,11 @@ public WebhookController(WebhookServiceFactory webhookServiceFactory) { public void webhookProcess( @PathVariable String paymentKey, @PathVariable String webhookStatus, - @RequestBody WebhookRequest webhookRequest) { + @RequestBody WebhookResponse webhookResponse) { WebhookService webhookService = webhookServiceFactory.getWebhookService(webhookStatus); - webhookService.webhookProcess(webhookRequest, paymentKey); + webhookService.webhookProcess(webhookResponse, paymentKey); } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java new file mode 100644 index 0000000..5009e05 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java @@ -0,0 +1,18 @@ +package me.jaesung.simplepg.domain.dto.webhook; + +import lombok.Builder; +import lombok.Data; + +/** + * PG -> 가맹점 + */ +@Data +@Builder +public class MerchantRequest { + private String clientId; + private String paymentKey; + private String amount; + private String orderNo; + private String customerName; + private String methodCode; +} diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java index 325f35c..7597ec4 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java @@ -5,10 +5,20 @@ import lombok.Data; import lombok.NoArgsConstructor; -@Data @Builder -@AllArgsConstructor @NoArgsConstructor +/** + * PG -> 외부 결제 + */ +@Data +@Builder @AllArgsConstructor @NoArgsConstructor public class WebhookRequest { + + private String paymentKey; + private String amount; + private String orderNo; + private String customerName; + private String methodCode; + private String successUrl; + private String failureUrl; private String transactionId; - private String paymentStatus; - private String approvedAt; + private String cancelReason; } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java index 7dd026f..2c5afd5 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java @@ -1,18 +1,14 @@ package me.jaesung.simplepg.domain.dto.webhook; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.NoArgsConstructor; -/** - * PG -> 가맹점 - */ -@Data -@Builder +@Data @Builder +@AllArgsConstructor @NoArgsConstructor public class WebhookResponse { - private String clientId; - private String paymentKey; - private String amount; - private String orderNo; - private String customerName; - private String methodCode; + private String transactionId; + private String paymentStatus; + private String approvedAt; } diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index 69279d7..c56d3d1 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -6,8 +6,8 @@ import me.jaesung.simplepg.common.util.HmacUtil; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; -import me.jaesung.simplepg.domain.dto.webhook.ExternalApiDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.domain.vo.api.ApiCredential; import me.jaesung.simplepg.mapper.ApiCredentialMapper; import org.springframework.beans.factory.annotation.Value; @@ -40,11 +40,13 @@ public WebClientService(@Value("${api.request.url}") String requestApiUrl, /** * 외부 결제 시스템으로 결제 요청 + * 비동기 + 웹훅 (Mono) + * * @param paymentDTO */ public void sendRequest(PaymentDTO paymentDTO) { - ExternalApiDTO externalApiDTO = ExternalApiDTO.builder() + WebhookRequest webhookRequest = WebhookRequest.builder() .paymentKey(paymentDTO.getPaymentKey()) .amount(paymentDTO.getAmount().toString()) .orderNo(paymentDTO.getOrderNo()) @@ -56,7 +58,7 @@ public void sendRequest(PaymentDTO paymentDTO) { webClient.post() .uri(requestApiUrl) - .bodyValue(externalApiDTO) + .bodyValue(webhookRequest) .retrieve() .bodyToMono(String.class) .subscribe( @@ -66,26 +68,27 @@ public void sendRequest(PaymentDTO paymentDTO) { } ); } - + /** * 외부 결제 시스템으로 결제 취소 요청 전송 - * @param paymentDTO 취소할 결제 정보 + * + * @param paymentDTO 취소할 결제 정보 * @param cancelReason 취소 사유(옵션) * @throws PaymentException.ExternalPaymentException 외부 API 호출 실패 시 */ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { try { - log.info("결제 취소 요청 전송: paymentKey={}, transactionId={}", + log.info("결제 취소 요청 전송: paymentKey={}, transactionId={}", paymentDTO.getPaymentKey(), paymentDTO.getTransactionId()); - - ExternalApiDTO cancelRequest = ExternalApiDTO.builder() + + WebhookRequest cancelRequest = WebhookRequest.builder() .paymentKey(paymentDTO.getPaymentKey()) .transactionId(paymentDTO.getTransactionId()) .amount(paymentDTO.getAmount().toString()) .orderNo(paymentDTO.getOrderNo()) .cancelReason(cancelReason != null ? cancelReason : "고객 요청에 의한 취소") .build(); - + // 동기 방식으로 요청 처리 - 취소는 즉시 응답이 필요함 String response = webClient.post() .uri(cancelUrl) @@ -93,7 +96,7 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { .retrieve() .bodyToMono(String.class) .block(); - + log.debug("결제 취소 응답: {}", response); } catch (Exception e) { log.error("결제 취소 요청 실패: {}", e.getMessage(), e); @@ -103,47 +106,52 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { /** * 가맹점 서버에 웹훅 응답 전송 - * @param webhookResponse + * + * @param merchantRequest * @throws Exception */ - public void sendResponse(WebhookResponse webhookResponse) throws Exception { - - String data = plusBody(webhookResponse); - String clientId = webhookResponse.getClientId(); - - ApiCredential apiCredential = apiCredentialMapper.findByClientId(clientId) - .orElseThrow(() -> new ApiException.NotFoundException("해당 clientId가 존재하지 않습니다")); - String clientSecret = apiCredential.getClientSecret(); - - ApiCredentialResponse apiCredentialResponse = ApiCredentialResponse.of(apiCredential); - HttpHeaders httpHeaders = new HttpHeaders(); - httpHeaders.add("X-CLIENT-ID", clientId); - httpHeaders.add("X-TIMESTAMP", LocalDateTime.now().toString()); - httpHeaders.add("X-SIGNATURE", HmacUtil.generateSignature(data,clientSecret)); + public void sendResponse(MerchantRequest merchantRequest) throws Exception { - webClient.post() - .uri(apiCredential.getReturnUrl()) - .headers(h -> h.addAll(httpHeaders)) - .bodyValue(apiCredentialResponse) - .retrieve() - .bodyToMono(ApiCredentialResponse.class) - .block(); + try { + String data = plusBody(merchantRequest); + String clientId = merchantRequest.getClientId(); + + ApiCredential apiCredential = apiCredentialMapper.findByClientId(clientId) + .orElseThrow(() -> new ApiException.NotFoundException("해당 clientId가 존재하지 않습니다")); + String clientSecret = apiCredential.getClientSecret(); + + ApiCredentialResponse apiCredentialResponse = ApiCredentialResponse.of(apiCredential); + HttpHeaders httpHeaders = new HttpHeaders(); + httpHeaders.add("X-CLIENT-ID", clientId); + httpHeaders.add("X-TIMESTAMP", LocalDateTime.now().toString()); + httpHeaders.add("X-SIGNATURE", HmacUtil.generateSignature(data, clientSecret)); + + webClient.post() + .uri(apiCredential.getReturnUrl()) + .headers(h -> h.addAll(httpHeaders)) + .bodyValue(apiCredentialResponse) + .retrieve() + .bodyToMono(ApiCredentialResponse.class) + .subscribe(null,null); + } catch (Exception e) { + log.error("가맹점 서버로의 요청 실패: {}", e.getMessage(), e); + } } - private String plusBody(WebhookResponse webhookResponse) { + private String plusBody(MerchantRequest merchantRequest) { try { - if (webhookResponse == null) { + if (merchantRequest == null) { throw new NullPointerException("webhookResponse가 null입니다"); } StringBuffer sb = new StringBuffer(); - sb.append(webhookResponse.getClientId()); - sb.append(webhookResponse.getPaymentKey()); - sb.append(webhookResponse.getAmount()); - sb.append(webhookResponse.getOrderNo()); - sb.append(webhookResponse.getCustomerName()); - sb.append(webhookResponse.getMethodCode()); + sb.append(merchantRequest.getClientId()); + sb.append(merchantRequest.getPaymentKey()); + sb.append(merchantRequest.getAmount()); + sb.append(merchantRequest.getOrderNo()); + sb.append(merchantRequest.getCustomerName()); + sb.append(merchantRequest.getMethodCode()); return sb.toString(); diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java index 4097bfd..41850b4 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java @@ -1,12 +1,9 @@ package me.jaesung.simplepg.service.webhook; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.exception.PaymentException; -import me.jaesung.simplepg.common.util.DateTimeUtil; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.domain.vo.payment.PaymentLogAction; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; @@ -14,7 +11,6 @@ import me.jaesung.simplepg.mapper.PaymentMapper; import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -27,14 +23,14 @@ public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymen } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.FAILED.toString())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { paymentDTO.setStatus(PaymentStatus.FAILED); paymentDTO.setTransactionId(webhookRequest.getTransactionId()); diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java index 0b4b293..08e4caa 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookService.java @@ -1,8 +1,7 @@ package me.jaesung.simplepg.service.webhook; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; public interface WebhookService { - void webhookProcess(WebhookRequest webhookRequest, String paymentKey); + void webhookProcess(WebhookResponse webhookRequest, String paymentKey); } diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java index d96283f..27d3865 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java @@ -4,8 +4,8 @@ import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.exception.PaymentException; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; @@ -23,45 +23,44 @@ public abstract class WebhookServiceImpl implements WebhookService { protected final WebClientService webClientService; @Override - @Transactional - public void webhookProcess(WebhookRequest webhookRequest, String paymentKey) { - try { + public void webhookProcess(WebhookResponse webhookRequest, String paymentKey) { - PaymentDTO paymentDTO = paymentMapper.findByPaymentKeyWithLock(paymentKey) - .orElseThrow(() -> new PaymentException.InvalidPaymentRequestException("결제 정보를 찾을 수 없습니다")); + MerchantRequest merchantRequest = processPaymentTransaction(webhookRequest, paymentKey); - if (paymentDTO.getStatus() != PaymentStatus.READY) { - throw new PaymentException.InvalidPaymentRequestException("이미 처리된 결제 내역 입니다"); - } + webClientService.sendResponse(merchantRequest); + } - validatePayment(paymentDTO, webhookRequest); + @Transactional + public MerchantRequest processPaymentTransaction(WebhookResponse webhookRequest, String paymentKey) { + PaymentDTO paymentDTO = paymentMapper.findByPaymentKeyWithLock(paymentKey) + .orElseThrow(() -> new PaymentException.InvalidPaymentRequestException("결제 정보를 찾을 수 없습니다")); - processPaymentStatus(paymentDTO, webhookRequest); + if (paymentDTO.getStatus() != PaymentStatus.READY) { + throw new PaymentException.InvalidPaymentRequestException("이미 처리된 결제 내역 입니다"); + } - WebhookResponse webhookResponse = WebhookResponse.builder() - .clientId(paymentDTO.getClientId()) - .paymentKey(paymentKey) - .amount(paymentDTO.getAmount().toString()) - .orderNo(paymentDTO.getOrderNo()) - .customerName(paymentDTO.getCustomerName()) - .methodCode(paymentDTO.getMethodCode().toString()) - .build(); + validatePayment(paymentDTO, webhookRequest); - webClientService.sendResponse(webhookResponse); + processPaymentStatus(paymentDTO, webhookRequest); - } catch (Exception e) { - log.error("웹훅 처리 실패: paymentKey={}, 원인={}", paymentKey, e.getMessage(), e); - throw new PaymentException.WebhookProcessingException("웹훅 수신 데이터 처리 실패"); - } + MerchantRequest merchantRequest = MerchantRequest.builder() + .clientId(paymentDTO.getClientId()) + .paymentKey(paymentKey) + .amount(paymentDTO.getAmount().toString()) + .orderNo(paymentDTO.getOrderNo()) + .customerName(paymentDTO.getCustomerName()) + .methodCode(paymentDTO.getMethodCode().toString()) + .build(); + return merchantRequest; } /** * 결제 정보와 웹훅 요청 검증 */ - protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest); + protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest); /** * 결제 상태 처리 */ - protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest); + protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest); } diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java index 63ccc44..c6bfcd8 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java @@ -1,12 +1,9 @@ package me.jaesung.simplepg.service.webhook; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.exception.PaymentException; -import me.jaesung.simplepg.common.util.DateTimeUtil; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; -import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; import me.jaesung.simplepg.domain.vo.payment.PaymentLogAction; import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; @@ -14,7 +11,6 @@ import me.jaesung.simplepg.mapper.PaymentMapper; import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @@ -28,14 +24,14 @@ public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper payme } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookRequest webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { paymentDTO.setStatus(PaymentStatus.APPROVED); paymentDTO.setApprovedAt(LocalDateTime.parse(webhookRequest.getApprovedAt())); paymentDTO.setTransactionId(webhookRequest.getTransactionId()); From c007db6a17f71ee2ef7f339008114334e257790e Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Sat, 14 Jun 2025 23:53:23 +0900 Subject: [PATCH 02/13] =?UTF-8?q?Feat:=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=9A=94=EC=B2=AD=20=EC=A0=84=EC=86=A1=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81(PG->=EA=B0=80=EB=A7=B9=EC=A0=90)=20-=20Appli?= =?UTF-8?q?cationEventPublisher=20=ED=99=9C=EC=9A=A9=20-=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=EA=B3=BC=20=EC=99=B8=EB=B6=80=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/PaymentProcessedEvent.java | 13 ++++++ .../event/handler/PaymentEventHandler.java | 39 ++++++++++++++++ .../service/payment/PaymentService.java | 6 +++ .../service/webclient/WebClientService.java | 4 +- .../service/webhook/WebhookFailedService.java | 5 ++- .../service/webhook/WebhookServiceImpl.java | 44 +++++++++++++------ .../webhook/WebhookSuccessService.java | 6 +-- 7 files changed, 96 insertions(+), 21 deletions(-) create mode 100644 src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java create mode 100644 src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java diff --git a/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java b/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java new file mode 100644 index 0000000..d5e6b20 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/common/event/PaymentProcessedEvent.java @@ -0,0 +1,13 @@ +package me.jaesung.simplepg.common.event; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; + +@Getter +@AllArgsConstructor +public class PaymentProcessedEvent { + + private final MerchantRequest merchantRequest; + +} diff --git a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java new file mode 100644 index 0000000..721685f --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java @@ -0,0 +1,39 @@ +package me.jaesung.simplepg.common.event.handler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import me.jaesung.simplepg.common.event.PaymentProcessedEvent; +import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; +import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentEventHandler { + private final WebClientService webClientService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + @Retryable(value = {Exception.class}, backoff = @Backoff(delay = 3000)) + public void handlePaymentEvent(PaymentProcessedEvent event) { + try { + log.info("Payment 정상 커밋 완료 후 비동기 요청 실행: {}", event.getMerchantRequest().getPaymentKey()); + webClientService.sendResponse(event.getMerchantRequest()); + } catch (Exception e) { + log.error("Payment -> Client 결제 정보 전송 실패 및 재시도 예정: {}", event.getMerchantRequest().getPaymentKey(), e); + } + } + + @Recover + public void retryFailed(Exception e, PaymentProcessedEvent event){ + log.error("Payment -> Client 결제 정보 최종 실패: {}", event.getMerchantRequest().getPaymentKey(), e); + + } +} diff --git a/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java b/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java index 2415e60..509e9c0 100644 --- a/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java +++ b/src/main/java/me/jaesung/simplepg/service/payment/PaymentService.java @@ -173,6 +173,12 @@ private void validatePaymentStatusTransition(PaymentStatus currentStatus, Paymen } } + /** + * 결제 생성 메서드 + * @param request + * @param amountBD + * @return + */ private PaymentDTO createPayment(PaymentRequest request, BigDecimal amountBD) { String paymentId = String.valueOf(UUID.randomUUID()); String orderNo = request.getOrderNo(); diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index c56d3d1..715c22e 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -110,7 +110,7 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { * @param merchantRequest * @throws Exception */ - public void sendResponse(MerchantRequest merchantRequest) throws Exception { + public void sendResponse(MerchantRequest merchantRequest) { try { String data = plusBody(merchantRequest); @@ -132,7 +132,7 @@ public void sendResponse(MerchantRequest merchantRequest) throws Exception { .bodyValue(apiCredentialResponse) .retrieve() .bodyToMono(ApiCredentialResponse.class) - .subscribe(null,null); + .subscribe(null, null); } catch (Exception e) { log.error("가맹점 서버로의 요청 실패: {}", e.getMessage(), e); diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java index 41850b4..a1897fa 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java @@ -10,6 +10,7 @@ import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -18,8 +19,8 @@ @Slf4j public class WebhookFailedService extends WebhookServiceImpl { - public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, WebClientService webClientService) { - super(paymentMapper, paymentLogMapper, webClientService); + public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); } @Override diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java index 27d3865..784487d 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java @@ -2,6 +2,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import me.jaesung.simplepg.common.event.PaymentProcessedEvent; import me.jaesung.simplepg.common.exception.PaymentException; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; @@ -10,6 +11,7 @@ import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,18 +22,32 @@ public abstract class WebhookServiceImpl implements WebhookService { protected final PaymentMapper paymentMapper; protected final PaymentLogMapper paymentLogMapper; - protected final WebClientService webClientService; + private final ApplicationEventPublisher eventPublisher; + /** + * 1.상점 서버로 보낼 결제 및 로그 상태 처리 및 저장 + * 2.가맹점 서버로 보낼 DTO 와 함께 이벤트 발생 (비동기 요청) + * + * @param webhookResponse (결제 서버로 부터 웹훅으로 받은 데이터) + * @param paymentKey (결제 고유 키 -> URL 파라미터로 받음) + */ + @Transactional @Override - public void webhookProcess(WebhookResponse webhookRequest, String paymentKey) { + public void webhookProcess(WebhookResponse webhookResponse, String paymentKey) { + + MerchantRequest merchantRequest = paymentTransaction(webhookResponse, paymentKey); - MerchantRequest merchantRequest = processPaymentTransaction(webhookRequest, paymentKey); + eventPublisher.publishEvent(new PaymentProcessedEvent(merchantRequest)); - webClientService.sendResponse(merchantRequest); } - @Transactional - public MerchantRequest processPaymentTransaction(WebhookResponse webhookRequest, String paymentKey) { + /** + * 상점 서버로 보낼 결제 및 로그 상태 처리 및 저장 + * @param webhookResponse (결제 서버로 부터 웹훅으로 받은 데이터) + * @param paymentKey (결제 고유 키 -> URL 파라미터로 받음) + * @return MerchantRequest (상점 서버로 보낼 데이터 ) + */ + private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, String paymentKey) { PaymentDTO paymentDTO = paymentMapper.findByPaymentKeyWithLock(paymentKey) .orElseThrow(() -> new PaymentException.InvalidPaymentRequestException("결제 정보를 찾을 수 없습니다")); @@ -39,11 +55,11 @@ public MerchantRequest processPaymentTransaction(WebhookResponse webhookRequest, throw new PaymentException.InvalidPaymentRequestException("이미 처리된 결제 내역 입니다"); } - validatePayment(paymentDTO, webhookRequest); + processPaymentStatus(paymentDTO, webhookResponse); - processPaymentStatus(paymentDTO, webhookRequest); + validatePayment(paymentDTO, webhookResponse); - MerchantRequest merchantRequest = MerchantRequest.builder() + return MerchantRequest.builder() .clientId(paymentDTO.getClientId()) .paymentKey(paymentKey) .amount(paymentDTO.getAmount().toString()) @@ -51,16 +67,16 @@ public MerchantRequest processPaymentTransaction(WebhookResponse webhookRequest, .customerName(paymentDTO.getCustomerName()) .methodCode(paymentDTO.getMethodCode().toString()) .build(); - return merchantRequest; } + /** - * 결제 정보와 웹훅 요청 검증 + * 결제 정보와 웹훅 요청 검증 (Success/Failed에 따라 분기) */ - protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest); + protected abstract void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse); /** - * 결제 상태 처리 + * 결제 상태 처리 (Success/Failed에 따라 분기) */ - protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest); + protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse); } diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java index c6bfcd8..c15e33d 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java @@ -9,7 +9,7 @@ import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -19,8 +19,8 @@ @Slf4j public class WebhookSuccessService extends WebhookServiceImpl { - public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, WebClientService webClientService) { - super(paymentMapper, paymentLogMapper, webClientService); + public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); } @Override From f5219ebcc9a134192a049b8b759ec1080e65784b Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:54:38 +0900 Subject: [PATCH 03/13] =?UTF-8?q?Feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/jaesung/simplepg/config/AppConfig.java | 15 ++++--- .../jaesung/simplepg/config/AsyncConfig.java | 39 +++++++++++++++++++ 2 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 src/main/java/me/jaesung/simplepg/config/AsyncConfig.java diff --git a/src/main/java/me/jaesung/simplepg/config/AppConfig.java b/src/main/java/me/jaesung/simplepg/config/AppConfig.java index b32de6e..7a8c99a 100644 --- a/src/main/java/me/jaesung/simplepg/config/AppConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AppConfig.java @@ -9,13 +9,15 @@ import org.mybatis.spring.SqlSessionFactoryBean; import org.mybatis.spring.annotation.MapperScan; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.annotation.*; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; -import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; -import org.springframework.web.client.RestTemplate; import org.springframework.web.reactive.function.client.WebClient; import reactor.netty.http.client.HttpClient; @@ -30,7 +32,7 @@ @MapperScan(basePackages = "me.jaesung.simplepg.mapper") @ComponentScan(basePackages = "me.jaesung.simplepg.**") @EnableTransactionManagement -public class AppConfig { +public class AppConfig implements AsyncConfigurer { @Value("${spring.datasource.driver-class-name}") String driver; @@ -67,7 +69,7 @@ public SqlSessionFactory sqlSessionFactory() throws Exception { } @Bean - public DataSourceTransactionManager transactionManager() { + public PlatformTransactionManager transactionManager() { return new DataSourceTransactionManager(dataSource()); } @@ -90,6 +92,9 @@ public WebClient webClient() { .build(); } - + @Bean + public ApplicationEventPublisher applicationEventPublisher(ApplicationContext applicationContext) { + return applicationContext; + } } diff --git a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java new file mode 100644 index 0000000..d61d78f --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java @@ -0,0 +1,39 @@ +package me.jaesung.simplepg.config; + +import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; +import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.EventListenerMethodProcessor; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; + +@Configuration +@EnableAsync +public class AsyncConfig implements AsyncConfigurer { + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("async-"); + executor.initialize(); + return executor; + } + + @Override + public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { + return new SimpleAsyncUncaughtExceptionHandler(); + } + + @Bean + public EventListenerMethodProcessor eventListenerMethodProcessor() { + return new EventListenerMethodProcessor(); + } + +} \ No newline at end of file From a70da7ed781d27c7912e3063291967db8ca1867c Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Mon, 16 Jun 2025 23:54:44 +0900 Subject: [PATCH 04/13] =?UTF-8?q?Feat:=20=EB=B9=84=EB=8F=99=EA=B8=B0=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EC=84=A4=EC=A0=95=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/me/jaesung/simplepg/config/WebConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/me/jaesung/simplepg/config/WebConfig.java b/src/main/java/me/jaesung/simplepg/config/WebConfig.java index 3aaa7a4..e7cee68 100644 --- a/src/main/java/me/jaesung/simplepg/config/WebConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/WebConfig.java @@ -7,7 +7,7 @@ public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer { @Override protected Class[] getRootConfigClasses() { - return new Class[]{AppConfig.class, SecurityConfig.class}; + return new Class[]{AppConfig.class, SecurityConfig.class, AsyncConfig.class}; } @Override From cbf835a75923cd67f3469b08ace92e7e874f7247 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:08:53 +0900 Subject: [PATCH 05/13] =?UTF-8?q?Fix:=20=EC=A4=91=EB=B3=B5=20=EB=B9=88=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=B4=20@Tr?= =?UTF-8?q?ansactional=EC=9D=B4=20=EB=8F=99=EC=9E=91=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0=20?= =?UTF-8?q?-=20ServletConfig=EC=97=90=EC=84=9C=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=8A=A4?= =?UTF-8?q?=EC=BA=94=EC=9D=84=20=EC=A0=9C=EA=B1=B0=ED=95=98=EC=97=AC=20?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20=EB=B9=88=20=EB=93=B1=EB=A1=9D=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20-=20AsyncConfig=EC=97=90=20@EnableRetry=EB=A5=BC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=ED=95=98=EC=97=AC=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=8B=9C=EB=8F=84=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../me/jaesung/simplepg/config/AppConfig.java | 17 +++++++++-------- .../me/jaesung/simplepg/config/AsyncConfig.java | 3 ++- .../jaesung/simplepg/config/ServletConfig.java | 9 ++------- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/src/main/java/me/jaesung/simplepg/config/AppConfig.java b/src/main/java/me/jaesung/simplepg/config/AppConfig.java index 7a8c99a..fb1549f 100644 --- a/src/main/java/me/jaesung/simplepg/config/AppConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AppConfig.java @@ -16,6 +16,7 @@ import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.stereotype.Controller; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.EnableTransactionManagement; import org.springframework.web.reactive.function.client.WebClient; @@ -30,8 +31,9 @@ @PropertySource(value = "classpath:application-${spring.profiles.active}.properties", ignoreResourceNotFound = true) @MapperScan(basePackages = "me.jaesung.simplepg.mapper") -@ComponentScan(basePackages = "me.jaesung.simplepg.**") -@EnableTransactionManagement +@ComponentScan(basePackages = "me.jaesung.simplepg.**", + excludeFilters = @ComponentScan.Filter(type = FilterType.ANNOTATION, classes = Controller.class)) +@EnableTransactionManagement(mode = AdviceMode.PROXY, proxyTargetClass = true) public class AppConfig implements AsyncConfigurer { @Value("${spring.datasource.driver-class-name}") @@ -68,12 +70,6 @@ public SqlSessionFactory sqlSessionFactory() throws Exception { return sqlSessionFactory.getObject(); } - @Bean - public PlatformTransactionManager transactionManager() { - return new DataSourceTransactionManager(dataSource()); - } - - @Bean public ObjectMapper objectMapper() { return new ObjectMapper(); @@ -92,6 +88,11 @@ public WebClient webClient() { .build(); } + @Bean + public PlatformTransactionManager transactionManager(DataSource dataSource) { + return new DataSourceTransactionManager(dataSource); + } + @Bean public ApplicationEventPublisher applicationEventPublisher(ApplicationContext applicationContext) { return applicationContext; diff --git a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java index d61d78f..68e2116 100644 --- a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java @@ -5,6 +5,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.event.EventListenerMethodProcessor; +import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -13,6 +14,7 @@ @Configuration @EnableAsync +@EnableRetry public class AsyncConfig implements AsyncConfigurer { @Override @@ -35,5 +37,4 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { public EventListenerMethodProcessor eventListenerMethodProcessor() { return new EventListenerMethodProcessor(); } - } \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java index ffabe64..4860855 100644 --- a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java @@ -1,10 +1,8 @@ package me.jaesung.simplepg.config; import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.ComponentScan; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.PropertySource; +import org.springframework.context.annotation.*; +import org.springframework.stereotype.Controller; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.support.StandardServletMultipartResolver; import org.springframework.web.servlet.config.annotation.*; @@ -15,7 +13,6 @@ @PropertySource(value = "classpath:application-${spring.profiles.active}.properties", ignoreResourceNotFound = true) @ComponentScan(basePackages = "me.jaesung.simplepg.controller") -@ComponentScan(basePackages = "me.jaesung.simplepg.service") public class ServletConfig implements WebMvcConfigurer { @Value("${server.URI}") @@ -29,7 +26,6 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .addResourceLocations("/resources/assets/"); } - @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") @@ -42,5 +38,4 @@ public void addCorsMappings(CorsRegistry registry) { public MultipartResolver multipartResolver() { return new StandardServletMultipartResolver(); } - } \ No newline at end of file From 30eb95bf6c7441df2a68ca2571e563fcee441179 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Tue, 17 Jun 2025 21:11:26 +0900 Subject: [PATCH 06/13] =?UTF-8?q?Fix:=20sendResponse(=EA=B0=80=EB=A7=B9?= =?UTF-8?q?=EC=A0=90=20=EC=9D=91=EB=8B=B5)=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20doOnSuccess/doOnError=EB=A1=9C=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/webclient/WebClientService.java | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index 715c22e..20fa88a 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -14,6 +14,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; import java.time.LocalDateTime; @@ -104,14 +105,13 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { } } + /** * 가맹점 서버에 웹훅 응답 전송 * * @param merchantRequest - * @throws Exception */ public void sendResponse(MerchantRequest merchantRequest) { - try { String data = plusBody(merchantRequest); String clientId = merchantRequest.getClientId(); @@ -132,10 +132,17 @@ public void sendResponse(MerchantRequest merchantRequest) { .bodyValue(apiCredentialResponse) .retrieve() .bodyToMono(ApiCredentialResponse.class) - .subscribe(null, null); + .doOnSuccess(response -> { + log.info("가맹점 서버 응답 성공: {}", response); + }) + .doOnError(error -> { + log.error("가맹점 서버 요청 실패: {}", error.getMessage(), error); + }) + .onErrorResume(error -> Mono.empty()) + .subscribe(); } catch (Exception e) { - log.error("가맹점 서버로의 요청 실패: {}", e.getMessage(), e); + log.error("가맹점 서버로의 요청 준비 실패: {}", e.getMessage(), e); } } From 3b20285410fc880aee5f78eb2268ed618595be64 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Wed, 18 Jun 2025 23:51:26 +0900 Subject: [PATCH 07/13] =?UTF-8?q?Fix:=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EA=B4=80=EB=A0=A8(=EC=9A=94=EC=B2=AD=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EB=B0=8F=20DTO)=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=B7=A8=EC=86=8C=20DTO=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(CancelRequest)=20-=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=99=B8=EB=B6=80=20=EC=9A=94=EC=B2=AD=20DTO=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20(WebhookRequest,=20WebhookResponse)=20-=20Mock=20?= =?UTF-8?q?=EC=84=9C=EB=B2=84=20handleCancelData=20(MockController)=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/mock/MockController.java | 48 +++++++++++++++++-- .../domain/dto/webhook/CancelRequest.java | 26 ++++++++++ .../domain/dto/webhook/WebhookRequest.java | 9 ++-- .../domain/dto/webhook/WebhookResponse.java | 2 +- .../service/webclient/WebClientService.java | 26 ++++------ 5 files changed, 86 insertions(+), 25 deletions(-) create mode 100644 src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java diff --git a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java index fc2bc47..77476d8 100644 --- a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java +++ b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java @@ -3,8 +3,10 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; +import me.jaesung.simplepg.domain.dto.webhook.CancelRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -24,6 +26,7 @@ public class MockController { /** * 외부 서버 역할을 하는 Controller입니다. + * * @param webhookRequest * @return */ @@ -37,7 +40,7 @@ public ResponseEntity handleMockData(@RequestBody WebhookRequest webhook WebhookResponse webhookResponse = WebhookResponse.builder() .transactionId(transactionId) .paymentStatus("APPROVED") - .approvedAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) + .createdAt(LocalDateTime.now().format(DateTimeFormatter.ISO_DATE_TIME)) .build(); new Thread(() -> { @@ -50,12 +53,12 @@ public ResponseEntity handleMockData(@RequestBody WebhookRequest webhook .retrieve() .bodyToMono(String.class) .subscribe( - response -> log.info("웹훅 전송 성공: {}", response), - error -> log.error("웹훅 전송 실패: {}", error.getMessage()) + response -> log.info("결제 웹훅 전송 성공: {}", response), + error -> log.error("결제 웹훅 전송 실패: {}", error.getMessage()) ); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.error("웹훅 전송 중 인터럽트 발생", e); + log.error("취소 웹훅 전송 중 인터럽트 발생", e); } }).start(); @@ -63,6 +66,43 @@ public ResponseEntity handleMockData(@RequestBody WebhookRequest webhook return ResponseEntity.ok("Payment request received successfully. Transaction ID: " + transactionId); } + @PostMapping("/request/cancel") + public ResponseEntity handleCancelData(@RequestBody CancelRequest cancelRequest) { + + String transactionId = UUID.randomUUID().toString(); + String paymentKey = cancelRequest.getPaymentKey(); + + WebhookResponse webhookResponse = WebhookResponse.builder() + .transactionId(transactionId) + .paymentStatus(PaymentStatus.CANCELED.toString()) + .createdAt(LocalDateTime.now().toString()) + .build(); + + new Thread(() -> { + try { + Thread.sleep(2000); + + webClient.post() + .uri("http://localhost:8080/api/protected/payment" + paymentKey + "/cancel") + .bodyValue(webhookResponse) + .retrieve() + .bodyToMono(String.class) + .subscribe( + success -> log.info("취소 웹훅 전송 성공: {}",success), + error -> log.error("취소 웹훅 전송 실패: {}", error.getMessage()) + ); + + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("취소 웹훅 전송 중 인터럽트 발생", e); + } + }).start(); + + log.info("Mock 서버가 취소 요청 접수 완료. 트랜잭션 ID: {}", transactionId); + return ResponseEntity.ok("Payment cancel request received successfully. Transaction ID: " + transactionId); + + } + @PostMapping("/callback1") public ResponseEntity handleWebhookCallback( @RequestHeader("X-CLIENT-ID") String clientId, diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java new file mode 100644 index 0000000..910160c --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/CancelRequest.java @@ -0,0 +1,26 @@ +package me.jaesung.simplepg.domain.dto.webhook; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * PG -> 외부 결제 + */ +@Data +@Builder +@AllArgsConstructor +@NoArgsConstructor +public class CancelRequest { + + private String paymentKey; + private String amount; + private String orderNo; + private String customerName; + private String methodCode; + private String returnUrl; + private String transactionId; + private String cancelReason; + +} diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java index 7597ec4..2cae1ab 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookRequest.java @@ -9,7 +9,9 @@ * PG -> 외부 결제 */ @Data -@Builder @AllArgsConstructor @NoArgsConstructor +@Builder +@AllArgsConstructor +@NoArgsConstructor public class WebhookRequest { private String paymentKey; @@ -17,8 +19,7 @@ public class WebhookRequest { private String orderNo; private String customerName; private String methodCode; - private String successUrl; - private String failureUrl; + private String returnUrl; private String transactionId; - private String cancelReason; + } diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java index 2c5afd5..ff1ba63 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/WebhookResponse.java @@ -10,5 +10,5 @@ public class WebhookResponse { private String transactionId; private String paymentStatus; - private String approvedAt; + private String createdAt; } diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index 20fa88a..e29f79f 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -6,6 +6,7 @@ import me.jaesung.simplepg.common.util.HmacUtil; import me.jaesung.simplepg.domain.dto.api.ApiCredentialResponse; import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; +import me.jaesung.simplepg.domain.dto.webhook.CancelRequest; import me.jaesung.simplepg.domain.dto.webhook.WebhookRequest; import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.domain.vo.api.ApiCredential; @@ -53,8 +54,7 @@ public void sendRequest(PaymentDTO paymentDTO) { .orderNo(paymentDTO.getOrderNo()) .customerName(paymentDTO.getCustomerName()) .methodCode(paymentDTO.getMethodCode().toString()) - .successUrl(returnUrl + "/" + paymentDTO.getPaymentKey() + "/success") - .failureUrl(returnUrl + "/" + paymentDTO.getPaymentKey() + "/failure") + .returnUrl(returnUrl) .build(); webClient.post() @@ -62,12 +62,10 @@ public void sendRequest(PaymentDTO paymentDTO) { .bodyValue(webhookRequest) .retrieve() .bodyToMono(String.class) - .subscribe( - null, - error -> { - throw new PaymentException.ExternalPaymentException("외부 결제 서버로 요청 실패"); - } - ); + .doOnSuccess(response -> log.info("PG -> 외부 결제 시스템 요청 성공: {}", response)) + .doOnError(error -> log.error("PG -> 외부 결제 시스템 요청 실패: {}", error)) + .onErrorResume(error -> Mono.empty()) + .subscribe(); } /** @@ -82,7 +80,7 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { log.info("결제 취소 요청 전송: paymentKey={}, transactionId={}", paymentDTO.getPaymentKey(), paymentDTO.getTransactionId()); - WebhookRequest cancelRequest = WebhookRequest.builder() + CancelRequest cancelRequest = CancelRequest.builder() .paymentKey(paymentDTO.getPaymentKey()) .transactionId(paymentDTO.getTransactionId()) .amount(paymentDTO.getAmount().toString()) @@ -132,12 +130,8 @@ public void sendResponse(MerchantRequest merchantRequest) { .bodyValue(apiCredentialResponse) .retrieve() .bodyToMono(ApiCredentialResponse.class) - .doOnSuccess(response -> { - log.info("가맹점 서버 응답 성공: {}", response); - }) - .doOnError(error -> { - log.error("가맹점 서버 요청 실패: {}", error.getMessage(), error); - }) + .doOnSuccess(response -> log.info("가맹점 서버 응답 성공: {}", response)) + .doOnError(error -> log.error("가맹점 서버 요청 실패: {}", error.getMessage(), error)) .onErrorResume(error -> Mono.empty()) .subscribe(); @@ -167,4 +161,4 @@ private String plusBody(MerchantRequest merchantRequest) { throw new PaymentException.ProcessingException("webhook 수신 후 바디 생성 중 예외 발생"); } } -} +} \ No newline at end of file From 795dfea8019e366b0687631320fa4852ce9780cd Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Wed, 18 Jun 2025 23:54:05 +0900 Subject: [PATCH 08/13] =?UTF-8?q?Fix:=20TransactionalListener=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20Async=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/event/handler/PaymentEventHandler.java | 14 +++++++++----- .../me/jaesung/simplepg/config/AsyncConfig.java | 6 ------ .../service/webhook/WebhookServiceImpl.java | 10 +--------- .../service/webhook/WebhookSuccessService.java | 13 +++++++------ 4 files changed, 17 insertions(+), 26 deletions(-) diff --git a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java index 721685f..ba111e1 100644 --- a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java +++ b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.event.PaymentProcessedEvent; -import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; @@ -17,23 +16,28 @@ @Slf4j @RequiredArgsConstructor public class PaymentEventHandler { + private final WebClientService webClientService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentEvent(PaymentProcessedEvent event) { + handlePaymentEventAsync(event); + } + @Async @Retryable(value = {Exception.class}, backoff = @Backoff(delay = 3000)) - public void handlePaymentEvent(PaymentProcessedEvent event) { + public void handlePaymentEventAsync(PaymentProcessedEvent event) { try { log.info("Payment 정상 커밋 완료 후 비동기 요청 실행: {}", event.getMerchantRequest().getPaymentKey()); webClientService.sendResponse(event.getMerchantRequest()); } catch (Exception e) { log.error("Payment -> Client 결제 정보 전송 실패 및 재시도 예정: {}", event.getMerchantRequest().getPaymentKey(), e); + throw e; } } @Recover - public void retryFailed(Exception e, PaymentProcessedEvent event){ + public void retryFailed(Exception e, PaymentProcessedEvent event) { log.error("Payment -> Client 결제 정보 최종 실패: {}", event.getMerchantRequest().getPaymentKey(), e); - } -} +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java index 68e2116..7b9d6d4 100644 --- a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java @@ -2,9 +2,7 @@ import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.event.EventListenerMethodProcessor; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; @@ -33,8 +31,4 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new SimpleAsyncUncaughtExceptionHandler(); } - @Bean - public EventListenerMethodProcessor eventListenerMethodProcessor() { - return new EventListenerMethodProcessor(); - } } \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java index 784487d..839983c 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java @@ -10,12 +10,9 @@ import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Service @Slf4j @RequiredArgsConstructor public abstract class WebhookServiceImpl implements WebhookService { @@ -34,11 +31,8 @@ public abstract class WebhookServiceImpl implements WebhookService { @Transactional @Override public void webhookProcess(WebhookResponse webhookResponse, String paymentKey) { - MerchantRequest merchantRequest = paymentTransaction(webhookResponse, paymentKey); - eventPublisher.publishEvent(new PaymentProcessedEvent(merchantRequest)); - } /** @@ -56,7 +50,6 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri } processPaymentStatus(paymentDTO, webhookResponse); - validatePayment(paymentDTO, webhookResponse); return MerchantRequest.builder() @@ -69,7 +62,6 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri .build(); } - /** * 결제 정보와 웹훅 요청 검증 (Success/Failed에 따라 분기) */ @@ -79,4 +71,4 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri * 결제 상태 처리 (Success/Failed에 따라 분기) */ protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse); -} +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java index c15e33d..e2d4c8e 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java @@ -11,12 +11,13 @@ import me.jaesung.simplepg.mapper.PaymentMapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @Service -@Slf4j +@Slf4j @Transactional public class WebhookSuccessService extends WebhookServiceImpl { public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { @@ -24,17 +25,17 @@ public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper payme } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { - if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + if (!webhookResponse.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { paymentDTO.setStatus(PaymentStatus.APPROVED); - paymentDTO.setApprovedAt(LocalDateTime.parse(webhookRequest.getApprovedAt())); - paymentDTO.setTransactionId(webhookRequest.getTransactionId()); + paymentDTO.setApprovedAt(LocalDateTime.parse(webhookResponse.getCreatedAt())); + paymentDTO.setTransactionId(webhookResponse.getTransactionId()); PaymentLogDTO paymentLogDTO = PaymentLogDTO.builder() .paymentId(paymentDTO.getPaymentId()) From 8c82c461c0cf18537b1950b87e5a7c73efe0da93 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Wed, 18 Jun 2025 23:54:05 +0900 Subject: [PATCH 09/13] =?UTF-8?q?Fix:=20TransactionalListener=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20Async=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/handler/PaymentEventHandler.java | 14 +- .../jaesung/simplepg/config/AsyncConfig.java | 6 - .../domain/dto/webhook/ExternalApiDTO.java | 24 -- .../service/webhook/WebhookServiceImpl.java | 10 +- .../webhook/WebhookSuccessService.java | 13 +- src/main/webapp/WEB-INF/jsp/index.jsp | 312 ++++++++++++++++++ src/main/webapp/index.jsp | 13 - .../webhook/WebhookServiceImplTest.java | 113 +++++++ 8 files changed, 442 insertions(+), 63 deletions(-) delete mode 100644 src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java create mode 100644 src/main/webapp/WEB-INF/jsp/index.jsp delete mode 100644 src/main/webapp/index.jsp create mode 100644 src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java diff --git a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java index 721685f..ba111e1 100644 --- a/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java +++ b/src/main/java/me/jaesung/simplepg/common/event/handler/PaymentEventHandler.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import me.jaesung.simplepg.common.event.PaymentProcessedEvent; -import me.jaesung.simplepg.domain.dto.webhook.MerchantRequest; import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; @@ -17,23 +16,28 @@ @Slf4j @RequiredArgsConstructor public class PaymentEventHandler { + private final WebClientService webClientService; @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handlePaymentEvent(PaymentProcessedEvent event) { + handlePaymentEventAsync(event); + } + @Async @Retryable(value = {Exception.class}, backoff = @Backoff(delay = 3000)) - public void handlePaymentEvent(PaymentProcessedEvent event) { + public void handlePaymentEventAsync(PaymentProcessedEvent event) { try { log.info("Payment 정상 커밋 완료 후 비동기 요청 실행: {}", event.getMerchantRequest().getPaymentKey()); webClientService.sendResponse(event.getMerchantRequest()); } catch (Exception e) { log.error("Payment -> Client 결제 정보 전송 실패 및 재시도 예정: {}", event.getMerchantRequest().getPaymentKey(), e); + throw e; } } @Recover - public void retryFailed(Exception e, PaymentProcessedEvent event){ + public void retryFailed(Exception e, PaymentProcessedEvent event) { log.error("Payment -> Client 결제 정보 최종 실패: {}", event.getMerchantRequest().getPaymentKey(), e); - } -} +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java index 68e2116..7b9d6d4 100644 --- a/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/AsyncConfig.java @@ -2,9 +2,7 @@ import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler; import org.springframework.aop.interceptor.SimpleAsyncUncaughtExceptionHandler; -import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.context.event.EventListenerMethodProcessor; import org.springframework.retry.annotation.EnableRetry; import org.springframework.scheduling.annotation.AsyncConfigurer; import org.springframework.scheduling.annotation.EnableAsync; @@ -33,8 +31,4 @@ public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() { return new SimpleAsyncUncaughtExceptionHandler(); } - @Bean - public EventListenerMethodProcessor eventListenerMethodProcessor() { - return new EventListenerMethodProcessor(); - } } \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java deleted file mode 100644 index 7dca5d2..0000000 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/ExternalApiDTO.java +++ /dev/null @@ -1,24 +0,0 @@ -package me.jaesung.simplepg.domain.dto.webhook; - -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Data; -import lombok.NoArgsConstructor; - -/** - * PG -> 외부 결제 - */ -@Data -@Builder @AllArgsConstructor @NoArgsConstructor -public class ExternalApiDTO { - - private String paymentKey; - private String amount; - private String orderNo; - private String customerName; - private String methodCode; - private String successUrl; - private String failureUrl; - private String transactionId; - private String cancelReason; -} diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java index 784487d..839983c 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookServiceImpl.java @@ -10,12 +10,9 @@ import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -@Service @Slf4j @RequiredArgsConstructor public abstract class WebhookServiceImpl implements WebhookService { @@ -34,11 +31,8 @@ public abstract class WebhookServiceImpl implements WebhookService { @Transactional @Override public void webhookProcess(WebhookResponse webhookResponse, String paymentKey) { - MerchantRequest merchantRequest = paymentTransaction(webhookResponse, paymentKey); - eventPublisher.publishEvent(new PaymentProcessedEvent(merchantRequest)); - } /** @@ -56,7 +50,6 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri } processPaymentStatus(paymentDTO, webhookResponse); - validatePayment(paymentDTO, webhookResponse); return MerchantRequest.builder() @@ -69,7 +62,6 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri .build(); } - /** * 결제 정보와 웹훅 요청 검증 (Success/Failed에 따라 분기) */ @@ -79,4 +71,4 @@ private MerchantRequest paymentTransaction(WebhookResponse webhookResponse, Stri * 결제 상태 처리 (Success/Failed에 따라 분기) */ protected abstract void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse); -} +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java index c15e33d..e2d4c8e 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookSuccessService.java @@ -11,12 +11,13 @@ import me.jaesung.simplepg.mapper.PaymentMapper; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @Service -@Slf4j +@Slf4j @Transactional public class WebhookSuccessService extends WebhookServiceImpl { public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { @@ -24,17 +25,17 @@ public WebhookSuccessService(PaymentMapper paymentMapper, PaymentLogMapper payme } @Override - protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { - if (!webhookRequest.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + if (!webhookResponse.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); } } @Override - protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookRequest) { + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { paymentDTO.setStatus(PaymentStatus.APPROVED); - paymentDTO.setApprovedAt(LocalDateTime.parse(webhookRequest.getApprovedAt())); - paymentDTO.setTransactionId(webhookRequest.getTransactionId()); + paymentDTO.setApprovedAt(LocalDateTime.parse(webhookResponse.getCreatedAt())); + paymentDTO.setTransactionId(webhookResponse.getTransactionId()); PaymentLogDTO paymentLogDTO = PaymentLogDTO.builder() .paymentId(paymentDTO.getPaymentId()) diff --git a/src/main/webapp/WEB-INF/jsp/index.jsp b/src/main/webapp/WEB-INF/jsp/index.jsp new file mode 100644 index 0000000..6f36d16 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/index.jsp @@ -0,0 +1,312 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + SimplePG - 결제 시스템 테스트 대시보드 + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/index.jsp b/src/main/webapp/index.jsp deleted file mode 100644 index dd88878..0000000 --- a/src/main/webapp/index.jsp +++ /dev/null @@ -1,13 +0,0 @@ -<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> - - - - JSP - Hello World - - -

<%= "Hello World!" %> -

-
-Hello Servlet - - \ No newline at end of file diff --git a/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java b/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java new file mode 100644 index 0000000..db90aa7 --- /dev/null +++ b/src/test/java/me/jaesung/simplepg/service/webhook/WebhookServiceImplTest.java @@ -0,0 +1,113 @@ +package me.jaesung.simplepg.service.webhook; + +import me.jaesung.simplepg.common.exception.PaymentException; +import me.jaesung.simplepg.domain.dto.payment.PaymentDTO; +import me.jaesung.simplepg.domain.dto.payment.PaymentLogDTO; +import me.jaesung.simplepg.domain.dto.webhook.WebhookResponse; +import me.jaesung.simplepg.domain.vo.payment.*; +import me.jaesung.simplepg.mapper.PaymentLogMapper; +import me.jaesung.simplepg.mapper.PaymentMapper; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class WebhookServiceImplTest { + + @Mock + private PaymentMapper paymentMapper; + + @Mock + private PaymentLogMapper paymentLogMapper; + + @Mock + private ApplicationEventPublisher applicationEventPublisher; + + @InjectMocks + private TestWebhookServiceImpl testWebhookService; + + @Test + @DisplayName("이벤트 발행 테스트") + void webhookProcess() { + //given + String paymentKey = "test-payment-key"; + PaymentDTO mockPaymentDTO = createMockPaymentDTO(paymentKey); + WebhookResponse mockWebhookResponse = createMockWebhookResponse(); + + when(paymentMapper.findByPaymentKeyWithLock(paymentKey)) + .thenReturn(Optional.ofNullable(mockPaymentDTO)); + + //when + testWebhookService.webhookProcess(mockWebhookResponse, paymentKey); + + ///then + + + } + + + private static class TestWebhookServiceImpl extends WebhookServiceImpl { + + public TestWebhookServiceImpl(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { + super(paymentMapper, paymentLogMapper, eventPublisher); + } + + @Override + protected void validatePayment(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + if (!webhookResponse.getPaymentStatus().equals(PaymentStatus.APPROVED.name())) { + throw new PaymentException.WebhookProcessingException("외부 Status 정보가 서버의 정보와 다릅니다"); + } + } + + @Override + protected void processPaymentStatus(PaymentDTO paymentDTO, WebhookResponse webhookResponse) { + paymentDTO.setStatus(PaymentStatus.APPROVED); + paymentDTO.setApprovedAt(LocalDateTime.parse(webhookResponse.getApprovedAt())); + paymentDTO.setTransactionId(webhookResponse.getTransactionId()); + + PaymentLogDTO paymentLogDTO = PaymentLogDTO.builder() + .paymentId(paymentDTO.getPaymentId()) + .action(PaymentLogAction.APPROVE) + .status(PaymentStatus.APPROVED) + .details("승인된 결제 내역입니다(승인 일시:" + LocalDateTime.now() + ")") + .build(); + + paymentMapper.updatePayment(paymentDTO); + paymentLogMapper.insertPaymentLog(paymentLogDTO); + } + } + + private PaymentDTO createMockPaymentDTO(String paymentKey) { + return PaymentDTO.builder() + .paymentKey(paymentKey) + .clientId("test-client") + .orderNo("test-order") + .transactionId("test-TID") + .amount(BigDecimal.valueOf(10000)) + .customerName("testUser") + .status(PaymentStatus.READY) + .productName("test-product") + .methodCode(MethodCode.BANK) + .createdAt(LocalDateTime.now()) + .build(); + + } + + private WebhookResponse createMockWebhookResponse() { + return WebhookResponse.builder() + .paymentStatus(String.valueOf(PaymentStatus.APPROVED)) + .transactionId("test-TID") + .approvedAt(LocalDateTime.now().toString()) + .build(); + } +} \ No newline at end of file From 61dd185d681b1a4e6be63cf41107c15682ababa9 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:46:30 +0900 Subject: [PATCH 10/13] =?UTF-8?q?Feat:=20=EA=B2=B0=EC=A0=9C=20API(?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=EC=B7=A8=EC=86=8C,=EC=99=84=EB=A3=8C)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=B9=ED=9B=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?JSP=20=EA=B4=80=EB=A0=A8=20-=20JSP=20=EB=B7=B0=20=EB=A6=AC?= =?UTF-8?q?=EC=A1=B8=EB=B2=84=20=EC=84=A4=EC=A0=95=20-=20Encoding=20Filter?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20-=20=EC=B7=A8=EC=86=8C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20MockController=20=EC=88=98=EC=A0=95=20->=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9C=ED=82=B9=20-=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20timeout=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../simplepg/config/ServletConfig.java | 20 +- .../me/jaesung/simplepg/config/WebConfig.java | 15 +- .../simplepg/controller/TestController.java | 73 +++ .../controller/mock/MockController.java | 43 +- .../service/webclient/WebClientService.java | 6 +- .../service/webhook/WebhookFailedService.java | 4 +- src/main/webapp/WEB-INF/jsp/api-test.jsp | 299 ++++++++++ .../WEB-INF/jsp/payment-test-simple.jsp | 146 +++++ src/main/webapp/WEB-INF/jsp/payment-test.jsp | 544 ++++++++++++++++++ src/main/webapp/WEB-INF/jsp/test.jsp | 21 + src/main/webapp/WEB-INF/jsp/webhook-test.jsp | 517 +++++++++++++++++ 11 files changed, 1652 insertions(+), 36 deletions(-) create mode 100644 src/main/java/me/jaesung/simplepg/controller/TestController.java create mode 100644 src/main/webapp/WEB-INF/jsp/api-test.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/payment-test.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/test.jsp create mode 100644 src/main/webapp/WEB-INF/jsp/webhook-test.jsp diff --git a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java index 4860855..29fa805 100644 --- a/src/main/java/me/jaesung/simplepg/config/ServletConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/ServletConfig.java @@ -2,10 +2,11 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.*; -import org.springframework.stereotype.Controller; import org.springframework.web.multipart.MultipartResolver; import org.springframework.web.multipart.support.StandardServletMultipartResolver; import org.springframework.web.servlet.config.annotation.*; +import org.springframework.web.servlet.view.InternalResourceViewResolver; +import org.springframework.web.servlet.view.JstlView; @EnableWebMvc @Configuration @@ -15,7 +16,7 @@ @ComponentScan(basePackages = "me.jaesung.simplepg.controller") public class ServletConfig implements WebMvcConfigurer { - @Value("${server.URI}") + @Value("${server.URI:http://localhost:8080}") String base_uri; @Override @@ -24,6 +25,8 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) { .addResourceLocations("/resources/"); registry.addResourceHandler("/assets/**") .addResourceLocations("/resources/assets/"); + registry.addResourceHandler("/static/**") + .addResourceLocations("/static/"); } @Override @@ -34,6 +37,19 @@ public void addCorsMappings(CorsRegistry registry) { .allowedHeaders("*"); } + /** + * JSP 뷰 리졸버 설정 + */ + @Override + public void configureViewResolvers(ViewResolverRegistry registry) { + InternalResourceViewResolver resolver = new InternalResourceViewResolver(); + resolver.setPrefix("/WEB-INF/jsp/"); + resolver.setSuffix(".jsp"); + resolver.setViewClass(JstlView.class); + resolver.setOrder(1); + registry.viewResolver(resolver); + } + @Bean public MultipartResolver multipartResolver() { return new StandardServletMultipartResolver(); diff --git a/src/main/java/me/jaesung/simplepg/config/WebConfig.java b/src/main/java/me/jaesung/simplepg/config/WebConfig.java index e7cee68..39896bc 100644 --- a/src/main/java/me/jaesung/simplepg/config/WebConfig.java +++ b/src/main/java/me/jaesung/simplepg/config/WebConfig.java @@ -1,10 +1,12 @@ package me.jaesung.simplepg.config; - +import org.springframework.web.filter.CharacterEncodingFilter; import org.springframework.web.servlet.support.AbstractAnnotationConfigDispatcherServletInitializer; +import javax.servlet.Filter; public class WebConfig extends AbstractAnnotationConfigDispatcherServletInitializer { + @Override protected Class[] getRootConfigClasses() { return new Class[]{AppConfig.class, SecurityConfig.class, AsyncConfig.class}; @@ -20,5 +22,14 @@ protected String[] getServletMappings() { return new String[]{"/"}; } - + /** + * Character Encoding Filter 설정 + */ + @Override + protected Filter[] getServletFilters() { + CharacterEncodingFilter characterEncodingFilter = new CharacterEncodingFilter(); + characterEncodingFilter.setEncoding("UTF-8"); + characterEncodingFilter.setForceEncoding(true); + return new Filter[]{characterEncodingFilter}; + } } diff --git a/src/main/java/me/jaesung/simplepg/controller/TestController.java b/src/main/java/me/jaesung/simplepg/controller/TestController.java new file mode 100644 index 0000000..8e365d9 --- /dev/null +++ b/src/main/java/me/jaesung/simplepg/controller/TestController.java @@ -0,0 +1,73 @@ +package me.jaesung.simplepg.controller; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@Slf4j +public class TestController { + + /** + * 메인 대시보드 페이지 + */ + @GetMapping("/") + public String index() { + log.info("메인 대시보드 페이지 요청"); + return "index"; + } + + /** + * 결제 시스템 테스트 페이지 + */ + @GetMapping("/payment-test") + public String paymentTest() { + log.info("결제 시스템 테스트 페이지 요청"); + return "payment-test"; + } + + /** + * 웹훅 테스트 페이지 + */ + @GetMapping("/webhook-test") + public String webhookTest() { + log.info("웹훅 테스트 페이지 요청"); + return "webhook-test"; + } + + /** + * API 테스트 페이지 + */ + @GetMapping("/api-test") + public String apiTest() { + log.info("API 테스트 페이지 요청"); + return "api-test"; + } + + /** + * 헬스 체크용 엔드포인트 + */ + @GetMapping("/health") + public String health() { + log.info("헬스 체크 요청"); + return "OK"; + } + + /** + * JSP 테스트용 엔드포인트 + */ + @GetMapping("/test") + public String test() { + log.info("JSP 테스트 페이지 요청"); + return "test"; + } + + /** + * 간단한 결제 테스트 페이지 + */ + @GetMapping("/payment-test-simple") + public String paymentTestSimple() { + log.info("간단한 결제 테스트 페이지 요청"); + return "payment-test-simple"; + } +} \ No newline at end of file diff --git a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java index 77476d8..b3a8f50 100644 --- a/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java +++ b/src/main/java/me/jaesung/simplepg/controller/mock/MockController.java @@ -67,39 +67,28 @@ public ResponseEntity handleMockData(@RequestBody WebhookRequest webhook } @PostMapping("/request/cancel") - public ResponseEntity handleCancelData(@RequestBody CancelRequest cancelRequest) { + public ResponseEntity handleCancelData(@RequestBody CancelRequest cancelRequest) { - String transactionId = UUID.randomUUID().toString(); - String paymentKey = cancelRequest.getPaymentKey(); + try { + Thread.sleep(3000); - WebhookResponse webhookResponse = WebhookResponse.builder() - .transactionId(transactionId) - .paymentStatus(PaymentStatus.CANCELED.toString()) - .createdAt(LocalDateTime.now().toString()) - .build(); + String transactionId = UUID.randomUUID().toString(); - new Thread(() -> { - try { - Thread.sleep(2000); + WebhookResponse webhookResponse = WebhookResponse.builder() + .transactionId(transactionId) + .paymentStatus(PaymentStatus.CANCELED.toString()) + .createdAt(LocalDateTime.now().toString()) + .build(); - webClient.post() - .uri("http://localhost:8080/api/protected/payment" + paymentKey + "/cancel") - .bodyValue(webhookResponse) - .retrieve() - .bodyToMono(String.class) - .subscribe( - success -> log.info("취소 웹훅 전송 성공: {}",success), - error -> log.error("취소 웹훅 전송 실패: {}", error.getMessage()) - ); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - log.error("취소 웹훅 전송 중 인터럽트 발생", e); - } - }).start(); + log.info("Mock 서버가 취소 요청 접수 완료. 트랜잭션 ID: {}", transactionId); + return ResponseEntity.ok(webhookResponse); - log.info("Mock 서버가 취소 요청 접수 완료. 트랜잭션 ID: {}", transactionId); - return ResponseEntity.ok("Payment cancel request received successfully. Transaction ID: " + transactionId); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.error("취소 처리 중 인터럽트 발생", e); + return ResponseEntity.internalServerError().build(); + } } diff --git a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java index e29f79f..ed12519 100644 --- a/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java +++ b/src/main/java/me/jaesung/simplepg/service/webclient/WebClientService.java @@ -17,6 +17,7 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.time.Duration; import java.time.LocalDateTime; @Service @@ -31,7 +32,7 @@ public class WebClientService { public WebClientService(@Value("${api.request.url}") String requestApiUrl, @Value("${api.return.url}") String returnUrl, - @Value("${api.request.url}/cancel}") String cancelUrl, + @Value("${api.request.url}/cancel") String cancelUrl, WebClient webClient, ApiCredentialMapper apiCredentialMapper) { this.requestApiUrl = requestApiUrl; this.returnUrl = returnUrl; @@ -70,7 +71,6 @@ public void sendRequest(PaymentDTO paymentDTO) { /** * 외부 결제 시스템으로 결제 취소 요청 전송 - * * @param paymentDTO 취소할 결제 정보 * @param cancelReason 취소 사유(옵션) * @throws PaymentException.ExternalPaymentException 외부 API 호출 실패 시 @@ -94,7 +94,7 @@ public void sendCancelRequest(PaymentDTO paymentDTO, String cancelReason) { .bodyValue(cancelRequest) .retrieve() .bodyToMono(String.class) - .block(); + .block(Duration.ofSeconds(30)); log.debug("결제 취소 응답: {}", response); } catch (Exception e) { diff --git a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java index a1897fa..335dd1f 100644 --- a/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java +++ b/src/main/java/me/jaesung/simplepg/service/webhook/WebhookFailedService.java @@ -9,14 +9,14 @@ import me.jaesung.simplepg.domain.vo.payment.PaymentStatus; import me.jaesung.simplepg.mapper.PaymentLogMapper; import me.jaesung.simplepg.mapper.PaymentMapper; -import me.jaesung.simplepg.service.webclient.WebClientService; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; @Service -@Slf4j +@Slf4j @Transactional public class WebhookFailedService extends WebhookServiceImpl { public WebhookFailedService(PaymentMapper paymentMapper, PaymentLogMapper paymentLogMapper, ApplicationEventPublisher eventPublisher) { diff --git a/src/main/webapp/WEB-INF/jsp/api-test.jsp b/src/main/webapp/WEB-INF/jsp/api-test.jsp new file mode 100644 index 0000000..96eef48 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/api-test.jsp @@ -0,0 +1,299 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + SimplePG - API 테스트 + + + +
+
+

SimplePG API 테스트

+

REST API 엔드포인트를 직접 테스트할 수 있습니다

+
+ +
+
+

빠른 테스트

+ +
+
+

결제 요청

+
+ + +
+
+ + +
+ +
+ +
+

결제 상태 조회

+
+ + +
+ +
+
+ + +
+ +
+

JSON 요청 테스트

+ +
+ + +
+ + + + + +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp b/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp new file mode 100644 index 0000000..ba56359 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/payment-test-simple.jsp @@ -0,0 +1,146 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> +<%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> +<%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> + + + + + SimplePG - 결제 시스템 테스트 (간단 버전) + + + +
+

💳 SimplePG 결제 시스템 테스트

+

간단한 결제 요청 테스트

+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ + +
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/payment-test.jsp b/src/main/webapp/WEB-INF/jsp/payment-test.jsp new file mode 100644 index 0000000..820d137 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/payment-test.jsp @@ -0,0 +1,544 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + + SimplePG - 결제 시스템 테스트 + + + +
+
+

💳 SimplePG 결제 시스템

+

결제 요청, 상태 조회, 취소, 완료 기능 테스트

+
+ +
+ +
+

🔵 1. 결제 요청 테스트

+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ +
+ + +
+

🔍 2. 결제 상태 조회 테스트

+
+ + +
+ + +
+ + +
+

❌ 3. 결제 취소 테스트

+
+ + +
+
+ + +
+ + +
+ + +
+

✅ 4. 결제 완료 테스트

+
+ + +
+ + +
+ + +
+

📋 5. 최근 결제 내역

+ + +
+
+
+ + + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/test.jsp b/src/main/webapp/WEB-INF/jsp/test.jsp new file mode 100644 index 0000000..3a487f3 --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/test.jsp @@ -0,0 +1,21 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8"%> + + + + + JSP 테스트 + + +

JSP 테스트 페이지

+

현재 시간: <%= new java.util.Date() %>

+

JSP 뷰 리졸버가 정상적으로 작동하고 있습니다!

+ +

다른 페이지로 이동:

+ + + \ No newline at end of file diff --git a/src/main/webapp/WEB-INF/jsp/webhook-test.jsp b/src/main/webapp/WEB-INF/jsp/webhook-test.jsp new file mode 100644 index 0000000..5cefb7c --- /dev/null +++ b/src/main/webapp/WEB-INF/jsp/webhook-test.jsp @@ -0,0 +1,517 @@ +<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %> + + + + + + SimplePG - 웹훅 테스트 + + + +
+
+

🔄 SimplePG 웹훅 테스트

+

외부 결제 시스템 → SimplePG 웹훅 시뮬레이션

+
+ +
+ +
+

📋 웹훅 처리 플로우

+
+
1
+
+
결제 요청 생성
+
SimplePG에서 결제 요청을 생성하고 Payment Key 발급
+
+
+
+
2
+
+
외부 결제 시스템으로 요청 전송
+
WebClient를 통해 외부 결제 시스템으로 비동기 요청
+
+
+
+
3
+
+
외부 시스템에서 웹훅 응답
+
결제 처리 완료 후 SimplePG로 웹훅 전송
+
+
+
+
4
+
+
SimplePG에서 웹훅 처리
+
결제 상태 업데이트 및 가맹점 서버로 결과 전송
+
+
+
+ + +
+

🎯 웹훅 시뮬레이션

+

외부 결제 시스템에서 SimplePG로 웹훅을 보내는 상황을 시뮬레이션합니다.

+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + + + + + +
+ + +
+

📊 웹훅 로그

+ +
+ 웹훅 로그가 여기에 표시됩니다... +
+
+ + +
+

🧪 테스트 시나리오

+
+
+

시나리오 1: 정상 결제 플로우

+
    +
  1. 결제 요청 생성 (Payment Key 발급)
  2. +
  3. 성공 웹훅 전송
  4. +
  5. 결제 상태 확인 (APPROVED)
  6. +
  7. 결제 완료 처리
  8. +
+
+
+

시나리오 2: 결제 실패 플로우

+
    +
  1. 결제 요청 생성 (Payment Key 발급)
  2. +
  3. 실패 웹훅 전송
  4. +
  5. 결제 상태 확인 (FAILED)
  6. +
  7. 재시도 또는 취소
  8. +
+
+
+
+
+
+ + + + + \ No newline at end of file From d73d57aaf6917cdb998e8795272c16a379dfff30 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Thu, 19 Jun 2025 17:47:11 +0900 Subject: [PATCH 11/13] =?UTF-8?q?Feat:=20=EA=B2=B0=EC=A0=9C=20API(?= =?UTF-8?q?=EC=83=9D=EC=84=B1,=EC=B7=A8=EC=86=8C,=EC=99=84=EB=A3=8C)=20?= =?UTF-8?q?=EB=B0=8F=20=EC=9B=B9=ED=9B=85=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?JSP=20=EA=B4=80=EB=A0=A8=20-=20JSP=20=EB=B7=B0=20=EB=A6=AC?= =?UTF-8?q?=EC=A1=B8=EB=B2=84=20=EC=84=A4=EC=A0=95=20-=20Encoding=20Filter?= =?UTF-8?q?=20=EC=84=A4=EC=A0=95=20-=20=EC=B7=A8=EC=86=8C=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20MockController=20=EC=88=98=EC=A0=95=20->=20?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=8A=A4=EB=A0=88=EB=93=9C=20=EB=B8=94?= =?UTF-8?q?=EB=A1=9C=ED=82=B9=20-=20=EA=B2=B0=EC=A0=9C=20=EC=B7=A8?= =?UTF-8?q?=EC=86=8C=20=EC=9A=94=EC=B2=AD=20=EC=8B=9C=20timeout=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 41 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 2e985da..870060f 100644 --- a/README.md +++ b/README.md @@ -90,7 +90,46 @@ simplepg/ - **`payment` → `payment_log`**: 1:N 관계 (FK : `payment_id`, `ON DELETE CASCADE`) --- -## 테스트 방법 +## 🧪 테스트 방법 + +### 🌐 JSP 테스트 페이지 사용법 + +애플리케이션 실행 후 브라우저에서 다음 URL로 접속하여 테스트할 수 있습니다 + +#### 📊 메인 대시보드 +``` +http://localhost:8080/ +``` +- 시스템 상태 확인 +- 각 테스트 페이지로 이동하는 네비게이션 제공 + +#### 💳 결제 시스템 테스트 +``` +http://localhost:8080/payment-test +``` +- **결제 요청**: 새로운 결제 요청 생성 및 Payment Key 발급 +- **결제 상태 조회**: Payment Key로 결제 상태 확인 +- **결제 취소**: 결제 취소 처리 (사유 포함) +- **결제 완료**: 결제 완료 처리 +- **최근 결제 내역**: Mock 데이터로 결제 내역 확인 + +#### 🔄 웹훅 테스트 +``` +http://localhost:8080/webhook-test +``` +- **웹훅 플로우 시각화**: 결제 처리 과정을 단계별로 표시 +- **성공 웹훅 전송**: 외부 결제 시스템에서 성공 결과 전송 시뮬레이션 +- **실패 웹훅 전송**: 외부 결제 시스템에서 실패 결과 전송 시뮬레이션 +- **Mock 서버 요청**: Mock 서버로 결제 요청 전송 +- **실시간 로그**: 웹훅 전송 과정을 실시간으로 모니터링 + +#### 🔧 API 테스트 +``` +http://localhost:8080/api-test +``` +- **빠른 테스트**: 간단한 결제 요청 및 상태 조회 +- **JSON 요청 테스트**: 커스텀 JSON으로 API 직접 호출 +- **응답 분석**: API 응답 결과 분석 및 표시 ### 🔐 보안 고려사항 - 실제 설정 파일들은 `.gitignore`에 의해 제외됩니다 From b99bd72a4da4649fc4ef5baa9e1c279f68686373 Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:37:49 +0900 Subject: [PATCH 12/13] =?UTF-8?q?Chore:=20Spring=20Retry=20=EC=9D=98?= =?UTF-8?q?=EC=A1=B4=EC=84=B1=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 9f5168f..289f7bf 100644 --- a/build.gradle +++ b/build.gradle @@ -24,6 +24,7 @@ ext { junitVersion = '5.9.2' hikariVersion = '4.0.3' jstlVersion = '1.2' + retryVersion = '1.3.4' } dependencies { @@ -37,6 +38,10 @@ dependencies { implementation "org.springframework:spring-tx:${springVersion}" testImplementation "org.springframework:spring-test:${springVersion}" + // Spring retry + implementation "org.springframework.retry:spring-retry:${retryVersion}" + implementation "org.springframework:spring-aspects:${springVersion}" + // Spring Webflux implementation 'io.projectreactor:reactor-core:3.4.0' implementation 'org.springframework:spring-webflux:5.3.10' @@ -81,9 +86,10 @@ dependencies { implementation "commons-codec:commons-codec:1.15" // Servlet & JSP - implementation "javax.servlet:javax.servlet-api:3.1.0" + implementation "javax.servlet:javax.servlet-api:4.0.1" compileOnly "javax.servlet.jsp:jsp-api:2.2" implementation "javax.servlet:jstl:${jstlVersion}" + providedRuntime 'org.apache.tomcat.embed:tomcat-embed-jasper:9.0.75' // 보안 implementation("org.springframework.security:spring-security-web:${springSecurityVersion}") From 3016a7ab31269a6ce6d7a84365ab4809752d556e Mon Sep 17 00:00:00 2001 From: JaesungGo <102339979+JaesungGo@users.noreply.github.com> Date: Thu, 19 Jun 2025 18:38:25 +0900 Subject: [PATCH 13/13] =?UTF-8?q?Style:=20DTO=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80=20=EB=B0=8F=20MyBatis?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=20=EC=8A=A4=ED=83=80=EC=9D=BC=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../jaesung/simplepg/domain/dto/webhook/MerchantRequest.java | 2 ++ .../java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java | 3 +++ src/main/resources/mybatis-config.xml | 2 +- 3 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java index 5009e05..98aa4bb 100644 --- a/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java +++ b/src/main/java/me/jaesung/simplepg/domain/dto/webhook/MerchantRequest.java @@ -9,10 +9,12 @@ @Data @Builder public class MerchantRequest { + private String clientId; private String paymentKey; private String amount; private String orderNo; private String customerName; private String methodCode; + } diff --git a/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java b/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java index 81444f6..ff7f38a 100644 --- a/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java +++ b/src/main/java/me/jaesung/simplepg/domain/vo/api/ApiCredential.java @@ -4,6 +4,9 @@ import java.time.LocalDateTime; +/** + * 상점 <-> SimplePG 통신에서 필요한 상점의 식별자(clientId), MAC 인증에 필요한 secret_key(clientSecret) + */ @Getter public class ApiCredential { private String clientId; diff --git a/src/main/resources/mybatis-config.xml b/src/main/resources/mybatis-config.xml index 966fb6f..126474a 100644 --- a/src/main/resources/mybatis-config.xml +++ b/src/main/resources/mybatis-config.xml @@ -6,7 +6,7 @@ - +