토스(Toss) 결제 연동하기

2025. 12. 21. 13:53·노예 일지/스타트업 노예일지
728x90

오늘도 노예는 일을 합니다.

네비게이션 개발의 규모가 크다보니 시간이 오래걸릴 것 같아서 그전에 간단한 사업자 등록을 위해서 작은(?) 사실 작은지 모르겠는 프로젝트를 진행하게 되었다. 여기에는 결제도 연동되어야 할 것 같아서 API를 추가하고, 테스트까지 진행해보았다.

그래서 까먹기 전에 정리 해보려고 한다.(내 뇌는 휘발성 메모리라 적어놔야한다...ㅇㅇ)

 

일단 먼저, 토스에서 주는 시퀀스 다이어그램을 보자.

흐름을 보면 먼저, 사용자가 주문을 넣는다. 그러면 결제위젯을 렌더링 요청하고, 토스에서 위젯을 제공해준다.

대충 요로코롬 생긴 위젯을 띄운다.

그 다음 많이들 사용해본 것처럼 결제수단을 선택하고 결제하기 버튼을 누른다.

그러면 결제창을 띄운다고하는데,

실제로 눌러보면 요로코롬 나온다.

결제정보를 입력하면, 앱으로 알람이오고,

토스 앱에 들어가보면 결제할 수 있는 창이 뜨고, 결제를 하면 끝이난다.

결제가 실패하면, 따로 지정해둔 fail페이지로 이동시킨다.

성공하면?

alert설정을 해뒀다.

Payment

먼저 entity인 payment의 필드부터 보자.

@Entity
@Getter
@Builder
@AllArgsConstructor
@NoArgsConstructor
public class Payment {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @OneToOne
    private Product product;

    @ManyToOne
    private OauthMember payer;

    private Long amount;

    @Enumerated(EnumType.STRING)
    @Builder.Default
    private PaymentStatus status = PaymentStatus.PENDING;

    @Enumerated(EnumType.STRING)
    private PaymentMethod method;

    private String paymentKey;
    private String orderId;

    @Builder.Default
    private LocalDateTime createdAt = LocalDateTime.now();
    private LocalDateTime paidAt;
    private LocalDateTime expiredAt;

공식 문서를 확인해보면 아래처럼 나와있다.

 

서버로 결제정보 전달
서버로 paymentKey, amount, orderId 값을 전달하세요. 결제 승인에 필요한 데이터입니다. 결제 승인 결과에 따라 클라이언트에서 필요한 결제 성공 및 실패 로직을 추가하세요.

필요한 paymentKey와 amount, orderId를  필드에 포함했다.

검증과 결제/결제 취소를 위해 아래와 같은 메서드들을 payment에 추가했다.

/**
     * 결제 권한 검증
     */
    public void validatePaymentAuthority(OauthMember member) {
        if (!this.payer.equals(member)) {
            throw new IllegalStateException("결제 권한이 없습니다.");
        }
    }

    /**
     * 결제 취소 권한 검증
     */
    public void validateCancelAuthority(OauthMember member) {
        if (!this.payer.equals(member)) {
            throw new IllegalStateException("취소 권한이 없습니다.");
        }
    }

    /**
     * 결제 가능 상태 검증
     */
    public void validatePayable() {
        if (status != PaymentStatus.PENDING) {
            throw new IllegalStateException("결제 가능한 상태가 아닙니다.");
        }

        if (LocalDateTime.now().isAfter(expiredAt)) {
            throw new IllegalStateException("결제 기한이 만료되었습니다.");
        }
    }

    /**
     * 금액 검증 (변조 방지)
     */
    public void validateAmount(Long requestAmount) {
        if (!this.amount.equals(requestAmount)) {
            throw new IllegalArgumentException("결제 금액이 일치하지 않습니다.");
        }
    }

    /**
     * 결제 완료 처리
     */
    public void complete(String paymentKey, PaymentMethod method) {
        this.status = PaymentStatus.COMPLETED;
        this.paymentKey = paymentKey;
        this.method = method;
        this.paidAt = LocalDateTime.now();
    }

    /**
     * 결제 취소
     */
    public void cancel() {
        if (status != PaymentStatus.PENDING && status != PaymentStatus.COMPLETED) {
            throw new IllegalStateException("취소할 수 없는 상태입니다.");
        }
        this.status = PaymentStatus.CANCELLED;
    }

    /**
     * 결제 만료
     */
    public void expire() {
        if (status != PaymentStatus.PENDING) {
            throw new IllegalStateException("만료 처리할 수 없는 상태입니다.");
        }
        this.status = PaymentStatus.EXPIRED;
    }

    /**
     * 결제 가능 여부 (조회용)
     */
    public boolean isPayable() {
        return status == PaymentStatus.PENDING &&
                LocalDateTime.now().isBefore(expiredAt);
    }

PaymentService

이렇게 Payment를 정의했으니 그 다음은 이걸 사용해서 결제 로직을 처리할 service 클래스를 정의해보자.

결제 등록

public PaymentResponse createDirectPurchasePayment(Long memberId, Long productId) {
    OauthMember buyer = oauthMemberRepository.getByOauthMemberId(memberId);
    Product product = productRepository.getProductById(productId);

    // 고정가 구매 검증
    product.validateDirectPurchase();

    // 결제 대기 상태로 변경
    product.waitPayment(buyer);

    // Payment 생성 (48시간 내 결제)
    Payment payment = Payment.builder()
            .product(product)
            .payer(buyer)
            .amount(product.getPrice())
            .orderId(OrderIdGenerator.generateOrderId())
            .expiredAt(LocalDateTime.now().plusHours(48))
            .build();

    paymentRepository.save(payment);

    return PaymentResponse.from(payment);
}

우리 서비스는 결제 물품의 종류가 두 가지이다.

하나는 경매, 하나는 단순 구매이다.

그래서 고정가로 된 단순 구매의 경우는 위의 로직처럼 payment를 생성한다.

경매 방식은 따로 스케줄러를 둬서 경매 기간이 끝나면 결제확정을 짓는 로직으로 해두었다.

결제 확인

@Transactional(noRollbackFor = PaymentConfirmationException.class)
    public void confirmPayment(Long memberId, PaymentRequest request) throws PaymentConfirmationException {
        OauthMember member = oauthMemberRepository.getByOauthMemberId(memberId);
        Payment payment = paymentRepository.getByOrderId(request.orderId());

        // 도메인 객체에 위임
        payment.validatePaymentAuthority(member);
        payment.validatePayable();
        payment.validateAmount(request.amount());

        try {
            // 1. 토스페이먼츠 결제 승인 API 호출
            TossPaymentConfirmResponse response = tossPaymentClient.confirm(request.paymentKey(), request.orderId(),
                    request.amount());

            if (!"DONE".equalsIgnoreCase(response.status())) {
                // 결제 상태가 DONE이 아니면 (ex: PARTIAL_CANCEL) 예외 처리
                throw new IllegalStateException("토스 승인 실패: 상태가 " + response.status() + "입니다.");
            }

            // 2. 결제 완료 처리
            payment.complete(response.paymentKey(), response.method());

            // 3. 상품 상태 변경
            payment.getProduct().purchase(member);
        } catch (Exception e) {
            payment.getProduct().releasePaymentWait();
            payment.cancel();
            throw new PaymentConfirmationException("결제 승인 실패로 주문이 취소되었습니다.", e);
        }
    }

결제 검증 과정을 통해서 구매자가 맞는지, 상품이 구매 가능한 상태인지, 구매 금액이 일치하는지 확인한 후 결제를 진행한다.

실패하는 경우에는 다시 ACTIVE 상태로 돌리고 주문 취소로 만든다.

이제는 API로 생성하기만 하면 된다.

PaymentController

@RestController
@RequiredArgsConstructor
@RequestMapping("/payments")
public class PaymentController {

    private final PaymentService paymentService;

    // 고정가 구매 - Payment 생성
    @PostMapping("/products/{productId}/purchase")
    public ResponseEntity<PaymentResponse> createPurchasePayment(
            @Auth Long memberId,
            @PathVariable Long productId) {
        PaymentResponse response = paymentService.createDirectPurchasePayment(memberId, productId);
        return ResponseEntity.ok(response);
    }

    // 결제 정보 조회
    @GetMapping("/products/{productId}")
    public ResponseEntity<PaymentResponse> getPayment(
            @Auth Long memberId,
            @PathVariable Long productId) {
        return ResponseEntity.ok(paymentService.getPaymentInfo(memberId, productId));
    }

    // 결제 승인 (토스페이먼츠 콜백)
    @PostMapping("/confirm")
    public ResponseEntity<Void> confirmPayment(
            @Auth Long memberId,
            @RequestBody PaymentRequest request) throws PaymentConfirmationException {
        paymentService.confirmPayment(memberId, request);
        return ResponseEntity.ok().build();
    }

    // 결제 취소
    @PostMapping("/{paymentId}/cancel")
    public ResponseEntity<Void> cancelPayment(
            @Auth Long memberId,
            @PathVariable Long paymentId) {
        paymentService.cancelPayment(memberId, paymentId);
        return ResponseEntity.ok().build();
    }
}

 

이렇게 하면 끝이다.

노예의 토스 결제 연동 끝

 

참고자료

https://docs.tosspayments.com/guides/v2/payment-widget/integration

 

728x90

'노예 일지 > 스타트업 노예일지' 카테고리의 다른 글

노예는 시키면 뭐든해요. 프런트 만드는 백엔드  (0) 2025.11.28
내비게이션을 만들어보아요  (0) 2025.10.01
'노예 일지/스타트업 노예일지' 카테고리의 다른 글
  • 노예는 시키면 뭐든해요. 프런트 만드는 백엔드
  • 내비게이션을 만들어보아요
Bello's
Bello's
개발하는 벨로
  • Bello's
    벨로의 개발일지
    Bello's
  • 전체
    오늘
    어제
    • 분류 전체보기 (199) N
      • 노예 일지 (7)
        • 스타트업 노예일지 (3)
      • CS 이론 (81)
        • 학과 수업 (4)
        • 알고리즘 (64)
        • 시스템 프로그래밍 (3)
        • 데이터 통신 (1)
        • 운영체제 (2)
        • 데이터베이스 (1)
      • project (3)
      • 나는 감자다. (4)
      • Spring (27)
      • 모각코 (45)
        • 절개와지조(모각코) (7)
        • 어쩌다보니 박준태가 조장이조 (11)
        • 어쩌다보니 박준태가 또 조장이조 (12)
      • LikeLion🦁 (20)
      • 캘리포니아 감자 (4)
      • OpenSource Contribute (1)
      • 우아한테크벨로 (1) N
        • 프리코스 회고록 (6)
  • 블로그 메뉴

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

  • 공지사항

  • 인기 글

  • 태그

    타임리프
    백준
    자바
    어렵다
    Spring
    그래프 순회
    모각코
    프리코스
    8기
    절개와지조
    나는 감자
    오블완
    누적합
    회고록
    뛰슈
    BFS
    감자
    JPA
    티스토리챌린지
    DFS
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.5
Bello's
토스(Toss) 결제 연동하기
상단으로

티스토리툴바