블로그 리소스 소개 검색 주제
Tutorials

Node.js에서 Polar.sh 결제: 백엔드 완벽 가이드

업데이트됨 2026년 1월 3일

카테고리: Tutorials
공유

Node.js 백엔드와 Polar.sh 결제 연동

이 글은 Polar.sh 결제에 관한 제 X 스레드를 확장하여, 완전한 코드 예제, 오류 처리, 그리고 프로덕션 사용에서 얻은 교훈을 담았습니다.

SaaS를 구축하거나 디지털 제품을 판매한다면 결제 제공업체가 필요합니다. Polar.sh는 제가 독립 프로젝트에서 주로 사용하는 도구입니다. Node.js 백엔드에 통합하는 방법을 소개합니다.

Polar.sh를 선택해야 하는 이유? #why-polar-sh

코드를 살펴보기 전에, 제가 Polar.sh를 다른 대안보다 선택한 이유를 설명합니다.

Stripe 대비: Polar는 더 많은 기능을 기본적으로 제공합니다. 호스팅된 체크아웃 페이지, 고객 포털, 구독 라이프사이클 관리를 직접 구축할 필요 없이 이용할 수 있습니다. Stripe는 더 많은 제어권을 주지만 더 많은 코드가 필요합니다.

Paddle/Lemon Squeezy 대비: 유사한 상인 기록(Merchant of Record) 이점(VAT/판매세 처리)을 제공하지만, Polar는 개발자와 오픈소스 프로젝트를 위해 특별히 만들어졌습니다. 개발자 경험(DX)이 현저히 더 좋습니다.

Polar가 처리해주는 것:

  • 호스팅된 체크아웃 페이지
  • 결제 처리
  • 고객 포털
  • 구독 라이프사이클
  • 세금 준수 (상인 기록으로서)

당신이 처리해야 하는 것:

  • 웹훅 이벤트
  • 사용자 권한
  • 사용량 추적

프로젝트 설정 #project-setup

SDK와 웹훅 검증 라이브러리를 설치합니다:

Terminal
npm install @polar-sh/sdk standardwebhooks

Polar 클라이언트를 초기화합니다:

src/lib/polar.ts
import { Polar } from '@polar-sh/sdk';

export const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN!,
    server: process.env.NODE_ENV === 'production' ? 'production' : 'sandbox',
});

필요한 환경 변수:

.env
POLAR_ACCESS_TOKEN=your_access_token
POLAR_WEBHOOK_SECRET=your_webhook_secret

이 값들은 Polar 대시보드에서 가져올 수 있습니다. 개발 중에는 샌드박스 모드를 사용하세요. 테스트 카드를 제공하며 실제 돈을 청구하지 않습니다.

체크아웃 세션 생성 #creating-checkout-sessions

사용자가 “구독” 또는 “구매”를 클릭하면 체크아웃 세션을 생성하고 리다이렉트합니다:

src/routes/checkout.ts
import { polar } from '../lib/polar';

export async function createCheckout(user: { id: string; email: string }, productId: string) {
    const checkout = await polar.checkouts.create({
        products: [productId],
        successUrl: 'https://yourapp.com/success?session_id={CHECKOUT_SESSION_ID}',
        customerEmail: user.email,
        metadata: {
            userId: String(user.id),
        },
    });

    return checkout.url;
}

핵심 사항:

  • metadata.userId는 Polar 고객을 사용자와 연결합니다. 웹훅에서 이 값이 필요합니다.
  • successUrl에는 {CHECKOUT_SESSION_ID}를 포함할 수 있으며, Polar가 실제 ID로 대체합니다.
  • products는 배열을 받습니다. 하나의 체크아웃으로 여러 제품을 묶을 수 있습니다.

Express 라우트 예제:

src/routes/api.ts
router.post('/create-checkout', async (req, res) => {
    const user = req.user; // 인증 미들웨어에서 가져옴
    const { productId } = req.body;

    if (!productId) {
        return res.status(400).json({ error: 'Product ID required' });
    }

    try {
        const checkoutUrl = await createCheckout(user, productId);
        res.json({ url: checkoutUrl });
    } catch (error) {
        console.error('Checkout creation failed:', error);
        res.status(500).json({ error: 'Failed to create checkout' });
    }
});

웹훅 처리 #webhook-handling

웹훅은 Polar가 결제 이벤트를 앱에 알리는 방식입니다. 이 부분이 가장 중요하고 정확하게 처리해야 합니다.

서명 검증

Polar는 Standard Webhooks 명세를 사용합니다. 스푸핑된 요청을 방지하려면 서명을 검증해야 합니다:

src/webhooks/polar.ts
import { Webhook } from 'standardwebhooks';

const secret = 'whsec_' + Buffer.from(
    process.env.POLAR_WEBHOOK_SECRET!
).toString('base64');

const wh = new Webhook(secret);

export function verifyWebhook(rawBody: string, headers: Record<string, string>) {
    try {
        return wh.verify(rawBody, headers);
    } catch (error) {
        console.error('Webhook verification failed:', error);
        throw new Error('Invalid webhook signature');
    }
}

라우트 설정 (중요!)

웹훅 라우트는 서명 검증을 위해 원본 요청 본문이 필요합니다. JSON 미들웨어보다 먼저 등록해야 합니다:

src/app.ts
import express from 'express';
import { webhookRouter } from './routes/webhooks';

const app = express();

// 웹훅 라우트를 맨 먼저 등록 - 원본 본문 필요
app.use('/api/webhooks', webhookRouter);

// 웹훅 이후 JSON 파싱
app.use(express.json());

// 다른 라우트...
app.use('/api', apiRouter);
src/routes/webhooks.ts
import { Router } from 'express';
import express from 'express';
import { verifyWebhook } from '../webhooks/polar';
import { handlePolarEvent } from '../services/polar-events';

const router = Router();

router.post('/polar',
    express.raw({ type: 'application/json' }),
    async (req, res) => {
        try {
            const event = verifyWebhook(
                req.body.toString(),
                req.headers as Record<string, string>
            );

            await handlePolarEvent(event);
            res.status(200).json({ received: true });
        } catch (error) {
            console.error('Webhook error:', error);
            res.status(400).json({ error: 'Webhook processing failed' });
        }
    }
);

export { router as webhookRouter };

이벤트 핸들러

주요 구독 이벤트를 처리하는 방법은 다음과 같습니다:

src/services/polar-events.ts
import { db } from '../lib/db';

interface PolarEvent {
    type: string;
    data: {
        id: string;
        status: string;
        customer_id: string;
        metadata?: { userId?: string };
        current_period_end?: string;
    };
}

export async function handlePolarEvent(event: PolarEvent) {
    const { type, data } = event;

    switch (type) {
        case 'subscription.created':
            // Polar 고객을 사용자와 연결
            await db.user.update({
                where: { id: data.metadata?.userId },
                data: {
                    polarCustomerId: data.customer_id,
                    subscriptionId: data.id,
                    subscriptionStatus: 'pending',
                },
            });
            break;

        case 'subscription.active':
            // 결제 성공 - 구독 활성화
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'active',
                    currentPeriodEnd: new Date(data.current_period_end!),
                    // 구독별 한도 설정
                    apiCallsLimit: 10000,
                    storageLimit: 5 * 1024 * 1024 * 1024, // 5GB
                },
            });
            break;

        case 'subscription.updated':
            // 요금제 변경, 갱신 등
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: data.status,
                    currentPeriodEnd: new Date(data.current_period_end!),
                },
            });
            break;

        case 'subscription.canceled':
            // 사용자 취소 - 기간 종료까지는 계속 활성화
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'canceled',
                    canceledAt: new Date(),
                },
            });
            break;

        case 'subscription.revoked':
            // 즉시 취소 (결제 실패, 환불 등)
            await db.user.update({
                where: { polarCustomerId: data.customer_id },
                data: {
                    subscriptionStatus: 'revoked',
                    apiCallsLimit: 0,
                    storageLimit: 0,
                },
            });
            break;

        case 'order.paid':
            // 일회성 구매 완료
            await handleOneTimePurchase(data);
            break;

        default:
            console.log(`Unhandled event type: ${type}`);
    }
}

async function handleOneTimePurchase(data: PolarEvent['data']) {
    // 평생 접근 권한, 크레딧 등을 부여
    await db.user.update({
        where: { id: data.metadata?.userId },
        data: {
            lifetimeAccess: true,
        },
    });
}

구독 관리 #subscription-management

구독 취소

사용자가 앱에서 취소할 수 있도록 합니다 (기간 종료까지는 접근 권한 유지):

src/services/subscriptions.ts
import { polar } from '../lib/polar';

export async function cancelSubscription(subscriptionId: string) {
    await polar.subscriptions.update({
        id: subscriptionId,
        subscriptionUpdate: {
            cancelAtPeriodEnd: true,
        },
    });
}

고객 포털

Polar는 호스팅된 포털을 제공하여 고객이 결제 정보를 관리하고, 결제 수단을 업데이트하고, 인보이스를 볼 수 있게 합니다:

src/services/subscriptions.ts
export async function getCustomerPortalUrl(polarCustomerId: string) {
    const session = await polar.customerSessions.create({
        customerId: polarCustomerId,
    });

    return session.customerPortalUrl;
}

Express 라우트:

src/routes/api.ts
router.post('/billing-portal', async (req, res) => {
    const user = req.user;

    if (!user.polarCustomerId) {
        return res.status(400).json({ error: 'No subscription found' });
    }

    try {
        const portalUrl = await getCustomerPortalUrl(user.polarCustomerId);
        res.json({ url: portalUrl });
    } catch (error) {
        console.error('Portal creation failed:', error);
        res.status(500).json({ error: 'Failed to create portal session' });
    }
});

흔한 함정 #common-pitfalls

프로덕션에 Polar를 연동한 후 제가 겪고 (그리고 목격한) 실수들입니다.

1. 웹훅보다 JSON 미들웨어를 먼저 등록

서명 검증 오류가 발생한다면 이것이 원인일 가능성이 매우 높습니다. 원본 본문이 웹훅 핸들러가 보기도 전에 express.json()에 의해 파싱되기 때문입니다.

해결책: express.json()보다 웹훅 라우트를 먼저 등록하세요.

2. 모든 구독 상태 처리 실패

구독은 active, canceled, past_due, incomplete, trialing, revoked 상태일 수 있습니다. active만 확인하지 말고 전체 라이프사이클을 처리하세요.

3. 메타데이터 누락

체크아웃 메타데이터에 userId를 전달하는 것을 잊어버리면 Polar 고객을 사용자와 연결할 수 없습니다. 항상 식별 정보를 포함하세요.

4. 웹훅 핸들러가 멱용성이 아님

Polar는 웹훅을 재시도할 수 있습니다. 핸들러는 멱용성이어야 합니다. 동일한 이벤트를 두 번 처리해도 문제가 발생하지 않아야 합니다.

// 나쁨 - 중복 레코드 생성
await db.payment.create({ data: { orderId: event.data.id, ... } });

// 좋음 - 고유 ID를 기반으로 upsert
await db.payment.upsert({
    where: { polarOrderId: event.data.id },
    create: { polarOrderId: event.data.id, ... },
    update: { ... },
});

5. 웹훅 응답 차단

웹훅 엔드포인트는 빠르게 응답해야 합니다. 무거운 처리가 필요하다면 즉시 응답하고 비동기적으로 처리하세요:

router.post('/polar', express.raw({ type: 'application/json' }), async (req, res) => {
    const event = verifyWebhook(req.body.toString(), req.headers);

    // 즉시 응답
    res.status(200).json({ received: true });

    // 비동기 처리 (프로덕션에서는 적절한 작업 대기열 사용)
    setImmediate(() => handlePolarEvent(event));
});

연동 테스트 #testing-your-integration

샌드박스 모드 사용

Polar의 샌드박스 환경은 실제 요금 없이 전체 흐름을 테스트할 수 있게 합니다:

const polar = new Polar({
    accessToken: process.env.POLAR_ACCESS_TOKEN,
    server: 'sandbox', // 테스트 모드
});

테스트 카드

샌드박스에서는 다음 테스트 카드 번호를 사용하세요:

  • 4242 4242 4242 4242 - 성공적인 결제
  • 4000 0000 0000 0002 - 카드 거절

로컬 웹훅 테스트

ngrok과 같은 도구를 사용하여 로컬 서버를 노출하세요:

ngrok http 3000

그런 다음 Polar 대시보드에서 웹훅 URL을 https://your-ngrok-url.ngrok.io/api/webhooks/polar로 설정하세요.

설정 확인

운영 환경 적용 전 체크리스트:

  • 체크아웃이 성공적으로 생성되고 Polar로 리다이렉트됨
  • 성공 URL이 앱으로 리다이렉트됨
  • 웹훅 서명 검증이 통과됨
  • subscription.active 이벤트가 사용자 접근 권한을 활성화함
  • 고객 포털이 올바르게 로드됨
  • 취소가 작동하고 cancelAtPeriodEnd가 설정됨

이것이 Polar.sh 결제 연동의 전부입니다. 핵심은 Polar가 잘하는 것(체크아웃 UI, 결제 처리, 고객 포털)은 Polar에 맡기고, 여러분의 코드는 웹훅 처리와 사용자 권한에 집중하는 것입니다.

자세한 내용은 공식 Polar 문서를 확인하세요.

카테고리 Tutorials
공유

최신 AI 인사이트를 받은 편지함으로 전달받으세요

최신 트렌드, 튜토리얼 및 업계 인사이트로 최신 정보를 유지하세요. 우리 뉴스레터를 신뢰하는 개발자 커뮤니티에 참여하세요.

신규 계정만 해당. 이메일을 제출하면 당사의 개인정보 보호정책