개발/개발팁

해외 결제를 위한 Stripe 사용해보기 with. SpringBoot + Kotlin

애쿠 2025. 5. 26. 09:52

개요

현재 담당하고 있는 제품이 해외 결제 시스템을 추가하게 됐다. 이에 따라 고려해야할게 몇 가지 있었다.

 

1. 어느 지역 까지 커버할 것인가? 

2. 구독이 정상적으로 동작할 것인가?

3. 데이터 유실 없이 안정성은 보장할 수 있을 것인가?

4. 개발하기 간편한가?

5. 환불이나 CS 운영적인 부분도 커버가 가능한가? 

 

이리저리 알아볼 것도 없이 Stripe가 가장 강력한 후보가 됐다. Twilio 때도 그랬지만 북미에서 제공하는 SaaS 형식의 제품들은 사용하기는 편하지만 생각보다 비싸다. 그래도 자체 인프라 없이, 월간 결제 형식이 아니라 on-demand + 수수료로 책정되는 모델이라 초기 모델로는 더할 나위 없을 것 같았다.

 

이번 글에서는 완전히 도입까진 아니고 developer 사이트에서 결제 세션을 만들고 결제 확인을 위한 webhook까지 사용해보려고 한다. 최종 선택이 된다면 추가 글을 써볼 예정이다. https://dashboard.stripe.com/test

 

1. 어느 지역 까지 커버할 것인가? 

 

https://stripe.com/global

서비스는 아직 시작단계지만, 일본부터 시작해서 동남아 미국까지 결제를 도입할 예정이다. Stripe의 딱하나 아쉬운 점이 있다면, 동남아 지역 다양한 지역까지는 커버해주진 못한다는 점이다. 아마 환율 문제가 가장 크지 않을까 싶긴하지만 일부지역이 허용된다. 우선은 일본과 미국이 동시 허용된다는걸 큰 메리트로 생각해서 기술 검토를 진행해봤다.

 

2. 구독이 정상적으로 동작할 것인가?

구독도 깔끔하게 지원한다. 무엇보다 대부분의 기능들을 콘솔에서 제공해서 편하다.

샌드박스 사이트에서 Product catalog > Create Product > Recurring > Billing period(월간 결제면 Montly)를 선택하면 된다.

 

여기까지 만든 후 좌측 탭에 Payment Links로 가서 이 플랜을 등록 후 링크를 만들면 된다.

 

 

이러면  링크가 생성된다.

https://buy.stripe.com/test_9B6eV681h6bqfll6wkaR200

 

실제 결제하는 페이지

 

다시 Payment Link로 가면 내가 만든 결제 정보가 나타나는데 이 버튼을 누르면 아래와 같은 페이지가 생성된다.

 

 

여기서 상단의 Active payments를 클릭하면 실제 상품이 생성된다.

 

Buy button은 아래와 같은 HTML 코드를 생성해준다.

<script async
  src="https://js.stripe.com/v3/buy-button.js">
</script>

<stripe-buy-button
  buy-button-id="buy_btn_1RSoY04GjxiSZ1HZErmzjdZe"
  publishable-key="pk_test_51RRRiw4GjxiSZ1HZxOkVbwwkALVYJVFn5AaE1yTKTjSgnjGuHfVCH2YEHjTmmMoWrNyWuFltwk9heOcP0O0DtABe00Unn2p6Yr"
>
</stripe-buy-button>

 

Payment Method는 여러 결제 방식을 설정할 수 있다. 전부 원버튼이라 말이 안된다. 이렇게 모든 구독 설정을 콘솔에서 설정하고 코드까지 자동생성 해준다.

 

3. 데이터 유실 없이 안정성은 보장할 수 있을 것인가?

사실 개발하는 입장에선 이게 가장 큰 이슈다. 결제 정보가 누락되는 건 너무나 치명적인 이슈다. 위 두가지도 중요했지만 개발하는 입장에서는 이 부분이 제일 문제됐지만, 결제 이벤트가 성공적으로 수신되지 않으면 3일간 지수 백오프로 retry를 해준다. https://docs.stripe.com/webhooks#behaviors

 

그리고 결제 성공은 webhook으로 진행되는데 webhook 요청에 어느정도의 메타데이터를 실을 수 있어서 어느 사용자가 결제를 했는지 담아서 보낼 수 있다. 물론 정말 결제 데이터가 존재하는 지 정도의 재확인 로직은 필요하겠지만 이정도 만으로도 충분히 안정성이 보장된다할 수 있지 않을까?

 

서버가 성공을 보내고 서버 내부 로직 상 실패로인한건 어쩔 수 없다..

 

4. 개발하기 간편한가?

사용자            브라우저(JS)            Spring Boot API              Stripe 서버                Webhook API
   |                  |                        |                         |                          |
   | [결제 버튼 클릭]  |                        |                         |                          |
   |----------------->|                        |                         |                          |
   |                  | POST /checkout-session |                         |                          |
   |                  |----------------------->|                         |                          |
   |                  |                        |Stripe 세션 생성 API 호출|                          |
   |                  |                        |------------------------>|                          |
   |                  |                        |                         | Checkout 세션 생성       |
   |                  |                        |                         |<-------------------------|
   |                  |<-----------------------| 세션 ID 반환            |                          |
   | redirectToCheckout(sessionId)             |                         |                          |
   |----------------->|   결제창으로 리디렉션   |                         |                          |
   |                  |                        |                         |                          |
   |                  | 카드 입력 / 결제 진행   |                         |                          |
   |                  |------------------------------------------------->|                          |
   |                  |                        |                         | 결제 처리 완료           |
   |                  |                        |                         |                          |
   |                  |                        |                         |→ Webhook 호출            |
   |                  |                        |                         |(invoice.payment_succeeded)
   |                  |                        |                         |------------------------->|
   |                  |                        |<------------------------| Webhook 수신             |
   |                  |                        | 구독 상태 업데이트       |                           |
   |                  |                        |                         |                          |

그리기 귀찮아서 GPT에게 텍스트로 만들어달라고 함. 솔직히 별로 할게 없다. 백엔드에선 체크아웃 세션을 만들고, 세션 ID 반환 후 결제정보를 웹훅으로 수신해 데이터베이스에 저장하면 끝이다. 프론트에선 위에서 만들어둔 결제버튼과 결제창에서 확인만 시켜주면 끝이다. 백엔드의 코드를 보면 다음과 같다.

// 시리얼라이즈 클래스가 있어야한다. PostConstruct가 싫으면 Configuration도 괜찮을듯
@Component
class StripeInitializer(
) {
    @PostConstruct
    fun init() {
        Stripe.apiKey = "sk_test_51RRRiw4GjxiSZ1HZW9y4a6FneLvBLuwogNNuZFmErPmuJQy94CtH6zE0WPUqvCdnTGNy2dwxVAYbX2f2WXJEirU500NDAohRys"
    }
}

 

체크아웃 세션 만드는 코드, 성공 페이지와 실패 페이지는 FE에서 요구하는 페이지로 리다이렉션 시켜주면 될 것 같다. Price ID는 Product Catalog에서 Product를 생성하면 바로 보인다.

@RestController
@RequestMapping("/api/payment")
class PaymentController {

    @PostMapping("/create-checkout-session")
    fun createSession(): Map<String, String> {
        val params = SessionCreateParams.builder()
            .setMode(SessionCreateParams.Mode.SUBSCRIPTION)
            .setSuccessUrl("http://localhost:8080/success")
            .setCancelUrl("http://localhost:8080/cancel")
            .addLineItem(
                SessionCreateParams.LineItem.builder()
                    .setQuantity(1L)
                    .setPrice("price_1RRS8P4GjxiSZ1HZ6IAjjCg1") // 실제 Stripe Price ID
                    .build()
            ).build()

        val session = Session.create(params)
        return mapOf("id" to session.id)
    }
}

결제 성공을 확인하기 위한 Stripe 이벤트 웹훅이다. 메타데이터를 전달받고 결제 재검증 과정은 빠져있다. 무엇보다 retry를 서버에서 하지 않아도 되는게 가장 큰 장점인 것 같다.

@RestController
class StripeWebhookController {
    @PostMapping("/webhook")
    fun handleWebhook(
        @RequestBody payload: String,
        @RequestHeader("Stripe-Signature") sigHeader: String
    ): ResponseEntity<String> {
        return try {
            val event = Webhook.constructEvent(payload, sigHeader, "whsec_db0eac30523bd1d8e49043c27c1aa50de74f325d2db0b15254edb66785185dc2")

            when (event.type) {
                "invoice.payment_succeeded" -> {
                    println("✅ 결제 성공: ${event.id}")
                    // TODO: subscription ID 확인 → DB 저장
                }
                "invoice.payment_failed" -> {
                    println("❌ 결제 실패: ${event.id}")
                }
                "customer.subscription.deleted" -> {
                    println("🛑 구독 해지됨: ${event.id}")
                }
                else -> {
                    println("📦 처리되지 않은 이벤트: ${event.type}")
                }
            }

            ResponseEntity.ok("received")
        } catch (e: Exception) {
            println("⚠️ Webhook 오류: ${e.message}")
            ResponseEntity.badRequest().build()
        }
    }
}

결제와 관련된 거의 모든 이벤트들을 제공하기 때문에 입맛에 맞게 핸들링이 가능하다.

https://docs.stripe.com/api/events/types

5. 환불과 운영

시스템 자체가 Stripe에서 결제를 관리하기 때문에 제품관리자 입장에선 딱히 처리할 게 없다. 문의사항 정도? 대부분의 처리는 웹훅에서 이벤트로 관리되기 때문에 자동화도 편하다.

 

 

물론 코틀린 코드로도 환불이 가능하다.

val refundParams = RefundCreateParams.builder()
    .setPaymentIntent("pi_1xxxxx") // 또는 charge ID
    .setAmount(500L) // 부분 환불 시 (센트 단위)
    .build()

val refund = Refund.create(refundParams)

환불 결과는 stripe 콘솔에서 모두 확인 가능하다. CS도 별도의 페이지를 제공해서 운영 가능하다고 하는데 여기까진 진행해보지 않았다.

가격

2.9% + $0.30(1건당)의 요금이 발생한다. 다른 결제 SaaS인 Paypal에 비해서는 썩 비싸진 않지만, 국내 PG 사의 수수료가 보통 2% 미만임을 생각해보면 아무래도 부담스럽다. 무엇보다 안그래도 원달러 환율이 높은데 건당 $0.30는 심하다는 생각이 든다. 아무래도 우리나라에서는 선택받기 힘들어 보인다.

 

마치며

내가 써본 SaaS 중 최고의 편의성을 제공하는 제품이었다. 문서화도 매우 깔끔하게 잘되어있다.

 

더 나아가 CLI도 제공하고 여러 테스트를 할 수 있고, 테스트를 진행해봤지만 본 글에선 따로 정리하진 않았다.

 

생각하는 모든게 있고, 정말 안정성을 제공하는가?에 대한 모든 테스트까지 진행해 볼 수 있게 만들어졌다.

 

괜히 성공한게 아니라는 생각이 들지만 역시 비싸다. 그래서 결국 최종적으론 기존 사용하던 PG사가 선택될 것 같다..

 

 하지만 SaaS를 만들려면 이정도는 되야하지 않을까? 라는 레퍼런스로는 최고의 제품이었다.

 

이런걸 보면 아직 우리나라 SaaS들은 멀었다는 생각이 든다... 사람이 직접가서 해주는게 더 싸기 때문일까...