콘텐츠로 이동

@gj-kit/toss-payments-nestjs

DI token은 타입을 싣고 다니지 않습니다. TossPaymentsFor<typeof config>가 그 타입을 되살려, 배선하지 않은 flow는 그대로 컴파일 에러로 남습니다.

  • npm 0.4.5
  • 런타임 의존성 0
  • @ts-expect-error로 고정한 거부 9건
  • Nest 10·11 실제 부팅 검증
  • unit 테스트 20개, 그중 8개는 실제 Nest DI 부팅

DI token은 타입을 싣고 다니지 않습니다. forRoot()가 돌려주는 건 unique symbol에 바인딩된 DynamicModule이라, 주입 지점에 적은 생성자 타입 표기가 유일한 근거로 남습니다 — BillingKeyStore를 배선한 적 없는 config인데도 toss.billing이 타입 검사를 통과하고, 런타임에는 그 프로퍼티가 undefined입니다. 여기에 webhook controller를 붙이면 Nest 기본 body parser가 요청을 이미 객체로 바꿔 놓은 뒤입니다. 다시 직렬화한 JSON으로는 서명 검증을 복구할 수 없습니다.

  • 배선 안 한 flow는 프로퍼티가 없습니다 — TossPaymentsFor<typeof config>가 DI 경계 뒤에서도 조건부 kit 타입을 복원하므로, BillingKeyStore 배선 없이 toss.billing을 쓰면 컴파일 에러입니다.
  • rawBody 부재는 조용히 넘어가지 않습니다 — req.rawBody가 없으면 toNestWebhookHandler는 handler를 호출하지 않습니다. 500을 돌려주고 확인할 설정 세 가지를 로그로 남깁니다 — NestFactory.create의 rawBody: true, Fastify의 raw body 지원 설정, 그리고 webhook route 앞에 걸린 JSON 미들웨어.
  • source IP가 wrapper를 통과합니다 — handler가 원본 Node socket을 그대로 전달해 코어의 fail-closed IP 검증이 유지되고, proxy 헤더를 신뢰하려면 sourceIp extractor를 명시해야 합니다.
  • ESM·CJS 어디서든 같은 token — TOSS_PAYMENTS와 getTossPaymentsToken(name)은 Symbol.for 기반이라, 패키지가 ESM/CJS로 이중 로드돼도 provider 바인딩이 하나로 유지됩니다.
  • emitDecoratorMetadata 없이 동작합니다 — InjectTossPayments()는 @Inject(token)에 위임하는 얇은 래퍼이고 src/ 어디에도 design:paramtypes를 읽는 코드가 없습니다. 그래서 패키지는 emitDecoratorMetadata: false로 배포되며, 해당 메타데이터를 아예 만들지 못하는 vitest의 esbuild 변환에서도 Nest DI 테스트가 그대로 해석됩니다.

완료 상태: typed payment kit을 주입하는 Nest provider 하나를 만듭니다.

Terminal window
pnpm add @gj-kit/toss-payments-nestjs

2. 앱이 소유할 경계를 정합니다

섹션 제목: “2. 앱이 소유할 경계를 정합니다”

core payment config을 만들고 한 번만 등록하며, webhook route에서는 원본 request bytes를 보존합니다.

먼저 아래 코드를 복사한 뒤, 위에서 언급한 앱 소유 값만 교체하세요.

import { orThrow } from '@gj-kit/toss-payments';
import { defineTossPaymentsConfig, parseApiSecretKey } from '@gj-kit/toss-payments/server';
import { TossPaymentsModule } from '@gj-kit/toss-payments-nestjs';
declare const apiSecretFromEnv: string; // Read this once from your server environment.
const config = defineTossPaymentsConfig({
secretKey: orThrow(parseApiSecretKey(apiSecretFromEnv)),
});
export const payments = TossPaymentsModule.forRoot(config);

주석 처리한 줄은 실제 TS2339입니다. 이 config에는 BillingKeyStore가 배선되어 있지 않으므로, 타입을 싣지 않는 DI token을 거쳐 주입된 kit에도 billing 프로퍼티 자체가 존재하지 않습니다.

import { Injectable } from '@nestjs/common';
import { orThrow } from '@gj-kit/toss-payments';
import { defineTossPaymentsConfig, parseApiSecretKey, type OrderStore } from '@gj-kit/toss-payments/server';
import { InjectTossPayments, TossPaymentsModule, type TossPaymentsFor } from '@gj-kit/toss-payments-nestjs';
declare const SECRET: string; // the app owns this (process.env)
declare const orders: OrderStore; // the app owns this (its own DB adapter)
export const tossConfig = defineTossPaymentsConfig({ secretKey: orThrow(parseApiSecretKey(SECRET)), orders });
export type AppToss = TossPaymentsFor<typeof tossConfig>;
export const tossModule = TossPaymentsModule.forRoot(tossConfig);
@Injectable()
export class PaymentsService {
constructor(@InjectTossPayments() private readonly toss: AppToss) {}
order = () => this.toss.confirm.createOrder({ amount: 9_900, orderName: 'Pro' });
// this.toss.billing — TS2339: the config wired no BillingKeyStore, so there is no property.
}

Nest 앱에서 core payment kit의 타입과 안전 경계를 의존성 주입까지 유지해야 할 때 사용합니다.

controller에서 결제 검증을 다시 구현하거나 웹훅 검증에 raw bytes가 필요한데 파싱된 JSON에 의존하지 마세요.

엔진: node >=20

Peer 지원 범위 필요한 위치
@gj-kit/toss-payments `^0.2.0
@nestjs/common `^10
reflect-metadata `^0.1.13
rxjs ^7 패키지 통합
Entry Exported symbols
Root entry 14

검증되는 웹훅은 원본 request bytes를 보존하고, 모든 store 의존성을 host Nest module에 명시하세요.

전체 API reference에는 선언 파일에서 생성한 모든 공개 함수, 클래스, 타입, 상수, 오류 계약이 있습니다. 각 항목은 import path, release signature, package version을 표시합니다.

관계 패키지 함께 쓰는 이유
필수 @gj-kit/toss-payments 이 패키지의 공개 계약을 구현하거나 사용합니다.
선택 조합 @gj-kit/toss-payments-postgresql 호스트가 PostgreSQL을 선택했다면 Nest module에 PostgreSQL 기반 core store를 제공합니다.

npm · GitHub