# 문제 해결

## 증상별 확인

### 결제창

| 현상 | 확인할 내용 |
| --- | --- |
| SDK가 로드되지 않음 | SDK 주소 `https://widget.point3.io/v2/standard`의 응답, npm 로더의 `loader=toss`, CSP의 `script-src` |
| SDK가 결제창을 열지 않음 | SDK 오류 코드, `client_id`, `customerKey`, `setAmount()` 호출 여부와 결과 URL |
| 본인인증 화면이 열리지 않음 | CSP의 `frame-src`, 앱이면 WebView의 JavaScript 설정 |
| 인증 후 결제 화면이 열리지 않음 | `Referrer-Policy`, 결제 세션 상태와 금액 |
| 앱에서 결제창의 뒤로가기·메뉴가 눌리지 않음 | WebView가 safe area 안에 있는지, 결제창 위에 네이티브 버튼이 겹치지 않는지 |
| 앱에서 결제 후 결과를 받지 못함 | 앱이 비교하는 `successUrl`·`failUrl` 주소 |
| 앱에서 결제창이 열리지 않음 | HTML을 HTTPS base URL로 불러왔는지, WebView의 JavaScript 설정 |
| 위임 인증을 전달했는데 문자 인증 화면이 열림 | 위임 인증 만료(5분)나 재사용 여부. 정상적인 전환이므로 결제는 계속 진행 |
| 위임 인증 실패 후 결제가 종료됨 | `customerKey`에 `ANONYMOUS`를 전달했는지 확인. 같은 `payerId`를 전달해야 문자 인증으로 전환 |

### 가맹점 서버

| 현상 | 확인할 내용 |
| --- | --- |
| 결제 세션을 생성하지 못함 | API 주소, API 키와 HTTP 상태 코드 |
| 결제 세션 API가 `503 temporarilyUnavailable` | `error.eob`가 있을 때만 EOB로 처리하고 `endsAt` 확인 |
| 위임 인증 생성이 `404 PAYER_NOT_FOUND` | 회원 정보에 저장된 `payerId`를 지금은 사용할 수 없음(탈퇴, 다른 가맹점 값 등). 위임 인증 없이 결제창을 열고, 결제 후 새로 받은 `payerId`로 회원 정보 갱신 |
| 결제 승인 API가 호출되지 않음 | `event.source`, `event.origin`, 메시지 필드, 현재 `sessionId`, 중복 처리 상태 |
| 결제 결과를 확인할 수 없음 | 승인 요청에 사용한 `sessionId`와 결제 세션 조회 응답의 `status` |
| 결제 세션이 만료됨 | 기존 세션을 재사용하지 않고 새 결제 세션 생성 |
| 승인 또는 취소 결과를 확인할 수 없음 | 성공·실패로 추정하지 않고 식별값과 주문 상태를 보존하며 각각의 상태 조회 API로 확인 |

응답 코드별 의미는 [에러 코드](/error-codes)에서 확인하세요.

## 결과를 확인할 수 없을 때

응답 대기 시간 초과, 네트워크 오류, 응답 유실, 승인 응답의 세션 불일치 또는 결제 취소의 `REFUND_TEMPORARY_UNAVAILABLE`·`REFUND_DUPLICATE_REQUEST`는 결과가 확정되지 않은 상황입니다. 다른 `409`는 [에러 코드](/error-codes#409-응답)의 처리 기준을 따르세요. 결과가 불확실한 승인이나 취소 요청을 무조건 다시 보내거나 상품 지급·환불 완료 처리를 하지 마세요.

<Callout type="caution" title="확인되지 않은 결과를 실패로 처리하지 마세요">
  결제 결과를 확인할 수 없다면 같은 주문을 바로 다시 결제하거나 상품을 지급하지 마세요. 주문 상태와 결제 내역을 먼저 확인하세요.
</Callout>

결제 승인 결과가 불확실하면 같은 `sessionId`로 `GET /payment/v3/session/{sessionId}`를 호출해 `status`를 확인합니다. `captured` 또는 `failed`가 확인될 때까지 주문을 확정하지 마세요.

취소 요청의 결과가 불확실하면 `GET /refunds/v1/{sessionId}`로 결제 세션의 취소 상태를 조회합니다. `refunds`에 요청한 항목이 `completed`로 확인되면 완료 처리하고, `failed`면 실패 정보를 기록합니다. `processing`인 활성 항목이 하나 있으면 `POST /refunds/v1/{sessionId}/resume`으로 처리를 재개할 수 있습니다. 조회 결과만으로 원래 요청을 식별할 수 없거나 조회·재개도 실패하면 요청 식별값과 `Idempotency-Key`를 보존한 채 [point3 담당자에게 문의](#문의하기)하세요.

## 결제 승인 실패 코드

결제 승인 API는 실패가 확정된 경우에도 HTTP `200`과 `status: "failed"`를 반환합니다. 사유는 `outcome.code`로 구분하세요. `outcome.message` 문구는 바뀔 수 있어 분기 조건으로 적합하지 않습니다.

| `outcome.code` | `outcome.type` | 의미 | 처리 |
| --- | --- | --- | --- |
| `PAYMENT_METHOD_DECLINED` | `declined` | 결제수단 승인 거절. 잔액 부족, 출금 가능 잔액 부족 등이 해당하며 잔액 부족만 뜻하지는 않음 | 구매자에게 잔액 확인 또는 다른 계좌 선택 안내 |
| `PAYMENT_METHOD_LIMIT_EXCEEDED` | `failed` | 이체한도 초과 | 구매자에게 이체한도 확인 또는 다른 계좌 선택 안내 |
| `PAYMENT_CAPTURE_REJECTED` | `rejected` | 세부 사유가 분류되지 않은 이체 실패 | 주문·결제 상태 확인, 필요하면 [문의](#문의하기) |
| `PAYMENT_CAPTURE_ALREADY_FAILED` | `failed` | 이미 실패한 결제를 다시 승인 요청함. 원래 사유는 포함되지 않음 | 처음 받은 실패 응답의 기록 확인 |

잔액 부족으로 승인이 거절된 경우:

```json
{
  "id": "pymt_sess-019f0000-0000-7000-9000-000000000000",
  "object": "paymentCapture",
  "status": "failed",
  "outcome": {
    "type": "declined",
    "code": "PAYMENT_METHOD_DECLINED",
    "message": "결제수단 승인이 거절되었습니다."
  },
  "committedAt": "2026-08-21"
}
```

이체한도를 초과한 경우:

```json
{
  "id": "pymt_sess-019f0000-0000-7000-9000-000000000000",
  "object": "paymentCapture",
  "status": "failed",
  "outcome": {
    "type": "failed",
    "code": "PAYMENT_METHOD_LIMIT_EXCEEDED",
    "message": "결제수단의 이체한도를 초과했습니다."
  },
  "committedAt": "2026-08-21"
}
```

### 첫 실패 사유 저장

실패한 결제를 다시 승인 요청하면 원래 사유 대신 `PAYMENT_CAPTURE_ALREADY_FAILED`가 반환되고, 결제 세션 조회 응답에 원래 사유가 남아 있다고 보장할 수도 없습니다. 그래서 처음 받은 실패 응답의 `outcome.code`를 주문과 `sessionId` 기준으로 저장해 두어야 합니다.

- 저장한 코드는 이후 받은 `processing`이나 `PAYMENT_CAPTURE_ALREADY_FAILED` 응답으로 덮어쓰지 않습니다.
- 같은 주문에서 새 결제 세션을 만들면 실패 기록도 세션별로 따로 둡니다. 이전 세션의 사유를 새 세션에 적용하지 마세요.
- 사유를 다시 확인하려고 승인 API를 반복 호출하지 마세요.

```ts title="TypeScript"
const sessionId = order.sessionId
const res = await capturePayment(sessionId)

if (res.status === 'captured') {
  await markOrderPaid(order.id)
} else if (res.status === 'processing') {
  // 아직 처리 중입니다. 결제 세션을 조회해 결과를 확인합니다.
  await scheduleSessionStatusCheck(sessionId)
} else if (res.status === 'failed') {
  const code = res.outcome.code

  // 실패 기록은 주문과 sessionId 단위로 관리합니다.
  const stored = await findCaptureFailure(order.id, sessionId)

  // 이 세션의 첫 실패만 저장합니다.
  if (!stored && code !== 'PAYMENT_CAPTURE_ALREADY_FAILED') {
    await saveCaptureFailure(order.id, sessionId, code)
  }

  switch (stored?.code ?? code) {
    case 'PAYMENT_METHOD_DECLINED':
      showMessage('잔액 또는 출금 가능 잔액을 확인하거나 다른 계좌를 선택해주세요.')
      break
    case 'PAYMENT_METHOD_LIMIT_EXCEEDED':
      showMessage('이체한도를 확인하거나 다른 계좌를 선택해주세요.')
      break
    default:
      // PAYMENT_CAPTURE_REJECTED, 기록이 없는 PAYMENT_CAPTURE_ALREADY_FAILED 등
      await reviewOrder(order.id)
  }
}
```

`PAYMENT_CAPTURE_ALREADY_FAILED`를 받았는데 해당 `sessionId`의 기록이 없다면 이 코드만으로 원인을 판단하지 말고 [point3 담당자에게 문의](#문의하기)하세요.

## 문의하기

[시스템 상태](https://status.point3.io)에서 장애 여부를 먼저 확인한 뒤, point3 담당자에게 연락해주세요. 어떤 문제가 언제 발생했는지 정리하고, 아래 항목 중 확인할 수 있는 정보를 함께 전달하면 원인을 더 빠르게 파악할 수 있습니다.

- 발생 시각
- 문제가 발생한 단계 (결제 세션 생성, 결제창, 결제 승인, 결제 취소 등)
- `sessionId`
- 호출한 API와 받은 HTTP 상태 코드, 오류 코드
- 브라우저 또는 WebView·OS 정보

<Callout type="caution" title="민감 정보는 보내지 마세요">
  API 키, PIN, 계좌번호, 주민등록번호, 인증번호를 문의 내용에 포함하지 마세요.
</Callout>
