[데브코스] Spring + Toss payments로 결제 기능 개발하기

2025. 10. 13. 19:44·Spring

✅ 개요

팀 프로젝트에서 결제 기능을 맡게 되었습니다. 스프링 개발 환경에서 어떤 식으로 결제 기능을 구현했는지 공유하고자 합니다.

 

✅ Toss Payments를 선택한 이유

먼저 어떤 API로 결제 기능을 구현할지 카카오페이, 포트원, 토스 등 여러 서비스의 가이드를 한 번씩 훑어보았습니다. 이 중 토스를 선택한 이유는 가장 먼저 가독성 때문이었습니다. 일단 다른 서비스에 비해 UI도 마음에 들었고, API 사용 가이드가 이해하기 쉽게 구성되어 있던 것 같습니다. 또 다른 이유는 샘플 코드와 테스트 환경을 제공해 주기 때문이었습니다. 프론트와 백엔드 각각 샘플 코드를 제공해 주어 가이드대로 따라 하기 매우 수월했습니다.

 

✅ Toss Payments 결제창 이해

Toss Payments 결제 서비스 연동에는 결제 위젯과 결제창이 있습니다. 이 중 결제 위젯은 사업자 등록번호를 등록해야 사용할 수 있기 때문에 큰 고민 없이 결제창을 연동하기로 했습니다.

 

Toss Payments 결제창 연동 가이드에서는 다음과 같은 결제 플로우를 제공합니다.

  1. API 키 준비
  2. 결제창 띄우기
  3. 리다이렉트 URL 이동
  4. 결제 승인
  5. 응답 확인

각 자세한 내용은 가이드에 정말 잘 설명되어 있어 다음 링크에서 몇 번만 보면 이해할 수 있습니다.

 

카드/간편결제 통합결제창 연동하기 | 토스페이먼츠 개발자센터

토스페이먼츠 카드/간편결제 통합결제창을 연동하는 방법이에요. 구매자가 결제창에서 결제수단, 결제 정보를 선택한 뒤에 카드 또는 간편결제 앱으로 이동해요.

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) {
}

그리고 서버에서 결제할 데이터를 임시로 저장해야 하는데, 이때 저장하는 방법에는 크게 세 가지 선택지가 있었습니다.

  1. 데이터베이스
  2. 세션
  3. 레디스

데이터베이스 저장은 임시 저장 용도와는 맞지 않다고 판단했습니다. 왜냐하면 저장되었다가 길지 않은 시간 후에 바로 삭제하게 될 텐데, 불필요한 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
'Spring' 카테고리의 다른 글
  • Spring AI 개발 일지 (1) - Spring AI 소개와 핵심 모델
  • PageableExecutionUtils.getPage로 페이징 성능 개선하기
  • Spring 비동기로 이메일 전송하기
  • Spring AbstractRequestLoggingFilter로 RequestBody 로깅 찍기
이런개발
이런개발
geun-00의 흔적 보관소
  • 이런개발
    내일이 기대되는 오늘
    이런개발
  • 전체
    오늘
    어제
    • 분류 전체보기 N
      • 백엔드 면접
      • SQL N
        • SUM, MAX, MIN
        • SELECT
        • GROUP BY
        • JOIN
      • Spring
      • JPA
      • 트러블슈팅
      • Infra
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    자바
    오블완
    raid
    티스토리챌린지
    스프링
    백엔드 면접
    JPA
    토스 페이먼츠
    데브코스
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
이런개발
[데브코스] Spring + Toss payments로 결제 기능 개발하기
상단으로

티스토리툴바