From 9df150bdbd4ba8f5b24b0d5560936a09c276d746 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 17:02:55 +0900 Subject: [PATCH 01/18] =?UTF-8?q?docs:=20=EC=9D=B4=EC=8A=88=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 5 +++-- .github/ISSUE_TEMPLATE/bug_report.md | 14 ++++++++++++++ .github/ISSUE_TEMPLATE/feat-request.md | 12 ++++++++++++ .github/ISSUE_TEMPLATE/refactor-report.md | 12 ++++++++++++ 4 files changed, 41 insertions(+), 2 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/feat-request.md create mode 100644 .github/ISSUE_TEMPLATE/refactor-report.md diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 495687b..ea2b201 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,9 +1,10 @@ { "permissions": { "allow": [ - "Bash(./gradlew:*)" + "Bash(./gradlew:*)", + "Bash(find:*)" ], "deny": [], "ask": [] } -} \ No newline at end of file +} diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..7a50eb5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,14 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '[FIX] ' +labels: '' +assignees: '' + +--- + +# 버그 내용 + +# 스크린샷 + +# 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/feat-request.md b/.github/ISSUE_TEMPLATE/feat-request.md new file mode 100644 index 0000000..256b6ec --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feat-request.md @@ -0,0 +1,12 @@ +--- +name: Feat request +about: Suggest an idea for this project +title: '[FEAT] ' +labels: '' +assignees: '' + +--- + +# 투두 리스트 + +# 참고 사항 diff --git a/.github/ISSUE_TEMPLATE/refactor-report.md b/.github/ISSUE_TEMPLATE/refactor-report.md new file mode 100644 index 0000000..0a65dc9 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/refactor-report.md @@ -0,0 +1,12 @@ +--- +name: Refactor request +about: Suggest an idea for this project +title: '[REFACTOR] ' +labels: '' +assignees: '' + +--- + +# 투두 리스트 + +# 참고 사항 From 898a3da7612e7522aa4224b843a6aa1298fed08d Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 17:38:49 +0900 Subject: [PATCH 02/18] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../infrastructure/MemberAuthArgumentResolver.java | 5 +++-- .../global/exception/InternalServerErrorException.java | 8 ++++++++ domain/build.gradle | 1 + .../kokomen/payment/domain/TosspaymentsPayment.java | 9 +++++---- .../kokomen/payment/service/PaymentFacadeService.java | 1 - 5 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java diff --git a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java index 10825ed..dfda578 100644 --- a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java +++ b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.global.exception.UnauthorizedException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; @@ -29,7 +30,7 @@ public Object resolveArgument(MethodParameter parameter, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = parameter.getParameterAnnotation(Authentication.class); if (authentication == null) { - throw new IllegalStateException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); + throw new InternalServerErrorException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); } boolean authenticationRequired = authentication.required(); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); @@ -56,7 +57,7 @@ private void validateAuthentication(Long memberId, boolean authenticationRequire log.error("세션에 MEMBER_ID가 없습니다."); } if (memberId == null && authenticationRequired) { - throw new IllegalStateException("세션에 MEMBER_ID가 없습니다."); + throw new UnauthorizedException("세션에 MEMBER_ID가 없습니다."); } } } diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java new file mode 100644 index 0000000..c96554a --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class InternalServerErrorException extends KokomenException { + + public InternalServerErrorException(String message) { + super(message, 500); + } +} diff --git a/domain/build.gradle b/domain/build.gradle index 6d18cd0..9fb1470 100644 --- a/domain/build.gradle +++ b/domain/build.gradle @@ -1,4 +1,5 @@ dependencies { + implementation project(':common') implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.flywaydb:flyway-core:11.9.1' implementation 'org.flywaydb:flyway-mysql:11.9.1' diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index dd8a206..6781b07 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -10,6 +10,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; @@ -69,16 +70,16 @@ public void updateState(PaymentState state) { public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount, String metadata) { if (!this.paymentKey.equals(paymentKey)) { - throw new IllegalStateException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); + throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); } if (!this.orderId.equals(orderId)) { - throw new IllegalStateException("토스 페이먼츠 응답(%s)의 orderId가 DB에 저장된 값(%s)과 다릅니다.".formatted(orderId, this.orderId)); + throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 orderId가 DB에 저장된 값(%s)과 다릅니다.".formatted(orderId, this.orderId)); } if (!this.totalAmount.equals(totalAmount)) { - throw new IllegalStateException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); + throw new InternalServerErrorException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); } // if (!this.metadata.equals(metadata)) { -// throw new IllegalStateException("토스 페이먼츠 응답(%s)의 metadata가 DB에 저장된 값(%s)과 다릅니다.".formatted(metadata, this.metadata)); +// throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 metadata가 DB에 저장된 값(%s)과 다릅니다.".formatted(metadata, this.metadata)); // } } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index 83c98f6..a04258e 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -5,7 +5,6 @@ import com.samhap.kokomen.payment.domain.TosspaymentsPayment; import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; import com.samhap.kokomen.payment.external.TosspaymentsClient; -import com.samhap.kokomen.payment.external.TosspaymentsInternalServerErrorCode; import com.samhap.kokomen.payment.external.dto.Failure; import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; From 03fc848cdfaea10a33f207a6f0b8ef49678b75ad Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 17:49:16 +0900 Subject: [PATCH 03/18] =?UTF-8?q?refactor:=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EB=8F=84=EC=BB=A4=20=ED=8C=8C=EC=9D=BC=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- common/run-test-redis.sh | 4 ++-- common/test.yml | 16 ---------------- domain/run-test-mysql.sh | 4 ++-- domain/test.yml => test-docker-compose.yml | 16 ++++++++++++++++ 4 files changed, 20 insertions(+), 20 deletions(-) delete mode 100644 common/test.yml rename domain/test.yml => test-docker-compose.yml (62%) diff --git a/common/run-test-redis.sh b/common/run-test-redis.sh index aaaeea8..ad4e473 100755 --- a/common/run-test-redis.sh +++ b/common/run-test-redis.sh @@ -1,8 +1,8 @@ #!/bin/bash if ! docker ps --format '{{.Names}}' | grep -q '^payment-test-redis$'; then - echo "payment-test-redis 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d payment-test-redis로 시작합니다..." - docker compose -f test.yml up -d payment-test-redis + echo "payment-test-redis 컨테이너가 실행 중이 아닙니다. docker compose -f test-docker-compose.yml up -d payment-test-redis로 시작합니다..." + docker compose -f "$(git rev-parse --show-toplevel)/test-docker-compose.yml" up -d payment-test-redis else echo "payment-test-redis 컨테이너가 이미 실행 중입니다." fi diff --git a/common/test.yml b/common/test.yml deleted file mode 100644 index a6650ad..0000000 --- a/common/test.yml +++ /dev/null @@ -1,16 +0,0 @@ -services: - payment-test-redis: - container_name: payment-test-redis - image: valkey/valkey:8.0.1 - ports: - - 16390:6379 - volumes: - - kokomen-payment-test-redis-data:/data - healthcheck: - test: [ "CMD", "redis-cli", "ping" ] - interval: 5s - timeout: 3s - retries: 5 - -volumes: - kokomen-payment-test-redis-data: diff --git a/domain/run-test-mysql.sh b/domain/run-test-mysql.sh index 4e1ccdd..73f0bcf 100755 --- a/domain/run-test-mysql.sh +++ b/domain/run-test-mysql.sh @@ -1,8 +1,8 @@ #!/bin/bash if ! docker ps --format '{{.Names}}' | grep -q '^payment-test-mysql$'; then - echo "payment-test-mysql 컨테이너가 실행 중이 아닙니다. docker-compose -f test.yml up -d payment-test-mysql로 시작합니다..." - docker compose -f test.yml up -d payment-test-mysql + echo "payment-test-mysql 컨테이너가 실행 중이 아닙니다. docker compose -f test-docker-compose.yml up -d payment-test-mysql로 시작합니다..." + docker compose -f "$(git rev-parse --show-toplevel)/test-docker-compose.yml" up -d payment-test-mysql else echo "payment-test-mysql 컨테이너가 이미 실행 중입니다." fi diff --git a/domain/test.yml b/test-docker-compose.yml similarity index 62% rename from domain/test.yml rename to test-docker-compose.yml index 112aa58..37838e1 100644 --- a/domain/test.yml +++ b/test-docker-compose.yml @@ -21,3 +21,19 @@ services: interval: 5s timeout: 3s retries: 5 + + payment-test-redis: + container_name: payment-test-redis + image: valkey/valkey:8.0.1 + ports: + - 16390:6379 + volumes: + - kokomen-payment-test-redis-data:/data + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + interval: 5s + timeout: 3s + retries: 5 + +volumes: + kokomen-payment-test-redis-data: From b0ac8f67c4ef2cc06b1855f6ca0ad5f4d6708bd2 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 18:41:55 +0900 Subject: [PATCH 04/18] chore: .gitignore --- .claude/settings.local.json | 10 ---------- .gitignore | 1 + 2 files changed, 1 insertion(+), 10 deletions(-) delete mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index ea2b201..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "permissions": { - "allow": [ - "Bash(./gradlew:*)", - "Bash(find:*)" - ], - "deny": [], - "ask": [] - } -} diff --git a/.gitignore b/.gitignore index c2065bc..a81e60d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,4 @@ out/ ### VS Code ### .vscode/ +.claude From 97f10a79bbda13683a7ef49002931e6116e8180d Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 18:54:12 +0900 Subject: [PATCH 05/18] =?UTF-8?q?refactor:=20=EC=BB=A4=EC=8A=A4=ED=85=80?= =?UTF-8?q?=20=EC=98=88=EC=99=B8=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokomen/global/exception/NotFoundException.java | 8 ++++++++ .../payment/service/TosspaymentsPaymentResultService.java | 3 ++- .../payment/service/TosspaymentsPaymentService.java | 5 +++-- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java b/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java new file mode 100644 index 0000000..0d73b5f --- /dev/null +++ b/common/src/main/java/com/samhap/kokomen/global/exception/NotFoundException.java @@ -0,0 +1,8 @@ +package com.samhap.kokomen.global.exception; + +public class NotFoundException extends KokomenException { + + public NotFoundException(String message) { + super(message, 404); + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java index 6e90502..f26191e 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.payment.service; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; import lombok.RequiredArgsConstructor; @@ -20,6 +21,6 @@ public TosspaymentsPaymentResult save(TosspaymentsPaymentResult tosspaymentsPaym @Transactional(readOnly = true) public TosspaymentsPaymentResult readByTosspaymentsPaymentId(Long tosspaymentsPaymentId) { return tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(tosspaymentsPaymentId) - .orElseThrow(() -> new IllegalStateException("해당 결제의 결과 정보가 존재하지 않습니다. tosspaymentsPaymentId: " + tosspaymentsPaymentId)); + .orElseThrow(() -> new NotFoundException("해당 결제의 결과 정보가 존재하지 않습니다. tosspaymentsPaymentId: " + tosspaymentsPaymentId)); } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java index c4d7259..17237c1 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -1,5 +1,6 @@ package com.samhap.kokomen.payment.service; +import com.samhap.kokomen.global.exception.NotFoundException; import com.samhap.kokomen.payment.domain.PaymentState; import com.samhap.kokomen.payment.domain.TosspaymentsPayment; import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; @@ -22,13 +23,13 @@ public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { @Transactional(readOnly = true) public TosspaymentsPayment readById(Long id) { return tosspaymentsPaymentRepository.findById(id) - .orElseThrow(() -> new IllegalStateException("해당 id의 결제 정보가 존재하지 않습니다. id: " + id)); + .orElseThrow(() -> new NotFoundException("해당 id의 결제 정보가 존재하지 않습니다. id: " + id)); } @Transactional(readOnly = true) public TosspaymentsPayment readByPaymentKey(String paymentKey) { return tosspaymentsPaymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> new IllegalStateException("해당 paymentKey의 결제 정보가 존재하지 않습니다. paymentKey: " + paymentKey)); + .orElseThrow(() -> new NotFoundException("해당 paymentKey의 결제 정보가 존재하지 않습니다. paymentKey: " + paymentKey)); } @Transactional From 002d32f238a671da180a381397414c213b820dd7 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Wed, 11 Feb 2026 19:01:35 +0900 Subject: [PATCH 06/18] =?UTF-8?q?refactor:=20=EC=8B=9C=ED=81=AC=EB=A6=BF?= =?UTF-8?q?=20=ED=82=A4=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-internal-prod.yml | 1 + docker/prod/docker-compose-prod.yml | 1 + external/src/main/resources/application-external.yml | 2 +- 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/cd-internal-prod.yml b/.github/workflows/cd-internal-prod.yml index 4d0db7a..2082018 100644 --- a/.github/workflows/cd-internal-prod.yml +++ b/.github/workflows/cd-internal-prod.yml @@ -101,6 +101,7 @@ jobs: SPRING_DATASOURCE_USERNAME_PROD: ${{ secrets.SPRING_DATASOURCE_USERNAME_PROD }} SPRING_DATASOURCE_PASSWORD_PROD: ${{ secrets.SPRING_DATASOURCE_PASSWORD_PROD }} SPRING_DATASOURCE_URL_PROD: ${{ secrets.SPRING_DATASOURCE_URL_PROD }} + WIDGET_SECRET_KEY_PROD: ${{ secrets.WIDGET_SECRET_KEY_PROD }} run: | export HOSTNAME=$(hostname) cd kokomen-payment/docker/prod diff --git a/docker/prod/docker-compose-prod.yml b/docker/prod/docker-compose-prod.yml index 31ec1ed..bd77ad4 100644 --- a/docker/prod/docker-compose-prod.yml +++ b/docker/prod/docker-compose-prod.yml @@ -39,6 +39,7 @@ services: SPRING_DATASOURCE_URL_PROD: ${SPRING_DATASOURCE_URL_PROD} SPRING_DATASOURCE_USERNAME_PROD: ${SPRING_DATASOURCE_USERNAME_PROD} SPRING_DATASOURCE_PASSWORD_PROD: ${SPRING_DATASOURCE_PASSWORD_PROD} + WIDGET_SECRET_KEY_PROD: ${WIDGET_SECRET_KEY_PROD} nginx: image: nginx:1.28.0 diff --git a/external/src/main/resources/application-external.yml b/external/src/main/resources/application-external.yml index 42add61..69defca 100644 --- a/external/src/main/resources/application-external.yml +++ b/external/src/main/resources/application-external.yml @@ -22,4 +22,4 @@ spring: activate: on-profile: prod tosspayments: - widget-secret-key: test_gsk_docs_OaPz8L5KdmQXkzRz3y47BMw6 + widget-secret-key: ${WIDGET_SECRET_KEY_PROD} From 6edc07e1100315927c623373959ac3d5341a8202 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:03:07 +0900 Subject: [PATCH 07/18] =?UTF-8?q?refactor:=20=EB=9E=8C=EB=8B=A4=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokomen/payment/external/TossPaymentsClientBuilder.java | 2 +- .../kokomen/global/exception/GlobalExceptionHandler.java | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java b/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java index d1ef77c..c4b05ab 100644 --- a/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java +++ b/external/src/main/java/com/samhap/kokomen/payment/external/TossPaymentsClientBuilder.java @@ -36,7 +36,7 @@ public TossPaymentsClientBuilder( .defaultHeader("Authorization", "Basic " + encodedSecretKey) .defaultHeader("Content-Type", "application/json") .messageConverters(converters -> { - converters.removeIf(converter -> converter instanceof MappingJackson2HttpMessageConverter); + converters.removeIf(MappingJackson2HttpMessageConverter.class::isInstance); converters.add(new MappingJackson2HttpMessageConverter(createObjectMapper())); }); } diff --git a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 01fb80d..204ede2 100644 --- a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -27,7 +28,7 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -53,4 +54,4 @@ public ResponseEntity handleException(Exception e) { return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); } -} \ No newline at end of file +} From 85ab7a702c82ee433a11544864da4e1a0795cda4 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:15:47 +0900 Subject: [PATCH 08/18] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=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 --- .../InternalServerErrorException.java | 4 ++ .../payment/service/PaymentFacadeService.java | 62 +++++++++++++------ 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java index c96554a..fe7f356 100644 --- a/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java +++ b/common/src/main/java/com/samhap/kokomen/global/exception/InternalServerErrorException.java @@ -5,4 +5,8 @@ public class InternalServerErrorException extends KokomenException { public InternalServerErrorException(String message) { super(message, 500); } + + public InternalServerErrorException(String message, Throwable cause) { + super(message, cause, 500); + } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index a04258e..e2fdcc3 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -1,10 +1,13 @@ package com.samhap.kokomen.payment.service; import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.KokomenException; import com.samhap.kokomen.payment.domain.PaymentState; import com.samhap.kokomen.payment.domain.TosspaymentsPayment; import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; import com.samhap.kokomen.payment.external.TosspaymentsClient; +import com.samhap.kokomen.payment.external.TosspaymentsInternalServerErrorCode; import com.samhap.kokomen.payment.external.dto.Failure; import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentCancelRequest; import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; @@ -33,7 +36,14 @@ public PaymentResponse confirmPayment(ConfirmRequest request) { try { TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); return PaymentResponse.from(tosspaymentsPaymentResponse); + } catch (KokomenException e) { + // inner에서 상태 처리 완료 (BadRequestException, InternalServerErrorException) + throw e; + } catch (HttpServerErrorException | ResourceAccessException e) { + // inner에서 상태 처리 완료 (NEED_CANCEL, CONNECTION_TIMEOUT) + throw e; } catch (Exception e) { + // 예상치 못한 예외만 NEED_CANCEL 설정 tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); throw e; } @@ -48,31 +58,31 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED); return tosspaymentsConfirmResponse; } catch (HttpClientErrorException e) { - // 토스에서 400 에러를 응답하면 TosspaymentsPaymentResponse 스펙이 아니라 {"code": ..., "message": ...} 스펙으로만 응답이 온다. Failure failure = e.getResponseBodyAs(Failure.class); String code = failure.code(); - log.info("토스 결제 실패(400), code = " + code + ", message = " + failure.message()); - // TODO: 기존 로직 제대로 돌아가도록 반영 -// TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); -// String code = tosspaymentsConfirmResponse.failure().code(); -// if (TosspaymentsInternalServerErrorCode.contains(code)) { -// TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); -// tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.SERVER_BAD_REQUEST); -// throw e; -// } -// -// tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); -// throw new BadRequestException(tosspaymentsConfirmResponse.failure().message(), e); + + if (TosspaymentsInternalServerErrorCode.contains(code)) { + log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + throw new InternalServerErrorException("결제 처리 중 서버 오류가 발생했습니다.", e); + } + + log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); throw new BadRequestException(failure.message(), e); } catch (HttpServerErrorException e) { // TODO: retry - TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); - TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); - tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL); + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL); + } catch (Exception parseException) { + log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } throw e; } catch (ResourceAccessException e) { - if (e.getRootCause() instanceof SocketTimeoutException) { - SocketTimeoutException socketTimeoutException = (SocketTimeoutException) e.getRootCause(); + if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { if (socketTimeoutException.getMessage().contains("Connect timed out")) { // TODO: retry tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); @@ -91,7 +101,19 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp public void cancelPayment(CancelRequest request) { TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(request.cancelReason()); - TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest); - tosspaymentsTransactionService.applyCancelResult(response); + try { + TosspaymentsPaymentResponse response = tosspaymentsClient.cancelPayment(request.paymentKey(), tosspaymentsPaymentCancelRequest); + tosspaymentsTransactionService.applyCancelResult(response); + } catch (HttpClientErrorException e) { + Failure failure = e.getResponseBodyAs(Failure.class); + log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), failure.message()); + throw new BadRequestException(failure.message(), e); + } catch (HttpServerErrorException e) { + log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode()); + throw new InternalServerErrorException("결제 취소 처리 중 서버 오류가 발생했습니다.", e); + } catch (ResourceAccessException e) { + log.error("결제 취소 네트워크 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException("결제 취소 처리 중 네트워크 오류가 발생했습니다.", e); + } } } From 98a0630e64bccbe3b3151c610802c9c6e770daf0 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:25:00 +0900 Subject: [PATCH 09/18] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EB=A9=94=EC=86=8C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentFacadeService.java | 87 ++++++++++--------- 1 file changed, 47 insertions(+), 40 deletions(-) diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index e2fdcc3..eaf7fdd 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -36,13 +36,10 @@ public PaymentResponse confirmPayment(ConfirmRequest request) { try { TosspaymentsPaymentResponse tosspaymentsPaymentResponse = confirmPayment(request, tosspaymentsPayment); return PaymentResponse.from(tosspaymentsPaymentResponse); - } catch (KokomenException e) { - // inner에서 상태 처리 완료 (BadRequestException, InternalServerErrorException) + } catch (KokomenException | HttpServerErrorException | ResourceAccessException e) { + // inner에서 상태 처리 완료 throw e; - } catch (HttpServerErrorException | ResourceAccessException e) { - // inner에서 상태 처리 완료 (NEED_CANCEL, CONNECTION_TIMEOUT) - throw e; - } catch (Exception e) { + } catch (Exception e) { // 예상치 못한 예외만 NEED_CANCEL 설정 tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); throw e; @@ -58,47 +55,57 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED); return tosspaymentsConfirmResponse; } catch (HttpClientErrorException e) { - Failure failure = e.getResponseBodyAs(Failure.class); - String code = failure.code(); - - if (TosspaymentsInternalServerErrorCode.contains(code)) { - log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); - throw new InternalServerErrorException("결제 처리 중 서버 오류가 발생했습니다.", e); - } - - log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); - throw new BadRequestException(failure.message(), e); + throw handleConfirmClientError(e, tosspaymentsPayment); } catch (HttpServerErrorException e) { - // TODO: retry - try { - TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); - TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); - tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL); - } catch (Exception parseException) { - log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); - } + handleConfirmServerError(e, tosspaymentsPayment); throw e; } catch (ResourceAccessException e) { - if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { - if (socketTimeoutException.getMessage().contains("Connect timed out")) { - // TODO: retry - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); - throw e; - } - if (socketTimeoutException.getMessage().contains("Read timed out")) { - // TODO: retry - tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); - throw e; - } - } - + handleConfirmNetworkError(e, tosspaymentsPayment); throw e; } } + private RuntimeException handleConfirmClientError(HttpClientErrorException e, TosspaymentsPayment tosspaymentsPayment) { + Failure failure = e.getResponseBodyAs(Failure.class); + String code = failure.code(); + + if (TosspaymentsInternalServerErrorCode.contains(code)) { + log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException("결제 처리 중 서버 오류가 발생했습니다.", e); + } + + log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CLIENT_BAD_REQUEST); + return new BadRequestException(failure.message(), e); + } + + private void handleConfirmServerError(HttpServerErrorException e, TosspaymentsPayment tosspaymentsPayment) { + // TODO: retry + try { + TosspaymentsPaymentResponse tosspaymentsConfirmResponse = e.getResponseBodyAs(TosspaymentsPaymentResponse.class); + TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); + tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.NEED_CANCEL); + } catch (Exception parseException) { + log.warn("토스 5xx 응답 파싱 실패, 상태만 업데이트합니다. paymentId = {}", tosspaymentsPayment.getId(), parseException); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + } + + private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPayment tosspaymentsPayment) { + if (e.getRootCause() instanceof SocketTimeoutException socketTimeoutException) { + if (socketTimeoutException.getMessage().contains("Connect timed out")) { + // TODO: retry + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.CONNECTION_TIMEOUT); + return; + } + if (socketTimeoutException.getMessage().contains("Read timed out")) { + // TODO: retry + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + } + } + } + public void cancelPayment(CancelRequest request) { TosspaymentsPaymentCancelRequest tosspaymentsPaymentCancelRequest = new TosspaymentsPaymentCancelRequest(request.cancelReason()); try { From 83ca1f884d21b871f9ccedf9e9038ee40efde655 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:45:37 +0900 Subject: [PATCH 10/18] =?UTF-8?q?refactor:=20=EB=AF=B8=EC=82=AC=EC=9A=A9?= =?UTF-8?q?=20=EB=B3=80=EC=88=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samhap/kokomen/payment/domain/TosspaymentsPayment.java | 5 +---- .../payment/repository/TosspaymentsPaymentRepository.java | 3 ++- .../samhap/kokomen/payment/service/PaymentFacadeService.java | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index 6781b07..8b7a0df 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -68,7 +68,7 @@ public void updateState(PaymentState state) { this.state = state; } - public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount, String metadata) { + public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { if (!this.paymentKey.equals(paymentKey)) { throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); } @@ -78,8 +78,5 @@ public void validateTosspaymentsResult(String paymentKey, String orderId, Long t if (!this.totalAmount.equals(totalAmount)) { throw new InternalServerErrorException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); } -// if (!this.metadata.equals(metadata)) { -// throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 metadata가 DB에 저장된 값(%s)과 다릅니다.".formatted(metadata, this.metadata)); -// } } } diff --git a/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java index fc38fe1..89e8fc1 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java +++ b/domain/src/main/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepository.java @@ -5,5 +5,6 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface TosspaymentsPaymentRepository extends JpaRepository { + Optional findByPaymentKey(String paymentKey); -} \ No newline at end of file +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index eaf7fdd..b39e2d6 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -50,7 +50,7 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp try { TosspaymentsPaymentResponse tosspaymentsConfirmResponse = tosspaymentsClient.confirmPayment(request.toTosspaymentsConfirmRequest()); tosspaymentsPayment.validateTosspaymentsResult(tosspaymentsConfirmResponse.paymentKey(), tosspaymentsConfirmResponse.orderId(), - tosspaymentsConfirmResponse.totalAmount(), tosspaymentsConfirmResponse.metadata()); + tosspaymentsConfirmResponse.totalAmount()); TosspaymentsPaymentResult tosspaymentsPaymentResult = tosspaymentsConfirmResponse.toTosspaymentsPaymentResult(tosspaymentsPayment); tosspaymentsTransactionService.applyTosspaymentsPaymentResult(tosspaymentsPaymentResult, PaymentState.COMPLETED); return tosspaymentsConfirmResponse; From 96826a72636b1002135c30a99302659e91f6ca55 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:54:45 +0900 Subject: [PATCH 11/18] =?UTF-8?q?test:=20domain=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/TosspaymentsPaymentResultTest.java | 42 +++++++ .../domain/TosspaymentsPaymentTest.java | 64 +++++++++++ .../TosspaymentsPaymentFixtureBuilder.java | 66 +++++++++++ ...sspaymentsPaymentResultFixtureBuilder.java | 106 ++++++++++++++++++ .../TosspaymentsPaymentRepositoryTest.java | 52 +++++++++ 5 files changed, 330 insertions(+) create mode 100644 domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java create mode 100644 domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java create mode 100644 internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java create mode 100644 internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java create mode 100644 internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java diff --git a/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java new file mode 100644 index 0000000..0319b7c --- /dev/null +++ b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentResultTest.java @@ -0,0 +1,42 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.junit.jupiter.api.Assertions.assertAll; + +import java.time.LocalDateTime; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentResultTest { + + @Test + void 취소_정보를_업데이트할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + TosspaymentsPaymentResult result = new TosspaymentsPaymentResult( + payment, PaymentType.NORMAL, "tvivarepublica", "KRW", 10000L, "카드", + 10000L, TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + null, null, null, null, "KR", null, null + ); + + String cancelReason = "단순 변심"; + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + Long easyPayDiscountAmount = 0L; + String lastTransactionKey = "cancel_transaction_key"; + String cancelStatus = "DONE"; + TosspaymentsStatus tosspaymentsStatus = TosspaymentsStatus.CANCELED; + + result.updateCancelInfo(cancelReason, canceledAt, easyPayDiscountAmount, + lastTransactionKey, cancelStatus, tosspaymentsStatus); + + assertAll( + () -> assertThat(result.getCancelReason()).isEqualTo(cancelReason), + () -> assertThat(result.getCanceledAt()).isEqualTo(canceledAt), + () -> assertThat(result.getEasyPayDiscountAmount()).isEqualTo(easyPayDiscountAmount), + () -> assertThat(result.getLastTransactionKey()).isEqualTo(lastTransactionKey), + () -> assertThat(result.getCancelStatus()).isEqualTo(cancelStatus), + () -> assertThat(result.getTosspaymentsStatus()).isEqualTo(tosspaymentsStatus) + ); + } +} diff --git a/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java new file mode 100644 index 0000000..33e95fb --- /dev/null +++ b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java @@ -0,0 +1,64 @@ +package com.samhap.kokomen.payment.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; + +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import org.junit.jupiter.api.Test; + +class TosspaymentsPaymentTest { + + @Test + void 토스페이먼츠_응답_검증에_성공한다() { + String paymentKey = "payment_key"; + String orderId = "order_id"; + long totalAmount = 10000L; + TosspaymentsPayment payment = new TosspaymentsPayment( + paymentKey, 1L, orderId, "주문명", totalAmount, "{}", ServiceType.INTERVIEW + ); + + assertDoesNotThrow(() -> payment.validateTosspaymentsResult(paymentKey, orderId, totalAmount)); + } + + @Test + void paymentKey가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("wrong_key", "order_id", 10000L)) + .isInstanceOf(InternalServerErrorException.class); + } + + @Test + void orderId가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "wrong_order", 10000L)) + .isInstanceOf(InternalServerErrorException.class); + } + + @Test + void totalAmount가_다르면_검증에_실패한다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "order_id", 99999L)) + .isInstanceOf(InternalServerErrorException.class); + } + + @Test + void 결제_상태를_변경할_수_있다() { + TosspaymentsPayment payment = new TosspaymentsPayment( + "payment_key", 1L, "order_id", "주문명", 10000L, "{}", ServiceType.INTERVIEW + ); + + payment.updateState(PaymentState.APPROVED); + + assertThat(payment.getState()).isEqualTo(PaymentState.APPROVED); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java new file mode 100644 index 0000000..c2cde92 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentFixtureBuilder.java @@ -0,0 +1,66 @@ +package com.samhap.kokomen.global.fixture; + +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; + +public class TosspaymentsPaymentFixtureBuilder { + + private String paymentKey; + private Long memberId; + private String orderId; + private String orderName; + private Long totalAmount; + private String metadata; + private ServiceType serviceType; + + public static TosspaymentsPaymentFixtureBuilder builder() { + return new TosspaymentsPaymentFixtureBuilder(); + } + + public TosspaymentsPaymentFixtureBuilder paymentKey(String paymentKey) { + this.paymentKey = paymentKey; + return this; + } + + public TosspaymentsPaymentFixtureBuilder memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderId(String orderId) { + this.orderId = orderId; + return this; + } + + public TosspaymentsPaymentFixtureBuilder orderName(String orderName) { + this.orderName = orderName; + return this; + } + + public TosspaymentsPaymentFixtureBuilder totalAmount(Long totalAmount) { + this.totalAmount = totalAmount; + return this; + } + + public TosspaymentsPaymentFixtureBuilder metadata(String metadata) { + this.metadata = metadata; + return this; + } + + public TosspaymentsPaymentFixtureBuilder serviceType(ServiceType serviceType) { + this.serviceType = serviceType; + return this; + } + + public TosspaymentsPayment build() { + return new TosspaymentsPayment( + paymentKey != null ? paymentKey : "test_payment_key_123", + memberId != null ? memberId : 1L, + orderId != null ? orderId : "order_123", + orderName != null ? orderName : "테스트 주문", + totalAmount != null ? totalAmount : 10000L, + metadata != null ? metadata : "{\"test\": \"metadata\"}", + serviceType != null ? serviceType : ServiceType.INTERVIEW + ); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java new file mode 100644 index 0000000..966f476 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/global/fixture/TosspaymentsPaymentResultFixtureBuilder.java @@ -0,0 +1,106 @@ +package com.samhap.kokomen.global.fixture; + +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import java.time.LocalDateTime; + +public class TosspaymentsPaymentResultFixtureBuilder { + + private TosspaymentsPayment tosspaymentsPayment; + private PaymentType type; + private String mId; + private String currency; + private Long totalAmount; + private String method; + private Long balanceAmount; + private TosspaymentsStatus tosspaymentsStatus; + private LocalDateTime requestedAt; + private LocalDateTime approvedAt; + private String lastTransactionKey; + private Long suppliedAmount; + private Long vat; + private Long taxFreeAmount; + private Long taxExemptionAmount; + private boolean isPartialCancelable; + private String receiptUrl; + private String easyPayProvider; + private Long easyPayAmount; + private Long easyPayDiscountAmount; + private String country; + private String failureCode; + private String failureMessage; + + public static TosspaymentsPaymentResultFixtureBuilder builder() { + return new TosspaymentsPaymentResultFixtureBuilder(); + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsPayment(TosspaymentsPayment tosspaymentsPayment) { + this.tosspaymentsPayment = tosspaymentsPayment; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder type(PaymentType type) { + this.type = type; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder method(String method) { + this.method = method; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder tosspaymentsStatus(TosspaymentsStatus status) { + this.tosspaymentsStatus = status; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder approvedAt(LocalDateTime approvedAt) { + this.approvedAt = approvedAt; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder receiptUrl(String receiptUrl) { + this.receiptUrl = receiptUrl; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureCode(String failureCode) { + this.failureCode = failureCode; + return this; + } + + public TosspaymentsPaymentResultFixtureBuilder failureMessage(String failureMessage) { + this.failureMessage = failureMessage; + return this; + } + + public TosspaymentsPaymentResult build() { + return new TosspaymentsPaymentResult( + tosspaymentsPayment, + type != null ? type : PaymentType.NORMAL, + mId != null ? mId : "tvivarepublica", + currency != null ? currency : "KRW", + totalAmount != null ? totalAmount : 10000L, + method != null ? method : "카드", + balanceAmount != null ? balanceAmount : 10000L, + tosspaymentsStatus != null ? tosspaymentsStatus : TosspaymentsStatus.DONE, + requestedAt != null ? requestedAt : LocalDateTime.now().minusMinutes(5), + approvedAt, + lastTransactionKey != null ? lastTransactionKey : "test_transaction_key", + suppliedAmount != null ? suppliedAmount : 9091L, + vat != null ? vat : 909L, + taxFreeAmount != null ? taxFreeAmount : 0L, + taxExemptionAmount != null ? taxExemptionAmount : 0L, + isPartialCancelable, + receiptUrl, + easyPayProvider, + easyPayAmount, + easyPayDiscountAmount, + country != null ? country : "KR", + failureCode, + failureMessage + ); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java b/internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java new file mode 100644 index 0000000..b2a5e72 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/payment/repository/TosspaymentsPaymentRepositoryTest.java @@ -0,0 +1,52 @@ +package com.samhap.kokomen.payment.repository; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DataIntegrityViolationException; + +class TosspaymentsPaymentRepositoryTest extends BaseTest { + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Test + void 중복된_paymentKey로_저장하면_예외가_발생한다() { + String duplicateKey = "duplicate_key"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_1") + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey(duplicateKey) + .orderId("order_2") + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } + + @Test + void 중복된_orderId로_저장하면_예외가_발생한다() { + String duplicateOrder = "duplicate_order"; + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_1") + .orderId(duplicateOrder) + .build(); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPayment duplicatePayment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("key_2") + .orderId(duplicateOrder) + .build(); + + assertThatThrownBy(() -> tosspaymentsPaymentRepository.save(duplicatePayment)) + .isInstanceOf(DataIntegrityViolationException.class); + } +} From 713e009e25b2e9ad7dcf4556828e51712e619a60 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 13:58:29 +0900 Subject: [PATCH 12/18] =?UTF-8?q?chore:=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-api-dev.yml | 15 --------------- .github/workflows/cd-api-prod.yml | 15 --------------- .github/workflows/cd-internal-dev.yml | 15 --------------- .github/workflows/cd-internal-prod.yml | 15 --------------- .github/workflows/ci-api-test.yml | 14 -------------- .github/workflows/ci-internal-test.yml | 14 -------------- 6 files changed, 88 deletions(-) diff --git a/.github/workflows/cd-api-dev.yml b/.github/workflows/cd-api-dev.yml index 2829049..3a45e37 100644 --- a/.github/workflows/cd-api-dev.yml +++ b/.github/workflows/cd-api-dev.yml @@ -5,22 +5,7 @@ on: branches: [ develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: develop - filters: | - api: - - 'api/**' - build-api: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-api-prod.yml b/.github/workflows/cd-api-prod.yml index 41169bd..f14798b 100644 --- a/.github/workflows/cd-api-prod.yml +++ b/.github/workflows/cd-api-prod.yml @@ -5,22 +5,7 @@ on: branches: [ main ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: main - filters: | - api: - - 'api/**' - build-api: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-internal-dev.yml b/.github/workflows/cd-internal-dev.yml index 9cb976d..82da3ea 100644 --- a/.github/workflows/cd-internal-dev.yml +++ b/.github/workflows/cd-internal-dev.yml @@ -5,22 +5,7 @@ on: branches: [ develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: develop - filters: | - internal: - - 'internal/**' - build-internal: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/cd-internal-prod.yml b/.github/workflows/cd-internal-prod.yml index 2082018..12a6f89 100644 --- a/.github/workflows/cd-internal-prod.yml +++ b/.github/workflows/cd-internal-prod.yml @@ -5,22 +5,7 @@ on: branches: [ main ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - base: main - filters: | - internal: - - 'internal/**' - build-internal: - needs: detect-changes runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 diff --git a/.github/workflows/ci-api-test.yml b/.github/workflows/ci-api-test.yml index c09101b..3bb6876 100644 --- a/.github/workflows/ci-api-test.yml +++ b/.github/workflows/ci-api-test.yml @@ -5,21 +5,7 @@ on: branches: [ main, develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - api_changed: ${{ steps.filter.outputs.api }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - api: - - 'api/**' - build: - needs: detect-changes runs-on: ubuntu-latest permissions: checks: write diff --git a/.github/workflows/ci-internal-test.yml b/.github/workflows/ci-internal-test.yml index b29029b..5ab5ca8 100644 --- a/.github/workflows/ci-internal-test.yml +++ b/.github/workflows/ci-internal-test.yml @@ -5,21 +5,7 @@ on: branches: [ main, develop ] jobs: - detect-changes: - runs-on: ubuntu-latest - outputs: - internal_changed: ${{ steps.filter.outputs.internal }} - steps: - - uses: actions/checkout@v4 - - id: filter - uses: dorny/paths-filter@v3 - with: - filters: | - internal: - - 'internal/**' - build: - needs: detect-changes runs-on: ubuntu-latest permissions: checks: write From bbc84df50b2cacfa69e555095d708bf38c044e7c Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 14:49:18 +0900 Subject: [PATCH 13/18] =?UTF-8?q?test:=20internal=20=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/PaymentControllerTest.java | 4 - .../service/PaymentFacadeServiceTest.java | 248 ++++++++++++++++++ .../TosspaymentsTransactionServiceTest.java | 101 +++++++ 3 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java create mode 100644 internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java diff --git a/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java b/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java index 3e66306..3794906 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/controller/PaymentControllerTest.java @@ -25,7 +25,6 @@ class PaymentControllerTest extends BaseControllerTest { @Test void 결제를_승인한다() throws Exception { - // given PaymentResponse mockResponse = new PaymentResponse( "test_payment_key_001", PaymentType.NORMAL, @@ -71,7 +70,6 @@ class PaymentControllerTest extends BaseControllerTest { } """; - // when & then mockMvc.perform(post("/internal/v1/payments/confirm") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) @@ -80,7 +78,6 @@ class PaymentControllerTest extends BaseControllerTest { @Test void 결제를_취소한다() throws Exception { - // given doNothing().when(paymentFacadeService).cancelPayment(any(CancelRequest.class)); String requestJson = """ @@ -90,7 +87,6 @@ class PaymentControllerTest extends BaseControllerTest { } """; - // when & then mockMvc.perform(post("/internal/v1/payments/cancel") .contentType(MediaType.APPLICATION_JSON) .content(requestJson)) diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java new file mode 100644 index 0000000..e097460 --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -0,0 +1,248 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.exception.BadRequestException; +import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.fixture.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.ServiceType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.TosspaymentsClient; +import com.samhap.kokomen.payment.external.dto.Failure; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import com.samhap.kokomen.payment.service.dto.CancelRequest; +import com.samhap.kokomen.payment.service.dto.ConfirmRequest; +import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.SocketTimeoutException; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; + +class PaymentFacadeServiceTest extends BaseTest { + + @MockitoBean + private TosspaymentsClient tosspaymentsClient; + + @Autowired + private PaymentFacadeService paymentFacadeService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 결제_승인에_성공한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any())).thenReturn(createSuccessResponse()); + + PaymentResponse response = paymentFacadeService.confirmPayment(request); + + assertThat(response.paymentKey()).isEqualTo("payment_key"); + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.COMPLETED); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 서버_원인_400_에러가_발생하면_SERVER_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_API_KEY", "잘못된 API 키입니다.")); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + + @Test + void 클라이언트_원인_400_에러가_발생하면_CLIENT_BAD_REQUEST_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("INVALID_CARD_NUMBER", "카드 번호가 유효하지 않습니다.")); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(BadRequestException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CLIENT_BAD_REQUEST); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_성공하면_결과를_저장하고_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenReturn(createSuccessResponse()); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isPresent(); + } + + @Test + void 결제_승인_시_5xx_에러_응답_파싱에_실패하면_NEED_CANCEL_상태만_변경한다() { + ConfirmRequest request = createConfirmRequest(); + HttpServerErrorException serverError = mock(HttpServerErrorException.class); + when(serverError.getResponseBodyAs(TosspaymentsPaymentResponse.class)) + .thenThrow(new RuntimeException("파싱 실패")); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(serverError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(HttpServerErrorException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + assertThat(tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(payment.getId())).isEmpty(); + } + + @Test + void 결제_승인_시_연결_타임아웃이_발생하면_CONNECTION_TIMEOUT_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Connect timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.CONNECTION_TIMEOUT); + } + + @Test + void 결제_승인_시_읽기_타임아웃이_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any())) + .thenThrow(new ResourceAccessException("I/O error", new SocketTimeoutException("Read timed out"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_예상치_못한_예외가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(RuntimeException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_취소에_성공한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult paymentResult = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(paymentResult); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse cancelResponse = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + when(tosspaymentsClient.cancelPayment(any(), any())).thenReturn(cancelResponse); + + paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심")); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 결제_취소_시_400_에러가_발생하면_BadRequestException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)) + .thenReturn(new Failure("ALREADY_CANCELED_PAYMENT", "이미 취소된 결제입니다.")); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(BadRequestException.class); + } + + @Test + void 결제_취소_시_5xx_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class); + } + + @Test + void 결제_취소_시_네트워크_에러가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new ResourceAccessException("네트워크 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class); + } + + private ConfirmRequest createConfirmRequest() { + return new ConfirmRequest("payment_key", "order_id", 10000L, "주문명", 1L, "{}", ServiceType.INTERVIEW); + } + + private TosspaymentsPaymentResponse createSuccessResponse() { + return new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.DONE, LocalDateTime.now(), LocalDateTime.now(), + "transaction_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + } +} diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java new file mode 100644 index 0000000..2415d7b --- /dev/null +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/TosspaymentsTransactionServiceTest.java @@ -0,0 +1,101 @@ +package com.samhap.kokomen.payment.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.samhap.kokomen.global.BaseTest; +import com.samhap.kokomen.global.fixture.TosspaymentsPaymentFixtureBuilder; +import com.samhap.kokomen.global.fixture.TosspaymentsPaymentResultFixtureBuilder; +import com.samhap.kokomen.payment.domain.PaymentState; +import com.samhap.kokomen.payment.domain.PaymentType; +import com.samhap.kokomen.payment.domain.TosspaymentsPayment; +import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; +import com.samhap.kokomen.payment.domain.TosspaymentsStatus; +import com.samhap.kokomen.payment.external.dto.TosspaymentsCancel; +import com.samhap.kokomen.payment.external.dto.TosspaymentsPaymentResponse; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; +import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +class TosspaymentsTransactionServiceTest extends BaseTest { + + @Autowired + private TosspaymentsTransactionService tosspaymentsTransactionService; + + @Autowired + private TosspaymentsPaymentRepository tosspaymentsPaymentRepository; + + @Autowired + private TosspaymentsPaymentResultRepository tosspaymentsPaymentResultRepository; + + @Test + void 취소_결과에_취소_정보가_있으면_결제_결과에_취소_정보를_업데이트한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + LocalDateTime canceledAt = LocalDateTime.of(2025, 1, 1, 12, 0); + TosspaymentsCancel cancel = new TosspaymentsCancel( + "cancel_tx_key", "단순 변심", 0L, + canceledAt, 0L, null, 10000L, 0L, 10000L, "DONE", null + ); + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "cancel_tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, List.of(cancel) + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isEqualTo("단순 변심"); + assertThat(updatedResult.getCanceledAt()).isEqualTo(canceledAt); + assertThat(updatedResult.getCancelStatus()).isEqualTo("DONE"); + } + + @Test + void 취소_결과에_취소_정보가_없으면_결제_상태만_변경한다() { + TosspaymentsPayment payment = TosspaymentsPaymentFixtureBuilder.builder() + .paymentKey("payment_key") + .build(); + payment.updateState(PaymentState.COMPLETED); + tosspaymentsPaymentRepository.save(payment); + + TosspaymentsPaymentResult result = TosspaymentsPaymentResultFixtureBuilder.builder() + .tosspaymentsPayment(payment) + .build(); + tosspaymentsPaymentResultRepository.save(result); + + TosspaymentsPaymentResponse response = new TosspaymentsPaymentResponse( + "payment_key", PaymentType.NORMAL, "order_id", "주문명", + "tvivarepublica", "KRW", "카드", 10000L, 10000L, + TosspaymentsStatus.CANCELED, LocalDateTime.now(), LocalDateTime.now(), + "tx_key", 9091L, 909L, 0L, 0L, true, + "{}", null, null, null, "KR", null, null + ); + + tosspaymentsTransactionService.applyCancelResult(response); + + TosspaymentsPayment updatedPayment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key") + .orElseThrow(); + assertThat(updatedPayment.getState()).isEqualTo(PaymentState.CANCELED); + TosspaymentsPaymentResult updatedResult = tosspaymentsPaymentResultRepository + .findByTosspaymentsPaymentId(updatedPayment.getId()).orElseThrow(); + assertThat(updatedResult.getCancelReason()).isNull(); + } +} From 2f054dafd7bfe28050b52feb31490c064c8821fc Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 14:58:16 +0900 Subject: [PATCH 14/18] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EB=A9=94=EC=8B=9C=EC=A7=80=20=EC=A0=95=EC=B1=85=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 --- .../global/exception/ApiErrorMessage.java | 22 +++++++++++++ .../exception/GlobalExceptionHandler.java | 31 +++++++++---------- .../MemberAuthArgumentResolver.java | 9 +++--- .../payment/domain/PaymentErrorMessage.java | 17 ++++++++++ .../payment/domain/TosspaymentsPayment.java | 16 +++++++--- .../domain/TosspaymentsPaymentTest.java | 9 ++++-- .../exception/ExternalErrorMessage.java | 19 ++++++++++++ .../exception/GlobalExceptionHandler.java | 31 +++++++++---------- .../exception/GlobalExceptionHandler.java | 6 ++-- .../exception/PaymentServiceErrorMessage.java | 23 ++++++++++++++ .../payment/service/PaymentFacadeService.java | 7 +++-- .../TosspaymentsPaymentResultService.java | 8 ++++- .../service/TosspaymentsPaymentService.java | 13 ++++++-- .../service/PaymentFacadeServiceTest.java | 10 ++++-- 14 files changed, 165 insertions(+), 56 deletions(-) create mode 100644 api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java create mode 100644 domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java create mode 100644 external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java create mode 100644 internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java b/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java new file mode 100644 index 0000000..09e603c --- /dev/null +++ b/api/src/main/java/com/samhap/kokomen/global/exception/ApiErrorMessage.java @@ -0,0 +1,22 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum ApiErrorMessage { + + AUTHENTICATION_ANNOTATION_REQUIRED("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."), + LOGIN_REQUIRED("로그인이 필요합니다"), + MEMBER_ID_NOT_IN_SESSION("세션에 MEMBER_ID가 없습니다."), + INVALID_REQUEST("잘못된 요청입니다."), + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다. JSON 형식을 확인해주세요."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + ApiErrorMessage(String message) { + this.message = message; + } +} diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 4f880ec..8fe31da 100644 --- a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -25,12 +26,12 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = ApiErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -44,35 +45,33 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho } @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; - log.warn("MissingServletRequestParameterException :: message: {}", message); + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + log.warn("MissingServletRequestParameterException :: parameterName: {}", e.getParameterName()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ApiErrorMessage.MISSING_REQUEST_PARAMETER.getMessage())); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - String message = "잘못된 요청 형식입니다. JSON 형식을 확인해주세요."; if (e.getCause() instanceof InvalidFormatException invalidFormatException) { String fieldName = invalidFormatException.getPath().get(0).getFieldName(); String invalidValue = String.valueOf(invalidFormatException.getValue()); - message = String.format( - "JSON 파싱 오류: '%s' 필드에 유효하지 않은 값이 전달되었습니다. (전달된 값: '%s')", - fieldName, - invalidValue - ); + log.warn("HttpMessageNotReadableException :: fieldName: {}, invalidValue: {}", fieldName, invalidValue); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(ApiErrorMessage.JSON_PARSE_ERROR.getMessage())); } - log.warn("HttpMessageNotReadableException :: message: {}", message); + log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ApiErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(ApiErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } } diff --git a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java index dfda578..d4af78f 100644 --- a/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java +++ b/api/src/main/java/com/samhap/kokomen/global/infrastructure/MemberAuthArgumentResolver.java @@ -2,6 +2,7 @@ import com.samhap.kokomen.global.annotation.Authentication; import com.samhap.kokomen.global.dto.MemberAuth; +import com.samhap.kokomen.global.exception.ApiErrorMessage; import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.global.exception.UnauthorizedException; import jakarta.servlet.http.HttpServletRequest; @@ -30,7 +31,7 @@ public Object resolveArgument(MethodParameter parameter, WebDataBinderFactory binderFactory) throws Exception { Authentication authentication = parameter.getParameterAnnotation(Authentication.class); if (authentication == null) { - throw new InternalServerErrorException("MemberAuth 파라미터는 @Authentication 어노테이션이 있어야 합니다."); + throw new InternalServerErrorException(ApiErrorMessage.AUTHENTICATION_ANNOTATION_REQUIRED.getMessage()); } boolean authenticationRequired = authentication.required(); HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); @@ -48,16 +49,16 @@ public Object resolveArgument(MethodParameter parameter, private void validateAuthentication(HttpSession session, boolean authenticationRequired) { if (session == null && authenticationRequired) { - throw new UnauthorizedException("로그인이 필요합니다"); + throw new UnauthorizedException(ApiErrorMessage.LOGIN_REQUIRED.getMessage()); } } private void validateAuthentication(Long memberId, boolean authenticationRequired) { if (memberId == null) { - log.error("세션에 MEMBER_ID가 없습니다."); + log.error(ApiErrorMessage.MEMBER_ID_NOT_IN_SESSION.getMessage()); } if (memberId == null && authenticationRequired) { - throw new UnauthorizedException("세션에 MEMBER_ID가 없습니다."); + throw new UnauthorizedException(ApiErrorMessage.MEMBER_ID_NOT_IN_SESSION.getMessage()); } } } diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java new file mode 100644 index 0000000..acaf60a --- /dev/null +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/PaymentErrorMessage.java @@ -0,0 +1,17 @@ +package com.samhap.kokomen.payment.domain; + +import lombok.Getter; + +@Getter +public enum PaymentErrorMessage { + + PAYMENT_KEY_MISMATCH("토스 페이먼츠 응답의 paymentKey가 DB에 저장된 값과 다릅니다."), + ORDER_ID_MISMATCH("토스 페이먼츠 응답의 orderId가 DB에 저장된 값과 다릅니다."), + TOTAL_AMOUNT_MISMATCH("토스 페이먼츠 응답의 totalAmount가 DB에 저장된 값과 다릅니다."); + + private final String message; + + PaymentErrorMessage(String message) { + this.message = message; + } +} diff --git a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java index 8b7a0df..2ecdb4a 100644 --- a/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java +++ b/domain/src/main/java/com/samhap/kokomen/payment/domain/TosspaymentsPayment.java @@ -1,6 +1,7 @@ package com.samhap.kokomen.payment.domain; import com.samhap.kokomen.global.domain.BaseEntity; +import com.samhap.kokomen.global.exception.InternalServerErrorException; import jakarta.persistence.Column; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; @@ -10,11 +11,12 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; -import com.samhap.kokomen.global.exception.InternalServerErrorException; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @@ -53,7 +55,8 @@ public class TosspaymentsPayment extends BaseEntity { @Enumerated(EnumType.STRING) private ServiceType serviceType; - public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, String metadata, ServiceType serviceType) { + public TosspaymentsPayment(String paymentKey, Long memberId, String orderId, String orderName, Long totalAmount, + String metadata, ServiceType serviceType) { this.paymentKey = paymentKey; this.memberId = memberId; this.orderId = orderId; @@ -70,13 +73,16 @@ public void updateState(PaymentState state) { public void validateTosspaymentsResult(String paymentKey, String orderId, Long totalAmount) { if (!this.paymentKey.equals(paymentKey)) { - throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 paymentKey가 DB에 저장된 값(%s)과 다릅니다.".formatted(paymentKey, this.paymentKey)); + log.error("paymentKey 불일치 - 응답: {}, DB: {}", paymentKey, this.paymentKey); + throw new InternalServerErrorException(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); } if (!this.orderId.equals(orderId)) { - throw new InternalServerErrorException("토스 페이먼츠 응답(%s)의 orderId가 DB에 저장된 값(%s)과 다릅니다.".formatted(orderId, this.orderId)); + log.error("orderId 불일치 - 응답: {}, DB: {}", orderId, this.orderId); + throw new InternalServerErrorException(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); } if (!this.totalAmount.equals(totalAmount)) { - throw new InternalServerErrorException("토스 페이먼츠 응답(%d)의 totalAmount가 DB에 저장된 값(%d)과 다릅니다.".formatted(totalAmount, this.totalAmount)); + log.error("totalAmount 불일치 - 응답: {}, DB: {}", totalAmount, this.totalAmount); + throw new InternalServerErrorException(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); } } } diff --git a/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java index 33e95fb..bc13de3 100644 --- a/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java +++ b/domain/src/test/java/com/samhap/kokomen/payment/domain/TosspaymentsPaymentTest.java @@ -28,7 +28,8 @@ class TosspaymentsPaymentTest { ); assertThatThrownBy(() -> payment.validateTosspaymentsResult("wrong_key", "order_id", 10000L)) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.PAYMENT_KEY_MISMATCH.getMessage()); } @Test @@ -38,7 +39,8 @@ class TosspaymentsPaymentTest { ); assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "wrong_order", 10000L)) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.ORDER_ID_MISMATCH.getMessage()); } @Test @@ -48,7 +50,8 @@ class TosspaymentsPaymentTest { ); assertThatThrownBy(() -> payment.validateTosspaymentsResult("payment_key", "order_id", 99999L)) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentErrorMessage.TOTAL_AMOUNT_MISMATCH.getMessage()); } @Test diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java b/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java new file mode 100644 index 0000000..4846793 --- /dev/null +++ b/external/src/main/java/com/samhap/kokomen/global/exception/ExternalErrorMessage.java @@ -0,0 +1,19 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum ExternalErrorMessage { + + INVALID_REQUEST("잘못된 요청입니다."), + MISSING_REQUEST_PARAMETER("필수 요청 파라미터가 누락되었습니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다. JSON 형식을 확인해주세요."), + JSON_PARSE_ERROR("JSON 파싱 오류: 유효하지 않은 값이 전달되었습니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + ExternalErrorMessage(String message) { + this.message = message; + } +} diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 4f880ec..87e125f 100644 --- a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.exc.InvalidFormatException; import com.samhap.kokomen.global.dto.ErrorResponse; import lombok.extern.slf4j.Slf4j; +import org.springframework.context.support.DefaultMessageSourceResolvable; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.converter.HttpMessageNotReadableException; @@ -25,12 +26,12 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = ExternalErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() .findFirst() - .map(error -> error.getDefaultMessage()) + .map(DefaultMessageSourceResolvable::getDefaultMessage) .orElse(defaultErrorMessageForUser); if (message.equals(defaultErrorMessageForUser)) { @@ -44,35 +45,33 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho } @ExceptionHandler(MissingServletRequestParameterException.class) - public ResponseEntity handleMissingServletRequestParameterException(MissingServletRequestParameterException e) { - String message = "필수 요청 파라미터 '" + e.getParameterName() + "'가 누락되었습니다."; - log.warn("MissingServletRequestParameterException :: message: {}", message); + public ResponseEntity handleMissingServletRequestParameterException( + MissingServletRequestParameterException e) { + log.warn("MissingServletRequestParameterException :: parameterName: {}", e.getParameterName()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ExternalErrorMessage.MISSING_REQUEST_PARAMETER.getMessage())); } @ExceptionHandler(HttpMessageNotReadableException.class) public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { - String message = "잘못된 요청 형식입니다. JSON 형식을 확인해주세요."; if (e.getCause() instanceof InvalidFormatException invalidFormatException) { String fieldName = invalidFormatException.getPath().get(0).getFieldName(); String invalidValue = String.valueOf(invalidFormatException.getValue()); - message = String.format( - "JSON 파싱 오류: '%s' 필드에 유효하지 않은 값이 전달되었습니다. (전달된 값: '%s')", - fieldName, - invalidValue - ); + log.warn("HttpMessageNotReadableException :: fieldName: {}, invalidValue: {}", fieldName, invalidValue); + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(new ErrorResponse(ExternalErrorMessage.JSON_PARSE_ERROR.getMessage())); } - log.warn("HttpMessageNotReadableException :: message: {}", message); + log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse(message)); + .body(new ErrorResponse(ExternalErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { - log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); + log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, + e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(ExternalErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } } diff --git a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 204ede2..a2c246f 100644 --- a/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -23,7 +23,7 @@ public ResponseEntity handleKokomenException(KokomenException e) @ExceptionHandler(MethodArgumentNotValidException.class) public ResponseEntity handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { - String defaultErrorMessageForUser = "잘못된 요청입니다."; + String defaultErrorMessageForUser = PaymentServiceErrorMessage.INVALID_REQUEST.getMessage(); String message = e.getBindingResult() .getFieldErrors() .stream() @@ -45,13 +45,13 @@ public ResponseEntity handleMethodArgumentNotValidException(Metho public ResponseEntity handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { log.warn("HttpMessageNotReadableException :: message: {}", e.getMessage()); return ResponseEntity.status(HttpStatus.BAD_REQUEST) - .body(new ErrorResponse("잘못된 요청 형식입니다.")); + .body(new ErrorResponse(PaymentServiceErrorMessage.INVALID_REQUEST_FORMAT.getMessage())); } @ExceptionHandler(Exception.class) public ResponseEntity handleException(Exception e) { log.error("Exception :: status: {}, message: {}, stackTrace: ", HttpStatus.INTERNAL_SERVER_ERROR, e.getMessage(), e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(new ErrorResponse("서버에 문제가 발생하였습니다.")); + .body(new ErrorResponse(PaymentServiceErrorMessage.INTERNAL_SERVER_ERROR.getMessage())); } } diff --git a/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java b/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java new file mode 100644 index 0000000..16095e2 --- /dev/null +++ b/internal/src/main/java/com/samhap/kokomen/global/exception/PaymentServiceErrorMessage.java @@ -0,0 +1,23 @@ +package com.samhap.kokomen.global.exception; + +import lombok.Getter; + +@Getter +public enum PaymentServiceErrorMessage { + + PAYMENT_NOT_FOUND_BY_ID("해당 id의 결제 정보가 존재하지 않습니다."), + PAYMENT_NOT_FOUND_BY_PAYMENT_KEY("해당 paymentKey의 결제 정보가 존재하지 않습니다."), + PAYMENT_RESULT_NOT_FOUND("해당 결제의 결과 정보가 존재하지 않습니다."), + CONFIRM_SERVER_ERROR("결제 처리 중 서버 오류가 발생했습니다."), + CANCEL_SERVER_ERROR("결제 취소 처리 중 서버 오류가 발생했습니다."), + CANCEL_NETWORK_ERROR("결제 취소 처리 중 네트워크 오류가 발생했습니다."), + INVALID_REQUEST("잘못된 요청입니다."), + INVALID_REQUEST_FORMAT("잘못된 요청 형식입니다."), + INTERNAL_SERVER_ERROR("서버에 문제가 발생하였습니다."); + + private final String message; + + PaymentServiceErrorMessage(String message) { + this.message = message; + } +} diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index b39e2d6..2130de8 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -3,6 +3,7 @@ import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.global.exception.InternalServerErrorException; import com.samhap.kokomen.global.exception.KokomenException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; import com.samhap.kokomen.payment.domain.PaymentState; import com.samhap.kokomen.payment.domain.TosspaymentsPayment; import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; @@ -72,7 +73,7 @@ private RuntimeException handleConfirmClientError(HttpClientErrorException e, To if (TosspaymentsInternalServerErrorCode.contains(code)) { log.error("토스 결제 실패(서버 원인 400), code = {}, message = {}", code, failure.message()); tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); - return new InternalServerErrorException("결제 처리 중 서버 오류가 발생했습니다.", e); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); } log.info("토스 결제 실패(클라이언트 원인 400), code = {}, message = {}", code, failure.message()); @@ -117,10 +118,10 @@ public void cancelPayment(CancelRequest request) { throw new BadRequestException(failure.message(), e); } catch (HttpServerErrorException e) { log.error("결제 취소 실패(5xx) - paymentKey: {}, status: {}", request.paymentKey(), e.getStatusCode()); - throw new InternalServerErrorException("결제 취소 처리 중 서버 오류가 발생했습니다.", e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); } catch (ResourceAccessException e) { log.error("결제 취소 네트워크 오류 - paymentKey: {}", request.paymentKey(), e); - throw new InternalServerErrorException("결제 취소 처리 중 네트워크 오류가 발생했습니다.", e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage(), e); } } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java index f26191e..d9ad13f 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentResultService.java @@ -1,12 +1,15 @@ package com.samhap.kokomen.payment.service; import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; import com.samhap.kokomen.payment.domain.TosspaymentsPaymentResult; import com.samhap.kokomen.payment.repository.TosspaymentsPaymentResultRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service public class TosspaymentsPaymentResultService { @@ -21,6 +24,9 @@ public TosspaymentsPaymentResult save(TosspaymentsPaymentResult tosspaymentsPaym @Transactional(readOnly = true) public TosspaymentsPaymentResult readByTosspaymentsPaymentId(Long tosspaymentsPaymentId) { return tosspaymentsPaymentResultRepository.findByTosspaymentsPaymentId(tosspaymentsPaymentId) - .orElseThrow(() -> new NotFoundException("해당 결제의 결과 정보가 존재하지 않습니다. tosspaymentsPaymentId: " + tosspaymentsPaymentId)); + .orElseThrow(() -> { + log.error("결제 결과 조회 실패 - tosspaymentsPaymentId: {}", tosspaymentsPaymentId); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_RESULT_NOT_FOUND.getMessage()); + }); } } diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java index 17237c1..af17691 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/TosspaymentsPaymentService.java @@ -1,14 +1,17 @@ package com.samhap.kokomen.payment.service; import com.samhap.kokomen.global.exception.NotFoundException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; import com.samhap.kokomen.payment.domain.PaymentState; import com.samhap.kokomen.payment.domain.TosspaymentsPayment; import com.samhap.kokomen.payment.repository.TosspaymentsPaymentRepository; import com.samhap.kokomen.payment.service.dto.ConfirmRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +@Slf4j @RequiredArgsConstructor @Service public class TosspaymentsPaymentService { @@ -23,13 +26,19 @@ public TosspaymentsPayment saveTosspaymentsPayment(ConfirmRequest request) { @Transactional(readOnly = true) public TosspaymentsPayment readById(Long id) { return tosspaymentsPaymentRepository.findById(id) - .orElseThrow(() -> new NotFoundException("해당 id의 결제 정보가 존재하지 않습니다. id: " + id)); + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - id: {}", id); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_ID.getMessage()); + }); } @Transactional(readOnly = true) public TosspaymentsPayment readByPaymentKey(String paymentKey) { return tosspaymentsPaymentRepository.findByPaymentKey(paymentKey) - .orElseThrow(() -> new NotFoundException("해당 paymentKey의 결제 정보가 존재하지 않습니다. paymentKey: " + paymentKey)); + .orElseThrow(() -> { + log.error("결제 정보 조회 실패 - paymentKey: {}", paymentKey); + return new NotFoundException(PaymentServiceErrorMessage.PAYMENT_NOT_FOUND_BY_PAYMENT_KEY.getMessage()); + }); } @Transactional diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java index e097460..8331791 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -9,6 +9,7 @@ import com.samhap.kokomen.global.BaseTest; import com.samhap.kokomen.global.exception.BadRequestException; import com.samhap.kokomen.global.exception.InternalServerErrorException; +import com.samhap.kokomen.global.exception.PaymentServiceErrorMessage; import com.samhap.kokomen.global.fixture.TosspaymentsPaymentFixtureBuilder; import com.samhap.kokomen.global.fixture.TosspaymentsPaymentResultFixtureBuilder; import com.samhap.kokomen.payment.domain.PaymentState; @@ -73,7 +74,8 @@ class PaymentFacadeServiceTest extends BaseTest { when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); @@ -220,7 +222,8 @@ class PaymentFacadeServiceTest extends BaseTest { .thenThrow(new HttpServerErrorException(HttpStatus.INTERNAL_SERVER_ERROR)); assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); } @Test @@ -229,7 +232,8 @@ class PaymentFacadeServiceTest extends BaseTest { .thenThrow(new ResourceAccessException("네트워크 오류")); assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) - .isInstanceOf(InternalServerErrorException.class); + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage()); } private ConfirmRequest createConfirmRequest() { From b935861069e0fdf8a83ab09cd2d25e30b4526baa Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 15:00:29 +0900 Subject: [PATCH 15/18] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20TODO=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../samhap/kokomen/global/exception/GlobalExceptionHandler.java | 1 - .../samhap/kokomen/global/exception/GlobalExceptionHandler.java | 1 - 2 files changed, 2 deletions(-) diff --git a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 8fe31da..352305d 100644 --- a/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/api/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { diff --git a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java index 87e125f..4d4b92a 100644 --- a/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java +++ b/external/src/main/java/com/samhap/kokomen/global/exception/GlobalExceptionHandler.java @@ -12,7 +12,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; -// TODO: HttpMessageNotReadableException 예외 처리 추가 @Slf4j @RestControllerAdvice public class GlobalExceptionHandler { From e8143579a5b094fe100627f69f56371f37d4ebd0 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 15:02:31 +0900 Subject: [PATCH 16/18] =?UTF-8?q?docs:=20pr=20=ED=85=9C=ED=94=8C=EB=A6=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/PULL_REQUEST_TEMPLATE.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .github/PULL_REQUEST_TEMPLATE.md diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..ecc9e7c --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,5 @@ +closed # + +# 작업 내용 + +# 참고 사항 From e3dab16f48bca1ce0f9154de4ee6ca62f493b1da Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 15:22:27 +0900 Subject: [PATCH 17/18] =?UTF-8?q?refactor:=20=EC=97=90=EB=9F=AC=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentFacadeService.java | 11 +++++ .../service/PaymentFacadeServiceTest.java | 40 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index 2130de8..94f4051 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -68,6 +68,11 @@ private TosspaymentsPaymentResponse confirmPayment(ConfirmRequest request, Tossp private RuntimeException handleConfirmClientError(HttpClientErrorException e, TosspaymentsPayment tosspaymentsPayment) { Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("토스 결제 실패(400) - 응답 파싱 실패", e); + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.SERVER_BAD_REQUEST); + return new InternalServerErrorException(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage(), e); + } String code = failure.code(); if (TosspaymentsInternalServerErrorCode.contains(code)) { @@ -103,8 +108,10 @@ private void handleConfirmNetworkError(ResourceAccessException e, TosspaymentsPa if (socketTimeoutException.getMessage().contains("Read timed out")) { // TODO: retry tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); + return; } } + tosspaymentsPaymentService.updateState(tosspaymentsPayment.getId(), PaymentState.NEED_CANCEL); } public void cancelPayment(CancelRequest request) { @@ -114,6 +121,10 @@ public void cancelPayment(CancelRequest request) { tosspaymentsTransactionService.applyCancelResult(response); } catch (HttpClientErrorException e) { Failure failure = e.getResponseBodyAs(Failure.class); + if (failure == null) { + log.error("결제 취소 실패(400) - 응답 파싱 실패, paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); + } log.error("결제 취소 실패(400) - paymentKey: {}, code: {}, message: {}", request.paymentKey(), failure.code(), failure.message()); throw new BadRequestException(failure.message(), e); } catch (HttpServerErrorException e) { diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java index 8331791..501ecc3 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -27,6 +27,7 @@ import com.samhap.kokomen.payment.service.dto.CancelRequest; import com.samhap.kokomen.payment.service.dto.ConfirmRequest; import com.samhap.kokomen.payment.service.dto.PaymentResponse; +import java.net.ConnectException; import java.net.SocketTimeoutException; import java.time.LocalDateTime; import java.util.List; @@ -154,6 +155,34 @@ class PaymentFacadeServiceTest extends BaseTest { assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); } + @Test + void 결제_승인_시_SocketTimeoutException_외_네트워크_오류가_발생하면_NEED_CANCEL_상태로_변경한다() { + ConfirmRequest request = createConfirmRequest(); + when(tosspaymentsClient.confirmPayment(any())) + .thenThrow(new ResourceAccessException("I/O error", new ConnectException("Connection refused"))); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(ResourceAccessException.class); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.NEED_CANCEL); + } + + @Test + void 결제_승인_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + ConfirmRequest request = createConfirmRequest(); + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.confirmPayment(any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.confirmPayment(request)) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CONFIRM_SERVER_ERROR.getMessage()); + + TosspaymentsPayment payment = tosspaymentsPaymentRepository.findByPaymentKey("payment_key").orElseThrow(); + assertThat(payment.getState()).isEqualTo(PaymentState.SERVER_BAD_REQUEST); + } + @Test void 결제_승인_시_예상치_못한_예외가_발생하면_NEED_CANCEL_상태로_변경한다() { ConfirmRequest request = createConfirmRequest(); @@ -216,6 +245,17 @@ class PaymentFacadeServiceTest extends BaseTest { .isInstanceOf(BadRequestException.class); } + @Test + void 결제_취소_시_400_에러_응답_파싱에_실패하면_InternalServerErrorException을_던진다() { + HttpClientErrorException clientError = mock(HttpClientErrorException.class); + when(clientError.getResponseBodyAs(Failure.class)).thenReturn(null); + when(tosspaymentsClient.cancelPayment(any(), any())).thenThrow(clientError); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + @Test void 결제_취소_시_5xx_에러가_발생하면_InternalServerErrorException을_던진다() { when(tosspaymentsClient.cancelPayment(any(), any())) From 2f29348d5456cf8049a2ff649ed1d4d1afd0ba74 Mon Sep 17 00:00:00 2001 From: unifolio0 Date: Thu, 12 Feb 2026 15:30:33 +0900 Subject: [PATCH 18/18] =?UTF-8?q?refactor:=20=EC=97=90=EC=99=B8=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kokomen/payment/service/PaymentFacadeService.java | 3 +++ .../payment/service/PaymentFacadeServiceTest.java | 10 ++++++++++ 2 files changed, 13 insertions(+) diff --git a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java index 94f4051..12a1402 100644 --- a/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java +++ b/internal/src/main/java/com/samhap/kokomen/payment/service/PaymentFacadeService.java @@ -133,6 +133,9 @@ public void cancelPayment(CancelRequest request) { } catch (ResourceAccessException e) { log.error("결제 취소 네트워크 오류 - paymentKey: {}", request.paymentKey(), e); throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage(), e); + } catch (Exception e) { + log.error("결제 취소 중 예상치 못한 오류 - paymentKey: {}", request.paymentKey(), e); + throw new InternalServerErrorException(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage(), e); } } } diff --git a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java index 501ecc3..079a686 100644 --- a/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java +++ b/internal/src/test/java/com/samhap/kokomen/payment/service/PaymentFacadeServiceTest.java @@ -276,6 +276,16 @@ class PaymentFacadeServiceTest extends BaseTest { .hasMessage(PaymentServiceErrorMessage.CANCEL_NETWORK_ERROR.getMessage()); } + @Test + void 결제_취소_시_예상치_못한_예외가_발생하면_InternalServerErrorException을_던진다() { + when(tosspaymentsClient.cancelPayment(any(), any())) + .thenThrow(new RuntimeException("예상치 못한 오류")); + + assertThatThrownBy(() -> paymentFacadeService.cancelPayment(new CancelRequest("payment_key", "단순 변심"))) + .isInstanceOf(InternalServerErrorException.class) + .hasMessage(PaymentServiceErrorMessage.CANCEL_SERVER_ERROR.getMessage()); + } + private ConfirmRequest createConfirmRequest() { return new ConfirmRequest("payment_key", "order_id", 10000L, "주문명", 1L, "{}", ServiceType.INTERVIEW); }