✅ 개요
팀 프로젝트에서 결제 기능을 맡게 되었습니다. 스프링 개발 환경에서 어떤 식으로 결제 기능을 구현했는지 공유하고자 합니다.
✅ Toss Payments를 선택한 이유
먼저 어떤 API로 결제 기능을 구현할지 카카오페이, 포트원, 토스 등 여러 서비스의 가이드를 한 번씩 훑어보았습니다. 이 중 토스를 선택한 이유는 가장 먼저 가독성 때문이었습니다. 일단 다른 서비스에 비해 UI도 마음에 들었고, API 사용 가이드가 이해하기 쉽게 구성되어 있던 것 같습니다. 또 다른 이유는 샘플 코드와 테스트 환경을 제공해 주기 때문이었습니다. 프론트와 백엔드 각각 샘플 코드를 제공해 주어 가이드대로 따라 하기 매우 수월했습니다.
✅ Toss Payments 결제창 이해
Toss Payments 결제 서비스 연동에는 결제 위젯과 결제창이 있습니다. 이 중 결제 위젯은 사업자 등록번호를 등록해야 사용할 수 있기 때문에 큰 고민 없이 결제창을 연동하기로 했습니다.
Toss Payments 결제창 연동 가이드에서는 다음과 같은 결제 플로우를 제공합니다.

- API 키 준비
- 결제창 띄우기
- 리다이렉트 URL 이동
- 결제 승인
- 응답 확인
각 자세한 내용은 가이드에 정말 잘 설명되어 있어 다음 링크에서 몇 번만 보면 이해할 수 있습니다.
카드/간편결제 통합결제창 연동하기 | 토스페이먼츠 개발자센터
토스페이먼츠 카드/간편결제 통합결제창을 연동하는 방법이에요. 구매자가 결제창에서 결제수단, 결제 정보를 선택한 뒤에 카드 또는 간편결제 앱으로 이동해요.
docs.tosspayments.com
✅ Spring 구현
1️⃣ HttpClient
먼저 API를 호출할 HttpClient를 정의하였습니다. 결제 승인 API 가이드를 참고하여 다음 코드를 작성하였습니다.
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import java.nio.charset.StandardCharsets;
import java.util.Base64;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.HttpHeaders.CONTENT_TYPE;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
@Configuration
public class ClientConfig{
@Bean
public PaymentClient paymentClient(RestClient.Builder builder,
@Value("${payment.secret-key}") String secretKey) {
String authHeaderValue = "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8));
RestClient restClient = builder.baseUrl("https://api.tosspayments.com/v1/payments")
.defaultHeader(AUTHORIZATION, authHeaderValue)
.defaultHeader(CONTENT_TYPE, APPLICATION_JSON_VALUE)
.build();
return HttpServiceProxyFactory.builderFor(RestClientAdapter.create(restClient))
.build()
.createClient(PaymentClient.class);
}
}
import com.fasterxml.jackson.databind.JsonNode;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.service.annotation.HttpExchange;
import org.springframework.web.service.annotation.PostExchange;
@HttpExchange
public interface PaymentClient {
@PostExchange("/confirm")
JsonNode confirmPayment(@RequestBody PaymentConfirmDTO paymentConfirmDTO);
}
public record PaymentConfirmDTO(
String paymentKey,
String orderId,
Integer amount) {
}
2️⃣결제 엔티티 설계
가이드에서 서버에 paymentKey, amount, orderId값을 꼭 저장하라고 권장하고 있기 때문에 이 세 개는 기본으로 두었고, 그 외 필요해 보이는 값들로 Payment 엔티티를 설계하였습니다. PK는 auto-increment 대신 고유한 paymentKey로 대체하였습니다.
@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Payment extends BaseEntity {
@Id
private String paymentKey;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "member_id", nullable = false)
private Member member;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "project_service_id")
private ProjectService projectService;
@Column(nullable = false)
private String orderId;
@Column(nullable = false)
private int totalAmount;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private PaymentStatus paymentStatus;
@Column(nullable = false)
private LocalDateTime requestedAt;
@Enumerated(EnumType.STRING)
private PaymentMethod paymentMethod;
private LocalDateTime approvedAt;
private String memo;
public void updateMemo(String memo) {
this.memo = memo;
}
}
3️⃣결제 요청 전 결제 데이터 저장
가이드에서 데이터 무결성을 위해 결제 요청 전 결제할 데이터를 저장하는 것을 권장합니다. 따라서 다음과 같이 프론트에서 결제 요청을 하기 전 서버에 임시 저장 API를 요청하도록 했습니다.
const handlePayment = async () => {
setIsProcessing(true);
setError("");
try {
const orderId = uuidv4();
const paymentAmount = parseInt(amount);
// 1단계: 결제를 요청하기 전에 orderId, amount를 서버에 저장
// 결제 과정에서 악의적으로 결제 금액이 바뀌는 것을 확인하는 용도
const saveResponse = await fetch(`${baseUrl}/api/v1/payments/save`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
orderId: orderId,
amount: paymentAmount,
}),
});
if (!saveResponse.ok) {
const err = await saveResponse.json();
throw new Error(err.message || "임시 결제 데이터 저장 실패");
}
// 2단계: 결제 요청
await payment.requestPayment({
method: "CARD", // 카드 결제
amount: {
currency: "KRW",
value: paymentAmount,
},
orderId: orderId,
orderName: serviceName,
successUrl: `${window.location.origin}/payment/success?chatId=${chatId}`,
failUrl: `${window.location.origin}/payment/fail`,
customerEmail: "customer@example.com",
customerName: "고객명",
customerMobilePhone: "01012345678",
card: {
useEscrow: false,
flowMode: "DEFAULT",
useCardPoint: false,
useAppCardOnly: false,
},
});
} catch (err: any) {
console.error("결제 요청 실패:", err);
setError(err.message || "결제 요청 중 오류가 발생했습니다.");
setIsProcessing(false);
}
};
그리고 서버 API는 다음과 같습니다.
@PostMapping("/save")
public ResponseEntity<?> savePayment(@Valid @RequestBody SavePaymentRequestDTO savePaymentRequestDTO) {
paymentService.savePayment(savePaymentRequestDTO);
return ResponseEntity.ok().build();
}
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
public record SavePaymentRequestDTO(
@NotBlank
@Pattern(regexp = "^[A-Za-z0-9_-]{6,64}$", message = "영문 대소문자, 숫자, 특수문자(-, _)만 허용하며 6~64자여야 합니다.")
String orderId,
@NotNull @PositiveOrZero
Integer amount) {
}
그리고 서버에서 결제할 데이터를 임시로 저장해야 하는데, 이때 저장하는 방법에는 크게 세 가지 선택지가 있었습니다.
- 데이터베이스
- 세션
- 레디스
데이터베이스 저장은 임시 저장 용도와는 맞지 않다고 판단했습니다. 왜냐하면 저장되었다가 길지 않은 시간 후에 바로 삭제하게 될 텐데, 불필요한 I/O 요청 2번(저장 후 삭제)이 생기게 된다고 생각했습니다.
세션은 임시 저장용으로 적절합니다. 자바에서 기본 제공하는 `HttpSession`을 사용하면 되므로 별도 설정 없이 편리하게 사용할 수 있다는 것도 장점입니다.
레디스 역시 임시 저장용으로 적절합니다. 의존성을 추가해 별도 설정이 필요하긴 하지만, 확장성과 TTL 관리에 용이합니다.
저는 레디스를 선택했습니다. 사용하기에는 세션이 간편하겠지만, TTL을 관리할 수 없어 명시적으로 제거를 해주어야 합니다. 만약 제거하는 로직이 누락되면 메모리 점유율이 높아지는 문제가 발생할 수 있습니다. (사이드 프로젝트지만 다양한 상황을 고려해 봤습니다.) 반면 레디스는 TTL을 적용할 수 있어 저장 이후 자동으로 메모리에서 제거가 되기 때문에 임시 데이터 저장 용도에 더 적합할 거라 판단했고, 나중에 다중 서버로 확장했을 때 확장성에도 유리합니다.
따라서 다음과 같이 결제 요청 전 임시 저장 로직을 만들었습니다. TTL이 10분인 이유는 가이드에서 10분 안에 결제 승인 API를 호출하지 않으면 만료된다고 안내하고 있어 이것에 맞췄습니다.
public void savePayment(SavePaymentRequestDTO savePaymentRequestDTO) {
String orderId = savePaymentRequestDTO.orderId();
Integer amount = savePaymentRequestDTO.amount();
String key = generateKey(orderId);
String value = amount.toString();
redisRepository.setValue(key, value, Duration.ofMinutes(10));
}
private String generateKey(String orderId) {
return "payment:" + orderId;
}
4️⃣ 결제 승인
결제 승인 단계에서는 실제 결제 승인 API를 호출하기 전에 위에서 임시 저장한 결제 데이터와 일치하는지 검증한 후 API를 호출하는 것이 핵심입니다.
@PostMapping("/confirm")
public ResponseEntity<PaymentResponseDTO> confirmPayment(@AuthenticationPrincipal CustomUserDetails customUserDetails,
@Valid @RequestBody PaymentConfirmRequestDTO paymentConfirmRequestDTO) {
PaymentResponseDTO response = paymentService.confirmPayment(paymentConfirmRequestDTO, customUserDetails.getEmail());
return ResponseEntity.ok(response);
}
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.PositiveOrZero;
public record PaymentConfirmRequestDTO(
@NotBlank
String paymentKey,
@NotBlank
@Pattern(regexp = "^[A-Za-z0-9_-]{6,64}$", message = "영문 대소문자, 숫자, 특수문자(-, _)만 허용하며 6~64자여야 합니다.")
String orderId,
@NotNull @PositiveOrZero
Integer amount,
String memo, Long serviceId) {
public PaymentConfirmDTO convert() {
return new PaymentConfirmDTO(this.paymentKey, this.orderId, this.amount);
}
}
서비스 로직의 대략적인 흐름은 verifyTempPayment 메서드에서 데이터 일치하는지 검증한 후, 결제 승인 API 요청을 보냅니다. 응답으로 받은 Payment 객체에서 필요한 값을 추출한 후 엔티티로 만들어 저장합니다. 마지막으로 필요없어진 임시 저장값은 레디스에서 제거합니다.
@Transactional
public PaymentResponseDTO confirmPayment(PaymentConfirmRequestDTO paymentConfirmRequestDTO, String email) {
String orderId = paymentConfirmRequestDTO.orderId();
Integer amount = paymentConfirmRequestDTO.amount();
String memo = paymentConfirmRequestDTO.memo();
Long serviceId = paymentConfirmRequestDTO.serviceId();
verifyTempPayment(orderId, amount);
PaymentConfirmDTO paymentConfirmDTO = paymentConfirmRequestDTO.convert();
JsonNode response = paymentClient.confirmPayment(paymentConfirmDTO);
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new EntityNotFoundException("존재하지 않는 유저입니다. email : " + email));
ProjectService projectService = serviceRepository.findById(serviceId)
.orElseThrow(() -> new EntityNotFoundException("존재하지 않는 서비스입니다. serviceId : " + serviceId));
paymentRepository.save(convertToEntity(response, member, projectService, memo));
redisRepository.deleteValue(generateKey(orderId));
String receiptUrl = response.get("receipt").get("url").asText(null);
return new PaymentResponseDTO(receiptUrl);
}
private void verifyTempPayment(String orderId, Integer amount) {
String key = generateKey(orderId);
String storedValue = redisRepository.getValue(key);
if (!Objects.equals(storedValue, String.valueOf(amount))) {
log.debug("orderId: {}", orderId);
throw new PaymentException("결제 금액 불일치 또는 임시 데이터가 존재하지 않습니다.");
}
}
📌 회고
Toss Payments API를 사용하여 편리하게 결제 기능을 구현할 수 있었습니다. 같은 고민을 하고 계시는 다른 분들에게 조금이나마 도움이 되었으면 좋겠고, 더 좋은 의견 주시면 감사하겠습니다.
'Spring' 카테고리의 다른 글
| Spring AI 개발 일지 (2) - OpenAI 사용해 후기 요약 구현해보기 (0) | 2025.11.19 |
|---|---|
| Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델 (0) | 2025.11.19 |
| PageableExecutionUtils.getPage로 페이징 성능 개선하기 (0) | 2025.10.24 |
| Spring 비동기로 이메일 전송하기 (0) | 2025.10.21 |
| Spring AbstractRequestLoggingFilter로 RequestBody 로깅 찍기 (0) | 2025.10.14 |