Skip to content

Commit 3741502

Browse files
committed
feat: 토스 페이먼츠 설정 및 세팅 (MIKKI-216)
1 parent 22a21ae commit 3741502

8 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package com.backend.global.config;
2+
3+
import java.time.Duration;
4+
5+
import org.springframework.boot.web.client.RestTemplateBuilder;
6+
import org.springframework.context.annotation.Bean;
7+
import org.springframework.context.annotation.Configuration;
8+
import org.springframework.http.HttpHeaders;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.web.client.RestTemplate;
11+
12+
@Configuration
13+
public class WebConfig {
14+
15+
@Bean
16+
public RestTemplate restTemplate(RestTemplateBuilder builder) {
17+
return builder
18+
.defaultHeader(HttpHeaders.ACCEPT, MediaType.APPLICATION_JSON_VALUE)
19+
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
20+
.connectTimeout(Duration.ofSeconds(3)) //서버 연결 시도 최대 시간
21+
.readTimeout(Duration.ofSeconds(5)) //데이터 응답 대기 최대 시간
22+
.build();
23+
}
24+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.backend.global.payment;
2+
3+
import java.util.Map;
4+
5+
import org.springframework.beans.factory.annotation.Value;
6+
import org.springframework.http.HttpEntity;
7+
import org.springframework.http.HttpHeaders;
8+
import org.springframework.http.HttpMethod;
9+
import org.springframework.http.MediaType;
10+
import org.springframework.http.ResponseEntity;
11+
import org.springframework.stereotype.Component;
12+
import org.springframework.web.client.HttpStatusCodeException;
13+
import org.springframework.web.client.RestTemplate;
14+
15+
import com.backend.global.payment.dto.request.TossCancelRequest;
16+
import com.backend.global.payment.dto.request.TossPaymentRequest;
17+
import com.backend.global.payment.dto.response.TossCancelResponse;
18+
import com.backend.global.payment.dto.response.TossPaymentResponse;
19+
import com.backend.global.payment.exception.PaymentErrorCode;
20+
import com.backend.global.payment.exception.PaymentException;
21+
import com.fasterxml.jackson.core.type.TypeReference;
22+
import com.fasterxml.jackson.databind.ObjectMapper;
23+
24+
import lombok.RequiredArgsConstructor;
25+
import lombok.extern.slf4j.Slf4j;
26+
27+
@Slf4j
28+
@Component
29+
@RequiredArgsConstructor
30+
public class TossPaymentHttpClient {
31+
32+
private static final String TOSS_PAYMENT_URL = "https://api.tosspayments.com/v1/payments";
33+
34+
private final RestTemplate restTemplate;
35+
private final ObjectMapper objectMapper;
36+
37+
@Value("${api.toss.secret-key}")
38+
private String tossApiSecretKey;
39+
40+
public TossPaymentResponse sendPaymentConfirmRequest(final TossPaymentRequest request) {
41+
String confirmUrl = TOSS_PAYMENT_URL + "/confirm";
42+
43+
HttpHeaders headers = new HttpHeaders();
44+
headers.setContentType(MediaType.APPLICATION_JSON);
45+
headers.setBasicAuth(tossApiSecretKey, "");
46+
47+
HttpEntity<TossPaymentRequest> entity = new HttpEntity<>(request, headers);
48+
49+
try {
50+
ResponseEntity<TossPaymentResponse> responseEntity = restTemplate.exchange(
51+
confirmUrl,
52+
HttpMethod.POST,
53+
entity,
54+
TossPaymentResponse.class
55+
);
56+
return responseEntity.getBody();
57+
} catch (HttpStatusCodeException e) {
58+
handleApiError(e);
59+
throw new PaymentException(PaymentErrorCode.TOSS_API_ERROR);
60+
}
61+
}
62+
63+
public TossCancelResponse cancelPayment(final String paymentKey, final String cancelReason, final Long amount) {
64+
String cancelUrl = TOSS_PAYMENT_URL + "/" + paymentKey + "/cancel";
65+
66+
HttpHeaders headers = new HttpHeaders();
67+
headers.setContentType(MediaType.APPLICATION_JSON);
68+
headers.setBasicAuth(tossApiSecretKey, "");
69+
70+
TossCancelRequest cancelRequest = TossCancelRequest.of(cancelReason, amount);
71+
72+
HttpEntity<TossCancelRequest> entity = new HttpEntity<>(cancelRequest, headers);
73+
74+
try {
75+
ResponseEntity<TossCancelResponse> response = restTemplate.exchange(
76+
cancelUrl,
77+
HttpMethod.POST,
78+
entity,
79+
TossCancelResponse.class
80+
);
81+
log.info("Toss 결제 취소 완료: paymentKey={}, amount={}", paymentKey, amount);
82+
83+
return response.getBody();
84+
} catch (HttpStatusCodeException e) {
85+
log.error("Toss 결제 취소 실패: {}", e.getResponseBodyAsString());
86+
87+
throw new PaymentException(PaymentErrorCode.TOSS_API_CANCEL_FAILED);
88+
}
89+
}
90+
91+
private void handleApiError(HttpStatusCodeException e) {
92+
try {
93+
Map<String, Object> errorResponse = objectMapper
94+
.readValue(e.getResponseBodyAsString(), new TypeReference<>() {
95+
});
96+
97+
log.error("Toss API Error: code={}, message={}", errorResponse.get("code"), errorResponse.get("message"));
98+
} catch (Exception ex) {
99+
log.error("Failed to parse Toss API error", ex);
100+
}
101+
}
102+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.backend.global.payment.dto.request;
2+
3+
import com.fasterxml.jackson.annotation.JsonInclude;
4+
5+
import lombok.Builder;
6+
7+
@Builder
8+
@JsonInclude(JsonInclude.Include.NON_NULL)
9+
public record TossCancelRequest(
10+
String cancelReason,
11+
Long cancelAmount
12+
) {
13+
public static TossCancelRequest of(final String cancelReason, final Long cancelAmount) {
14+
return TossCancelRequest.builder()
15+
.cancelReason(cancelReason)
16+
.cancelAmount(cancelAmount)
17+
.build();
18+
}
19+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package com.backend.global.payment.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.Min;
5+
import jakarta.validation.constraints.NotBlank;
6+
import jakarta.validation.constraints.NotNull;
7+
import jakarta.validation.constraints.Size;
8+
import lombok.Builder;
9+
10+
/**
11+
* 토스 결제 요청
12+
*
13+
* @param paymentKey
14+
* @param amount
15+
* @param orderId
16+
*/
17+
@Builder
18+
public record TossPaymentRequest(
19+
@NotBlank
20+
@Schema(description = "토스에서 발급하는 결제 고유 식별자")
21+
String paymentKey,
22+
23+
@NotNull
24+
@Min(0)
25+
@Schema(description = "결제 금액")
26+
Long amount,
27+
28+
@NotBlank
29+
@Size(min = 6, max = 64)
30+
@Schema(description = "주문번호, 결제 요청에서 직접 생성한 영문 대소문자, 숫자, '-', '_'로 이루어진 6자 이상 64이하 문자열")
31+
String orderId
32+
) {
33+
public static TossPaymentRequest from(
34+
final String paymentKey,
35+
final Long amount,
36+
final String orderId) {
37+
38+
return TossPaymentRequest.builder()
39+
.paymentKey(paymentKey)
40+
.amount(amount)
41+
.orderId(orderId)
42+
.build();
43+
}
44+
}
45+
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
package com.backend.global.payment.dto.response;
2+
3+
import java.time.OffsetDateTime;
4+
5+
public record TossCancelResponse(
6+
String status,
7+
String cancelReason,
8+
Long canceledAmount,
9+
OffsetDateTime canceledAt
10+
) {
11+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package com.backend.global.payment.dto.response;
2+
3+
import java.time.OffsetDateTime;
4+
5+
import lombok.Getter;
6+
7+
@Getter
8+
public class TossPaymentResponse {
9+
private String paymentKey;
10+
private String orderId;
11+
private String method;
12+
private OffsetDateTime approvedAt;
13+
private Long totalAmount;
14+
private String status;
15+
private Card card;
16+
private Receipt receipt;
17+
18+
@Getter
19+
public static class Card {
20+
private String number;
21+
private String approveNo;
22+
}
23+
24+
@Getter
25+
public static class Receipt {
26+
private String url;
27+
}
28+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.backend.global.payment.exception;
2+
3+
import org.springframework.http.HttpStatus;
4+
5+
import com.backend.global.exception.ErrorCode;
6+
7+
import lombok.Getter;
8+
import lombok.RequiredArgsConstructor;
9+
10+
@Getter
11+
@RequiredArgsConstructor
12+
public enum PaymentErrorCode implements ErrorCode {
13+
14+
TOSS_API_ERROR(HttpStatus.BAD_REQUEST, 16001, "결제 오류가 발생했습니다."),
15+
TOSS_API_CANCEL_FAILED(HttpStatus.BAD_REQUEST, 16002, "결제 취소 오류가 발생했습니다.");
16+
17+
private final HttpStatus httpStatus;
18+
private final Integer code;
19+
private final String message;
20+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package com.backend.global.payment.exception;
2+
3+
import com.backend.global.exception.ErrorCode;
4+
import com.backend.global.exception.GlobalException;
5+
6+
import lombok.Getter;
7+
8+
@Getter
9+
public class PaymentException extends GlobalException {
10+
11+
private final ErrorCode errorCode;
12+
13+
public PaymentException(final ErrorCode errorCode) {
14+
super(errorCode);
15+
this.errorCode = errorCode;
16+
}
17+
18+
public PaymentException(final Throwable cause, final ErrorCode errorCode) {
19+
super(cause, errorCode);
20+
this.errorCode = errorCode;
21+
}
22+
}

0 commit comments

Comments
 (0)