├── .gitignore ├── LICENSE ├── README.md ├── config-overrides.js ├── manuals ├── CERTIFICATION.md └── PAYMENT.md ├── package.json ├── public ├── favicon.ico ├── index.html └── manifest.json ├── src ├── App.js ├── App.test.js ├── Certification │ └── index.js ├── CertificationResult │ └── index.js ├── Home │ └── index.js ├── Payment │ ├── constants.js │ ├── index.js │ └── utils.js ├── PaymentResult │ └── index.js ├── index.css ├── index.js └── serviceWorker.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | build 4 | node_modules 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Solee Choi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # iamport-react-example 3 | [ ![alt text](https://img.shields.io/badge/react-v16.8.6-orange.svg?longCache=true&style=flat-square) ](https://github.com/facebook/react/) 4 | [ ![alt text](https://img.shields.io/badge/antd-v3.20.5-yellow.svg?longCache=true&style=flat-square) ](https://github.com/ant-design/ant-design) 5 | [ ![alt text](https://img.shields.io/badge/styled--components-v4.3.2-green.svg?longCache=true&style=flat-square) ](https://github.com/styled-components/styled-components) 6 | 7 | 리액트 환경에서 아임포트 결제 및 휴대폰 본인인증 연동을 위한 예제입니다. 8 | 9 | ## 다운받기 10 | 11 | ``` 12 | $ git clone https://github.com/iamport/iamport-react-example 13 | ``` 14 | 15 | ## 실행하기 16 | 17 | ``` 18 | $ cd ./iamport-react-example 19 | $ yarn 20 | $ yarn start 21 | ``` 22 | 23 | ## 아임포트 라이브러리 추가하기 24 | 25 | 최상단 HTML(public/index.html)에 아래의 ` 30 | 31 | 32 | ``` 33 | 34 | ## 결제 연동하기 35 | 36 | 결제 연동 방법은 [리액트에서 아임포트 결제 연동하기](manuals/PAYMENT.md) 문서를 참고하세요. 37 | 38 | ## 휴대폰 본인인증 연동하기 39 | 40 | 휴대폰 본인인증 연동 방법은 [리액트에서 아임포트 휴대폰 본인인증 연동하기](manuals/CERTIFICATION.md) 문서를 참고하세요. 41 | -------------------------------------------------------------------------------- /config-overrides.js: -------------------------------------------------------------------------------- 1 | const { override, fixBabelImports, addLessLoader } = require('customize-cra'); 2 | 3 | module.exports = override( 4 | fixBabelImports('import', { 5 | libraryName: 'antd', 6 | libraryDirectory: 'es', 7 | style: true, 8 | }), 9 | addLessLoader({ 10 | javascriptEnabled: true, 11 | modifyVars: { '@primary-color': '#344e81' }, 12 | }), 13 | ); -------------------------------------------------------------------------------- /manuals/CERTIFICATION.md: -------------------------------------------------------------------------------- 1 | 2 | # 리액트에서 아임포트 휴대폰 본인인증 연동하기 3 | 4 | 리액트 환경에서 아임포트 휴대폰 본인인증 연동을 위한 안내입니다. 5 | 6 | ## 1. 가맹점 식별하기 7 | 8 | `IMP` 객체의 `init` 함수 첫번째 인자에 `가맹점 식별코드`를 추가합니다. 9 | 10 | ```javascript 11 | const { IMP } = window; 12 | IMP.init('imp00000000'); // 'imp00000000' 대신 발급받은 가맹점 식별코드를 사용합니다. 13 | ``` 14 | 15 | 가맹점 식별코드는 아임포트 관리자 페이지 로그인 후, 시스템 설정 > 내정보에서 확인하실 수 있습니다. 16 | 17 | ## 2. 본인인증 데이터 정의하기 18 | 19 | 본인인증에 필요한 데이터를 아래와 같이 정의합니다. 이때 정의한 데이터는 `IMP.certification` 함수 호출시, 첫번째 인자로 전달됩니다. 본인인증 데이터에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요. 20 | 21 | ```javascript 22 | const data = { 23 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 24 | company: '아임포트', // 회사명 또는 URL 25 | carrier: 'SKT', // 통신사 26 | name: '홍길동', // 이름 27 | phone: '01012341234', // 전화번호 28 | ... 29 | }; 30 | ``` 31 | 32 | ## 3. 콜백 함수 정의하기 33 | 34 | 본인인증 후 실행될 로직을 콜백 함수로 정의합니다. 이때 정의한 함수는 `IMP.certification` 함수 호출시, 두번째 인자로 전달됩니다. 콜백 함수의 첫번째 인자로 본인인증 결과가 객체의 형태로 전달됩니다. 35 | 36 | ```javascript 37 | function callback(response) { 38 | const { 39 | success, 40 | merchant_uid, 41 | error_msg, 42 | ... 43 | } = response; 44 | 45 | if (success) { 46 | alert('본인인증 성공'); 47 | } else { 48 | alert(`본인인증 실패: ${error_msg}`); 49 | } 50 | } 51 | ``` 52 | 53 | ## 4. 본인인증 창 호출하기 54 | 55 | 본인인증 하기 버튼을 눌렀을때 `IMP` 객체의 `certification` 함수를 호출해 본인인증 창을 호출합니다. `certification` 함수의 첫번째 인자로는 2에서 정의한 `본인인증 데이터`를, 두번째 인자로는 3에서 정의한 `콜백 함수`를 전달합니다. 56 | 57 | ```javascript 58 | import React from 'react'; 59 | 60 | function Certification() { 61 | function onClickCertification() { 62 | /* 1. 가맹점 식별하기 */ 63 | const { IMP } = window; 64 | IMP.init('imp00000000'); 65 | 66 | /* 2. 본인인증 데이터 정의하기 */ 67 | const data = { 68 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 69 | company: '아임포트', // 회사명 또는 URL 70 | carrier: 'SKT', // 통신사 71 | name: '홍길동', // 이름 72 | phone: '01012341234', // 전화번호 73 | ... 74 | }; 75 | 76 | /* 4. 본인인증 창 호출하기 */ 77 | IMP.certification(data, callback); 78 | } 79 | 80 | /* 3. 콜백 함수 정의하기 */ 81 | function callback(response) { 82 | const { 83 | success, 84 | merchant_uid, 85 | error_msg, 86 | ... 87 | } = response; 88 | 89 | if (success) { 90 | alert('본인인증 성공'); 91 | } else { 92 | alert(`본인인증 실패: ${error_msg}`); 93 | } 94 | } 95 | 96 | return ( 97 | ... 98 | 99 | ... 100 | ); 101 | } 102 | ``` 103 | 104 | ## 5. 리액트 네이티브 환경에 대응하기 105 | 106 | 리액트 네이티브에서 해당 본인인증 화면을 웹뷰로 띄워 재사용하는 경우가 있습니다. 이 경우 본인인증 하기 버튼을 눌렀을때 본인인증 환경이 리액트 네이티브인지 판단해, `IMP.certification` 함수 호출이 아닌, **리액트 네이티브로 post message를 보내야** 합니다. 리액트 네이티브에 아임포트 리액트 네이티브 모듈을 설치한 후, 리액트로부터 post message를 받으면 해당 본인인증 화면을 렌더링 하는 로직을 추가해야 합니다. 107 | 108 | ### 5-1. 리액트 네이티브로 post message 보내기 109 | 110 | 본인인증 하기 버튼을 눌렀을 때 본인인증 환경을 판단하는 로직을 추가합니다. 본인인증 환경이 리액트 네이티브인 경우, **리액트 네이티브로 `가맹점 식별코드`, `본인인증 데이터` 그리고 `액션 유형`을 post message로 보냅니다.** 111 | 112 | ```javascript 113 | import React from 'react'; 114 | 115 | function Certification() { 116 | function onClickCertification() { 117 | const userCode = 'imp00000000'; 118 | 119 | /* 2. 본인인증 데이터 정의하기 */ 120 | const data = { 121 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 122 | company: '아임포트', // 회사명 또는 URL 123 | carrier: 'SKT', // 통신사 124 | name: '홍길동', // 이름 125 | phone: '01012341234', // 전화번호 126 | ... 127 | }; 128 | 129 | if (isReactNative()) { 130 | /* 5. 리액트 네이티브 환경에 대응하기 */ 131 | const params = { 132 | userCode, // 가맹점 식별코드 133 | data, // 본인인증 데이터 134 | type: 'certification', // 결제와 본인인증 구분을 위한 필드 135 | }; 136 | const paramsToString = JSON.stringify(params); 137 | window.ReactNativeWebView.postMessage(paramsToString); 138 | } else { 139 | /* 1. 가맹점 식별하기 */ 140 | const { IMP } = window; 141 | IMP.init(userCode); 142 | /* 4. 본인인증 창 호출하기 */ 143 | IMP.certification(data, callback); 144 | } 145 | } 146 | 147 | /* 3. 콜백 함수 정의하기 */ 148 | function callback(response) { 149 | const { 150 | success, 151 | merchant_uid, 152 | error_msg, 153 | ... 154 | } = response; 155 | 156 | if (success) { 157 | alert('본인인증 성공'); 158 | } else { 159 | alert(`본인인증 실패: ${error_msg}`); 160 | } 161 | } 162 | 163 | function isReactNative() { 164 | /* 165 | 리액트 네이티브 환경인지 여부를 판단해 166 | 리액트 네이티브의 경우 IMP.certification()를 호출하는 대신 167 | iamport-react-native 모듈로 post message를 보낸다 168 | 169 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로 170 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다 171 | */ 172 | if (ua.mobile) return true; 173 | return false; 174 | } 175 | 176 | return ( 177 | ... 178 | 179 | ... 180 | ); 181 | } 182 | ``` 183 | 184 | ### 5-2. 리액트 네이티브에 아임포트 모듈 설치 및 설정하기 185 | 186 | - 아임포트 리액트 네이티브 모듈 설치하기 187 | - 아임포트 리액트 네이티브 모듈 설정하기 188 | 189 | ### 5-3. 리액트 네이티브에서 post message를 받았을때 본인인증 화면 렌더링하기 190 | 191 | 리액트에서 post message를 보내면, WebView의 `onMessage` 함수가 이를 트리거합니다. 메시지 내용 중 액션 유형(`type`)이 `payment`면 본인인증 화면을, `certification`이면 본인인증 화면을 렌더링 하기 위해 해당 라우트로 이동합니다. 이때 **post message로 전달 받은 `가맹점 식별코드`와 `본인인증 데이터`를 `query`로 전달**합니다. 192 | 193 | ```javascript 194 | import React, { useState, useEffect } from 'react'; 195 | import WebView from 'react-native-webview'; 196 | import queryString from 'query-string'; 197 | 198 | function Home({ navigation }) { 199 | function onMessage(e) { 200 | /* 리액트로부터 post message를 받았을때 트리거 된다 */ 201 | try { 202 | /* post message에서 가맹점 식별코드, 본인인증 데이터 그리고 액션 유형을 추출한다 */ 203 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data); 204 | const params = { userCode, data }; 205 | /* 본인인증 화면으로 이동한다 */ 206 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params); 207 | } catch (e) {} 208 | } 209 | 210 | return ( 211 | 221 | ); 222 | } 223 | 224 | export default Home; 225 | ``` 226 | 227 | ### 5-4. 리액트 네이티브에 본인인증 화면 추가하기 228 | 229 | `가맹점 식별코드`와 `본인인증 데이터`를 쿼리에서 추출해 `IMP.Payment`에 prop 형태로 전달합니다. 이때 본인인증 후 실행될 로직을 작성한 콜백 함수도 함께 전달합니다. 콜백함수에서 본인인증 결과에 따라 로직을 다르게 작성할 수 있습니다. 아래는 본인인증 성공시 웹뷰를 띄운 Home으로 돌아가고, 본인인증 실패시 바로 이전 화면으로 돌아가는 예시입니다. 230 | 231 | ```javascript 232 | import React from 'react'; 233 | import IMP from 'iamport-react-native'; 234 | 235 | import Loading from './Loading'; 236 | 237 | function Certification({ navigation }) { 238 | /* 가맹점 식별코드, 본인인증 데이터 추출 */ 239 | const userCode = navigation.getParam('userCode'); 240 | const data = navigation.getParam('data'); 241 | 242 | /* 본인인증 후 실행될 콜백 함수 입력 */ 243 | function callback(response) { 244 | const isSuccessed = getIsSuccessed(response); 245 | if (isSuccessed) { 246 | /* 본인인증 성공한 경우, 리디렉션 위해 홈으로 이동한다 */ 247 | const params = { 248 | ...response, 249 | type: 'certification', // 결제와 본인인증 구분을 위한 필드 250 | }; 251 | navigation.replace('Home', params); 252 | } else { 253 | /* 본인인증 실패한 경우, 이전 화면으로 돌아간다 */ 254 | navigation.goBack(); 255 | } 256 | } 257 | 258 | function getIsSuccessed(response) { 259 | const { success } = response; 260 | 261 | if (typeof success === 'string') return success === 'true'; 262 | if (typeof success === 'boolean') return success === true; 263 | } 264 | 265 | return ( 266 | } 269 | data={{ 270 | ...data, 271 | app_scheme: 'test://', 272 | }} 273 | callback={callback} 274 | /> 275 | ); 276 | } 277 | 278 | export default Certification; 279 | ``` 280 | 281 | ### 5-5. 본인인증 후 리디렉션 설정하기 282 | 283 | 위의 예시에 따라 본인인증 후, 웹뷰를 띄운 Home으로 돌아갔을때 리디렉션을 위한 추가 로직을 작성해야 합니다. 아래와 같은 경우를 가정합니다. 284 | 285 | | 유형 | 도메인 | 286 | | ---------- | ------------------------------------------ | 287 | | 홈 | https://example.com | 288 | | 본인인증 | https://example.com/certification | 289 | | 본인인증 완료 | https://example.com/certification/result | 290 | 291 | 위와 같은 경우, 본인인증 후 홈으로 렌더링 시 웹뷰의 도메인은 다시 `https://example.com`이 됩니다. 이를 `https://example.com/certification/result`로 리디렉션 하기 위해 홈 컴포넌트에 아래와 같은 로직을 작성합니다. 292 | 293 | ```javascript 294 | import React, { useState, useEffect } from 'react'; 295 | import WebView from 'react-native-webview'; 296 | import queryString from 'query-string'; 297 | 298 | const domain = 'https://example.com'; // 가맹점 도메인 299 | function Home({ navigation }) { 300 | const [uri, setUri] = useState(domain); 301 | 302 | useEffect(() => { 303 | /* navigation이 바뀌었을때를 트리거 */ 304 | const response = navigation.getParam('response'); 305 | if (response) { 306 | const query = queryString.stringify(response); 307 | const { type } = query; 308 | if (type === 'certification') { 309 | /* 본인인증 후 렌더링 되었을 경우, https://example.com/certification/result로 리디렉션 시킨다 */ 310 | setUri(`${domain}/certification/result?${query}`); 311 | } 312 | ... 313 | } 314 | }, [navigation]); 315 | 316 | function onMessage(e) { 317 | try { 318 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data); 319 | const params = { userCode, data }; 320 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params); 321 | } catch (e) {} 322 | } 323 | 324 | return ( 325 | 335 | ); 336 | } 337 | 338 | export default Home; 339 | ``` 340 | -------------------------------------------------------------------------------- /manuals/PAYMENT.md: -------------------------------------------------------------------------------- 1 | 2 | # 리액트에서 아임포트 결제 연동하기 3 | 4 | 리액트 환경에서 아임포트 결제 연동을 위한 안내입니다. 5 | 6 | ## 1. 가맹점 식별하기 7 | 8 | `IMP` 객체의 `init` 함수 첫번째 인자에 `가맹점 식별코드`를 추가합니다. 9 | 10 | ```javascript 11 | const { IMP } = window; 12 | IMP.init('imp00000000'); // 'imp00000000' 대신 발급받은 가맹점 식별코드를 사용합니다. 13 | ``` 14 | 15 | 가맹점 식별코드는 아임포트 관리자 페이지 로그인 후, 시스템 설정 > 내정보에서 확인하실 수 있습니다. 16 | 17 | ## 2. 결제 데이터 정의하기 18 | 19 | 결제에 필요한 데이터를 아래와 같이 정의합니다. 이때 정의한 데이터는 `IMP.request_pay` 함수 호출시, 첫번째 인자로 전달됩니다. 결제 데이터에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요. 20 | 21 | ```javascript 22 | const data = { 23 | pg: 'html5_inicis', // PG사 24 | pay_method: 'card', // 결제수단 25 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 26 | amount: 1000, // 결제금액 27 | name: '아임포트 결제 데이터 분석', // 주문명 28 | buyer_name: '홍길동', // 구매자 이름 29 | buyer_tel: '01012341234', // 구매자 전화번호 30 | buyer_email: 'example@example', // 구매자 이메일 31 | buyer_addr: '신사동 661-16', // 구매자 주소 32 | buyer_postcode: '06018', // 구매자 우편번호 33 | ... 34 | }; 35 | ``` 36 | 37 | ## 3. 콜백 함수 정의하기 38 | 39 | 결제 후 실행될 로직을 콜백 함수로 정의합니다. 이때 정의한 함수는 `IMP.request_pay` 함수 호출시, 두번째 인자로 전달됩니다. 콜백 함수의 첫번째 인자로 결제 결과가 객체의 형태로 전달됩니다. 결제 결과에 대한 보다 자세한 내용은 아임포트 공식 문서를 참고하세요. 40 | 41 | ```javascript 42 | function callback(response) { 43 | const { 44 | success, 45 | merchant_uid, 46 | error_msg, 47 | ... 48 | } = response; 49 | 50 | if (success) { 51 | alert('결제 성공'); 52 | } else { 53 | alert(`결제 실패: ${error_msg}`); 54 | } 55 | } 56 | ``` 57 | 58 | ## 4. 결제 창 호출하기 59 | 60 | 결제하기 버튼을 눌렀을때 `IMP` 객체의 `request_pay` 함수를 호출해 결제 창을 호출합니다. `request_pay` 함수의 첫번째 인자로는 2에서 정의한 `결제 데이터`를, 두번째 인자로는 3에서 정의한 `콜백 함수`를 전달합니다. 61 | 62 | ```javascript 63 | import React from 'react'; 64 | 65 | function Payment() { 66 | function onClickPayment() { 67 | /* 1. 가맹점 식별하기 */ 68 | const { IMP } = window; 69 | IMP.init('imp00000000'); 70 | 71 | /* 2. 결제 데이터 정의하기 */ 72 | const data = { 73 | pg: 'html5_inicis', // PG사 74 | pay_method: 'card', // 결제수단 75 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 76 | amount: 1000, // 결제금액 77 | name: '아임포트 결제 데이터 분석', // 주문명 78 | buyer_name: '홍길동', // 구매자 이름 79 | buyer_tel: '01012341234', // 구매자 전화번호 80 | buyer_email: 'example@example', // 구매자 이메일 81 | buyer_addr: '신사동 661-16', // 구매자 주소 82 | buyer_postcode: '06018', // 구매자 우편번호 83 | ... 84 | }; 85 | 86 | /* 4. 결제 창 호출하기 */ 87 | IMP.request_pay(data, callback); 88 | } 89 | 90 | /* 3. 콜백 함수 정의하기 */ 91 | function callback(response) { 92 | const { 93 | success, 94 | merchant_uid, 95 | error_msg, 96 | ... 97 | } = response; 98 | 99 | if (success) { 100 | alert('결제 성공'); 101 | } else { 102 | alert(`결제 실패: ${error_msg}`); 103 | } 104 | } 105 | 106 | return ( 107 | ... 108 | 109 | ... 110 | ); 111 | } 112 | ``` 113 | 114 | ## 5. 리액트 네이티브 환경에 대응하기 115 | 116 | 리액트 네이티브에서 해당 결제 화면을 웹뷰로 띄워 재사용하는 경우가 있습니다. 이 경우 결제하기 버튼을 눌렀을때 결제 환경이 리액트 네이티브인지 판단해, `IMP.request_pay` 함수 호출이 아닌, **리액트 네이티브로 post message를 보내야** 합니다. 리액트 네이티브에 아임포트 리액트 네이티브 모듈을 설치한 후, 리액트로부터 post message를 받으면 해당 결제 화면을 렌더링 하는 로직을 추가해야 합니다. 117 | 118 | ### 5-1. 리액트 네이티브로 post message 보내기 119 | 120 | 결제 하기 버튼을 눌렀을 때 결제 환경을 판단하는 로직을 추가합니다. 결제 환경이 리액트 네이티브인 경우, **리액트 네이티브로 `가맹점 식별코드`, `결제 데이터` 그리고 `액션 유형`을 post message로 보냅니다.** 121 | 122 | ```javascript 123 | import React from 'react'; 124 | 125 | function Payment() { 126 | function onClickPayment() { 127 | const userCode = 'imp00000000'; 128 | 129 | /* 2. 결제 데이터 정의하기 */ 130 | const data = { 131 | pg: 'html5_inicis', // PG사 132 | pay_method: 'card', // 결제수단 133 | merchant_uid: `mid_${new Date().getTime()}` // 주문번호 134 | amount: 1000, // 결제금액 135 | name: '아임포트 결제 데이터 분석', // 주문명 136 | buyer_name: '홍길동', // 구매자 이름 137 | buyer_tel: '01012341234', // 구매자 전화번호 138 | buyer_email: 'example@example', // 구매자 이메일 139 | buyer_addr: '신사동 661-16', // 구매자 주소 140 | buyer_postcode: '06018', // 구매자 우편번호 141 | ... 142 | }; 143 | 144 | if (isReactNative()) { 145 | /* 5. 리액트 네이티브 환경에 대응하기 */ 146 | const params = { 147 | userCode, // 가맹점 식별코드 148 | data, // 결제 데이터 149 | type: 'payment', // 결제와 본인인증 구분을 위한 필드 150 | }; 151 | const paramsToString = JSON.stringify(params); 152 | window.ReactNativeWebView.postMessage(paramsToString); 153 | } else { 154 | /* 1. 가맹점 식별하기 */ 155 | const { IMP } = window; 156 | IMP.init(userCode); 157 | /* 4. 결제 창 호출하기 */ 158 | IMP.request_pay(data, callback); 159 | } 160 | } 161 | 162 | /* 3. 콜백 함수 정의하기 */ 163 | function callback(response) { 164 | const { 165 | success, 166 | merchant_uid, 167 | error_msg, 168 | ... 169 | } = response; 170 | 171 | if (success) { 172 | alert('결제 성공'); 173 | } else { 174 | alert(`결제 실패: ${error_msg}`); 175 | } 176 | } 177 | 178 | function isReactNative() { 179 | /* 180 | 리액트 네이티브 환경인지 여부를 판단해 181 | 리액트 네이티브의 경우 IMP.payment()를 호출하는 대신 182 | iamport-react-native 모듈로 post message를 보낸다 183 | 184 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로 185 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다 186 | */ 187 | if (ua.mobile) return true; 188 | return false; 189 | } 190 | 191 | return ( 192 | ... 193 | 194 | ... 195 | ); 196 | } 197 | ``` 198 | 199 | ### 5-2. 리액트 네이티브에 아임포트 모듈 설치하기 200 | 201 | - 아임포트 리액트 네이티브 모듈 설치하기 202 | - 아임포트 리액트 네이티브 모듈 설정하기 203 | 204 | ### 5-3. 리액트 네이티브에서 post message를 받았을때 결제 화면 렌더링하기 205 | 206 | 리액트에서 post message를 보내면, WebView의 `onMessage` 함수가 이를 트리거합니다. 메시지 내용 중 액션 유형(`type`)이 `payment`면 결제 화면을, `certification`이면 본인인증 화면을 렌더링 하기 위해 해당 라우트로 이동합니다. 이때 **post message로 전달 받은 `가맹점 식별코드`와 `결제 데이터`를 `query`로 전달**합니다. 207 | 208 | ```javascript 209 | import React, { useState, useEffect } from 'react'; 210 | import WebView from 'react-native-webview'; 211 | import queryString from 'query-string'; 212 | 213 | function Home({ navigation }) { 214 | function onMessage(e) { 215 | /* 리액트로부터 post message를 받았을때 트리거 된다 */ 216 | try { 217 | /* post message에서 가맹점 식별코드, 결제 데이터 그리고 액션 유형을 추출한다 */ 218 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data); 219 | const params = { userCode, data }; 220 | /* 결제 화면으로 이동한다 */ 221 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params); 222 | } catch (e) {} 223 | } 224 | 225 | return ( 226 | 236 | ); 237 | } 238 | 239 | export default Home; 240 | ``` 241 | 242 | ### 5-4. 리액트 네이티브에 결제 화면 추가하기 243 | 244 | `가맹점 식별코드`와 `결제 데이터`를 쿼리에서 추출해 `IMP.Payment`에 prop 형태로 전달합니다. 이때 결제 후 실행될 로직을 작성한 콜백 함수도 함께 전달합니다. 콜백함수에서 결제 결과에 따라 로직을 다르게 작성할 수 있습니다. 아래는 결제 성공시 웹뷰를 띄운 Home으로 돌아가고, 결제 실패시 바로 이전 화면으로 돌아가는 예시입니다. 245 | 246 | ```javascript 247 | import React from 'react'; 248 | import IMP from 'iamport-react-native'; 249 | 250 | import Loading from './Loading'; 251 | 252 | function Payment({ navigation }) { 253 | /* 가맹점 식별코드, 결제 데이터 추출 */ 254 | const userCode = navigation.getParam('userCode'); 255 | const data = navigation.getParam('data'); 256 | 257 | /* 결제 후 실행될 콜백 함수 입력 */ 258 | function callback(response) { 259 | const isSuccessed = getIsSuccessed(response); 260 | if (isSuccessed) { 261 | /* 결제 성공한 경우, 리디렉션 위해 홈으로 이동한다 */ 262 | const params = { 263 | ...response, 264 | type: 'payment', // 결제와 본인인증 구분을 위한 필드 265 | }; 266 | navigation.replace('Home', params); 267 | } else { 268 | /* 결제 실패한 경우, 이전 화면으로 돌아간다 */ 269 | navigation.goBack(); 270 | } 271 | } 272 | 273 | function getIsSuccessed(response) { 274 | const { imp_success, success } = response; 275 | 276 | if (typeof imp_success === 'string') return imp_success === 'true'; 277 | if (typeof imp_success === 'boolean') return imp_success === true; 278 | if (typeof success === 'string') return success === 'true'; 279 | if (typeof success === 'boolean') return success === true; 280 | } 281 | 282 | return ( 283 | } 286 | data={{ 287 | ...data, 288 | app_scheme: 'test://', 289 | }} 290 | callback={callback} 291 | /> 292 | ); 293 | } 294 | 295 | export default Payment; 296 | ``` 297 | 298 | ### 5-5. 결제 후 리디렉션 설정하기 299 | 300 | 위의 예시에 따라 결제 후, 웹뷰를 띄운 Home으로 돌아갔을때 리디렉션을 위한 추가 로직을 작성해야 합니다. 아래와 같은 경우를 가정합니다. 301 | 302 | | 유형 | 도메인 | 303 | | ------ | ----------------------------------- | 304 | | 홈 | https://example.com | 305 | | 결제 | https://example.com/payment | 306 | | 결제완료 | https://example.com/payment/result | 307 | 308 | 위와 같은 경우, 결제 후 홈으로 렌더링 시 웹뷰의 도메인은 다시 `https://example.com`이 됩니다. 이를 `https://example.com/payment/result`로 리디렉션 하기 위해 홈 컴포넌트에 아래와 같은 로직을 작성합니다. 309 | 310 | ```javascript 311 | import React, { useState, useEffect } from 'react'; 312 | import WebView from 'react-native-webview'; 313 | import queryString from 'query-string'; 314 | 315 | const domain = 'https://example.com'; // 가맹점 도메인 316 | function Home({ navigation }) { 317 | const [uri, setUri] = useState(domain); 318 | 319 | useEffect(() => { 320 | /* navigation이 바뀌었을때를 트리거 */ 321 | const response = navigation.getParam('response'); 322 | if (response) { 323 | const query = queryString.stringify(response); 324 | const { type } = query; 325 | if (type === 'payment') { 326 | /* 결제 후 렌더링 되었을 경우, https://example.com/payment/result로 리디렉션 시킨다 */ 327 | setUri(`${domain}/payment/result?${query}`); 328 | } 329 | ... 330 | } 331 | }, [navigation]); 332 | 333 | function onMessage(e) { 334 | try { 335 | const { userCode, data, type } = JSON.parse(e.nativeEvent.data); 336 | const params = { userCode, data }; 337 | navigation.push(type === 'payment' ? 'Payment' : 'Certification', params); 338 | } catch (e) {} 339 | } 340 | 341 | return ( 342 | 352 | ); 353 | } 354 | 355 | export default Home; 356 | ``` 357 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "iamport-react-example", 3 | "version": "0.1.3", 4 | "description": "아임포트 리액트 결제 및 휴대폰 본인인증 연동 예제", 5 | "author": "Solee Deedee Choi", 6 | "keywords": ["iamport", "react", "payment", "certification", "webview", "react-native"], 7 | "dependencies": { 8 | "antd": "^3.20.5", 9 | "customize-cra": "^0.4.1", 10 | "less": "^3.9.0", 11 | "less-loader": "^5.0.0", 12 | "query-string": "^6.8.1", 13 | "react": "^16.8.6", 14 | "react-app-rewired": "^2.1.3", 15 | "react-dom": "^16.8.6", 16 | "react-router-dom": "^5.0.1", 17 | "react-scripts": "3.0.1", 18 | "react-useragent": "^1.1.2", 19 | "styled-components": "^4.3.2" 20 | }, 21 | "repository": { 22 | "type": "git", 23 | "url": "https://github.com/SoleeChoi/iamport-react-example" 24 | }, 25 | "scripts": { 26 | "start": "react-app-rewired start", 27 | "build": "react-app-rewired build", 28 | "test": "react-app-rewired test", 29 | "eject": "react-app-rewired eject" 30 | }, 31 | "eslintConfig": { 32 | "extends": "react-app" 33 | }, 34 | "browserslist": { 35 | "production": [ 36 | ">0.2%", 37 | "not dead", 38 | "not op_mini all" 39 | ], 40 | "development": [ 41 | "last 1 chrome version", 42 | "last 1 firefox version", 43 | "last 1 safari version" 44 | ] 45 | }, 46 | "devDependencies": { 47 | "babel-plugin-import": "^1.12.0" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/iamport/iamport-react-example/8fc6d3dc7a32ba69b9fbaf90bd789a0bc858b1cf/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 22 | React App 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": ".", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { BrowserRouter, Route } from 'react-router-dom'; 3 | 4 | import Home from './Home'; 5 | import Payment from './Payment'; 6 | import PaymentResult from './PaymentResult'; 7 | import Certification from './Certification'; 8 | import CertificationResult from './CertificationResult'; 9 | 10 | function App() { 11 | return ( 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | ); 20 | } 21 | 22 | export default App; 23 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/Certification/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Form, Input, Button } from 'antd'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { withUserAgent } from 'react-useragent'; 6 | import queryString from 'query-string'; 7 | 8 | const { Item } = Form; 9 | 10 | function Certification({ history, form, ua }) { 11 | const { getFieldDecorator, validateFieldsAndScroll } = form; 12 | 13 | function handleSubmit(e) { 14 | e.preventDefault(); 15 | 16 | validateFieldsAndScroll((error, values) => { 17 | if (!error) { 18 | /* 가맹점 식별코드 */ 19 | const userCode = 'imp10391932'; 20 | /* 결제 데이터 */ 21 | const { 22 | merchant_uid, 23 | name, 24 | phone, 25 | min_age, 26 | } = values; 27 | 28 | const data = { 29 | merchant_uid, 30 | }; 31 | 32 | if (name) { 33 | data.name = name; 34 | } 35 | if (phone) { 36 | data.phone = phone; 37 | } 38 | if (min_age) { 39 | data.min_age = min_age; 40 | } 41 | 42 | if (isReactNative()) { 43 | /* 리액트 네이티브 환경일때 */ 44 | const params = { 45 | userCode, 46 | data, 47 | type: 'certification', // 결제와 본인인증을 구분하기 위한 필드 48 | }; 49 | const paramsToString = JSON.stringify(params); 50 | window.ReactNativeWebView.postMessage(paramsToString); 51 | } else { 52 | /* 웹 환경일때 */ 53 | const { IMP } = window; 54 | IMP.init(userCode); 55 | IMP.certification(data, callback); 56 | } 57 | } 58 | }); 59 | } 60 | 61 | /* 본인인증 후 콜백함수 */ 62 | function callback(response) { 63 | const query = queryString.stringify(response); 64 | history.push(`/certification/result?${query}`); 65 | } 66 | 67 | function isReactNative() { 68 | /* 69 | 리액트 네이티브 환경인지 여부를 판단해 70 | 리액트 네이티브의 경우 IMP.certification()을 호출하는 대신 71 | iamport-react-native 모듈로 post message를 보낸다 72 | 73 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로 74 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다 75 | */ 76 | if (ua.mobile) return true; 77 | return false; 78 | } 79 | 80 | return ( 81 | 82 |
아임포트 본인인증 테스트
83 | 84 | 85 | {getFieldDecorator('merchant_uid', { 86 | initialValue: `min_${new Date().getTime()}`, 87 | rules: [{ required: true, message: '주문번호는 필수입력입니다' }], 88 | })( 89 | , 90 | )} 91 | 92 | 93 | {getFieldDecorator('name')( 94 | , 95 | )} 96 | 97 | 98 | {getFieldDecorator('phone')( 99 | , 100 | )} 101 | 102 | 103 | {getFieldDecorator('min_age')( 104 | , 110 | )} 111 | 112 | 115 | 116 |
117 | ); 118 | } 119 | 120 | const Wrapper = styled.div` 121 | padding: 7rem 0; 122 | display: flex; 123 | align-items: center; 124 | justify-content: center; 125 | flex-direction: column; 126 | `; 127 | 128 | const Header = styled.div` 129 | font-weight: bold; 130 | text-align: center; 131 | padding: 2rem; 132 | padding-top: 0; 133 | font-size: 3rem; 134 | `; 135 | 136 | const FormContainer = styled(Form)` 137 | width: 350px; 138 | border-radius: 3px; 139 | 140 | .ant-row { 141 | margin-bottom: 1rem; 142 | } 143 | .ant-form-item { 144 | display: flex; 145 | align-items: center; 146 | } 147 | .ant-col.ant-form-item-label > label::after { 148 | display: none; 149 | } 150 | 151 | .ant-form-explain { 152 | margin-top: 0.5rem; 153 | margin-left: 9rem; 154 | } 155 | 156 | .ant-input-group-addon:first-child { 157 | width: 9rem; 158 | text-align: left; 159 | color: #888; 160 | font-size: 1.2rem; 161 | border: none; 162 | background-color: inherit; 163 | } 164 | .ant-input-group > .ant-input:last-child { 165 | border-radius: 4px; 166 | } 167 | 168 | .ant-col { 169 | width: 100%; 170 | } 171 | 172 | button[type='submit'] { 173 | width: 100%; 174 | height: 5rem; 175 | font-size: 1.6rem; 176 | margin-top: 2rem; 177 | } 178 | `; 179 | 180 | const CertificationForm = Form.create({ name: 'certification' })(Certification); 181 | 182 | export default withUserAgent(withRouter(CertificationForm)); 183 | -------------------------------------------------------------------------------- /src/CertificationResult/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Icon, Button } from 'antd'; 4 | import { withRouter } from 'react-router-dom'; 5 | import queryString from 'query-string'; 6 | 7 | function CertificationResult({ history }) { 8 | const { location } = history; 9 | const { search } = location; 10 | const query = queryString.parse(search); 11 | 12 | const { merchant_uid, error_msg, imp_uid } = query; 13 | const isSuccessed = getIsSuccessed(); 14 | function getIsSuccessed() { 15 | const { success, } = query; 16 | if (typeof success === 'string') return success === 'true'; 17 | if (typeof success === 'boolean') return success === true; 18 | } 19 | 20 | const iconType = isSuccessed ? 'check-circle' : 'exclamation-circle'; 21 | const resultType = isSuccessed ? '성공' : '실패'; 22 | const colorType = isSuccessed ? '#52c41a' : '#f5222d'; 23 | return ( 24 | 25 | 26 | 27 |

{`본인인증에 ${resultType}하였습니다`}

28 |
    29 |
  • 30 | 주문번호 31 | {merchant_uid} 32 |
  • 33 | {isSuccessed ? ( 34 |
  • 35 | 아임포트 번호 36 | {imp_uid} 37 |
  • 38 | ) : ( 39 |
  • 40 | 에러 메시지 41 | {error_msg} 42 |
  • 43 | )} 44 |
45 | 49 |
50 |
51 | ); 52 | } 53 | 54 | const Wrapper = styled.div` 55 | position: absolute; 56 | top: 0; 57 | left: 0; 58 | right: 0; 59 | bottom: 0; 60 | `; 61 | 62 | const Container = styled.div` 63 | display: flex; 64 | align-items: center; 65 | justify-content: center; 66 | flex-direction: column; 67 | background-color: #fff; 68 | border-radius: 4px; 69 | position: absolute; 70 | top: 2rem; 71 | left: 2rem; 72 | right: 2rem; 73 | bottom: 2rem; 74 | padding: 2rem; 75 | 76 | > .anticon { 77 | font-size: 10rem; 78 | text-align: center; 79 | margin-bottom: 2rem; 80 | color: ${props => props.colorType}; 81 | } 82 | p { 83 | font-size: 2rem; 84 | font-weight: bold; 85 | margin-bottom: 2rem; 86 | } 87 | 88 | ul { 89 | list-style: none; 90 | padding: 0; 91 | margin-bottom: 3rem; 92 | 93 | li { 94 | display: flex; 95 | line-height: 2; 96 | span:first-child { 97 | width: 8rem; 98 | color: #888; 99 | } 100 | span:last-child { 101 | width: calc(100% - 8rem); 102 | color: #333; 103 | } 104 | } 105 | } 106 | 107 | button, button:hover { 108 | border-color: ${props => props.colorType}; 109 | color: ${props => props.colorType}; 110 | } 111 | button:hover { 112 | opacity: 0.7; 113 | } 114 | `; 115 | 116 | export default withRouter(CertificationResult); -------------------------------------------------------------------------------- /src/Home/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Button, Icon } from 'antd'; 4 | import { withRouter } from 'react-router-dom'; 5 | 6 | function Home({ history }) { 7 | return ( 8 | 9 |
10 |

아임포트 테스트

11 |

아임포트 리액트 테스트 화면입니다.

12 |

아래 버튼을 눌러 결제 또는 본인인증 테스트를 진행해주세요.

13 |
14 |
15 | 16 | 20 | 24 | 25 |
26 | ); 27 | } 28 | 29 | const Wrapper = styled.div` 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | flex-direction: column; 34 | position: absolute; 35 | top: 0; 36 | left: 0; 37 | right: 0; 38 | bottom: 0; 39 | 40 | > div { 41 | position: absolute; 42 | left: 0; 43 | right: 0; 44 | } 45 | > div:first-child { 46 | background-color: #344e81; 47 | top: 0; 48 | bottom: 50%; 49 | display: flex; 50 | align-items: center; 51 | justify-content: center; 52 | flex-direction: column; 53 | > * { 54 | color: #fff; 55 | } 56 | 57 | h4 { 58 | margin: 0; 59 | line-height: 1.5; 60 | } 61 | } 62 | > div:nth-child(2) { 63 | top: 50%; 64 | bottom: 0; 65 | } 66 | `; 67 | 68 | const ButtonContainer = styled.div` 69 | position: absolute; 70 | display: flex; 71 | align-items: center; 72 | justify-content: center; 73 | top: 50%; 74 | margin-top: -5rem; 75 | 76 | button { 77 | display: flex; 78 | align-items: center; 79 | justify-content: center; 80 | flex-direction: column; 81 | height: 10rem; 82 | width: 15rem; 83 | margin: 0 0.5rem; 84 | border: none; 85 | box-shadow: 0 0 1rem 0 rgba(0, 0, 0, 0.13); 86 | .anticon { 87 | margin-bottom: 0.5rem; 88 | font-size: 2rem; 89 | & + span { 90 | margin: 0; 91 | } 92 | } 93 | } 94 | `; 95 | 96 | export default withRouter(Home); 97 | -------------------------------------------------------------------------------- /src/Payment/constants.js: -------------------------------------------------------------------------------- 1 | export const PGS = [ 2 | { 3 | value: 'html5_inicis', 4 | label: '웹 표준 이니시스', 5 | }, 6 | { 7 | value: 'kcp', 8 | label: 'NHN KCP', 9 | }, 10 | { 11 | value: 'kcp_billing', 12 | label: 'NHN KCP 정기결제', 13 | }, 14 | { 15 | value: 'uplus', 16 | label: 'LG 유플러스', 17 | }, 18 | { 19 | value: 'jtnet', 20 | label: 'JTNET', 21 | }, 22 | { 23 | value: 'nice', 24 | label: '나이스 정보통신', 25 | }, 26 | { 27 | value: 'kakaopay', 28 | label: '신 - 카카오페이', 29 | }, 30 | { 31 | value: 'kakao', 32 | label: '구 - LG CNS 카카오페이', 33 | }, 34 | { 35 | value: 'danal', 36 | label: '다날 휴대폰 소액결제', 37 | }, 38 | { 39 | value: 'danal_tpay', 40 | label: '다날 일반결제', 41 | }, 42 | { 43 | value: 'kicc', 44 | label: '한국정보통신', 45 | }, 46 | { 47 | value: 'paypal', 48 | label: '페이팔', 49 | }, 50 | { 51 | value: 'mobilians', 52 | label: '모빌리언스', 53 | }, 54 | { 55 | value: 'payco', 56 | label: '페이코', 57 | }, 58 | { 59 | value: 'settle', 60 | label: '세틀뱅크 가상계좌', 61 | }, 62 | { 63 | value: 'naverco', 64 | label: '네이버 체크아웃', 65 | }, 66 | { 67 | value: 'naverpay', 68 | label: '네이버페이', 69 | }, 70 | { 71 | value: 'smilepay', 72 | label: '스마일페이', 73 | }, 74 | ]; 75 | 76 | export const METHODS = [ 77 | { 78 | value: 'card', 79 | label: '신용카드', 80 | }, 81 | { 82 | value: 'vbank', 83 | label: '가상계좌', 84 | }, 85 | { 86 | value: 'trans', 87 | label: '실시간 계좌이체', 88 | }, 89 | { 90 | value: 'phone', 91 | label: '휴대폰 소액결제' 92 | }, 93 | ]; 94 | 95 | export const METHODS_FOR_INICIS = 96 | METHODS.concat([ 97 | { 98 | value: 'samsung', 99 | label: '삼성페이', 100 | }, 101 | { 102 | value: 'kapy', 103 | label: 'KPAY', 104 | }, 105 | { 106 | value: 'cultureland', 107 | label: '문화상품권', 108 | }, 109 | { 110 | value: 'smartculture', 111 | label: '스마트문상', 112 | }, 113 | { 114 | value: 'happymoney', 115 | label: '해피머니', 116 | }, 117 | ]); 118 | 119 | export const METHODS_FOR_UPLUS = 120 | METHODS.concat([ 121 | { 122 | value: 'cultureland', 123 | label: '문화상품권', 124 | }, 125 | { 126 | value: 'smartculture', 127 | label: '스마트문상', 128 | }, 129 | { 130 | value: 'booknlife', 131 | label: '도서상품권', 132 | }, 133 | ]); 134 | 135 | export const METHODS_FOR_KCP = 136 | METHODS.concat([ 137 | { 138 | value: 'samsung', 139 | label: '삼성페이', 140 | }, 141 | ]); 142 | 143 | export const METHODS_FOR_MOBILIANS = [ 144 | { 145 | value: 'card', 146 | label: '신용카드', 147 | }, 148 | { 149 | value: 'phone', 150 | label: '휴대폰 소액결제', 151 | }, 152 | ]; 153 | 154 | export const METHODS_FOR_DANAL = [ 155 | { 156 | value: 'card', 157 | label: '신용카드', 158 | }, 159 | { 160 | value: 'vbank', 161 | label: '가상계좌', 162 | }, 163 | { 164 | value: 'trans', 165 | label: '실시간 계좌이체', 166 | }, 167 | ]; 168 | 169 | export const METHOD_FOR_CARD = [ 170 | { 171 | value: 'card', 172 | label: '신용카드', 173 | }, 174 | ]; 175 | 176 | export const METHOD_FOR_PHONE = [ 177 | { 178 | value: 'phone', 179 | label: '휴대폰 소액결제', 180 | }, 181 | ]; 182 | 183 | export const METHOD_FOR_VBANK = [ 184 | { 185 | value: 'vbank', 186 | label: '가상계좌', 187 | }, 188 | ]; 189 | 190 | export const QUOTAS = [ 191 | { 192 | value: 0, 193 | label: 'PG사 기본 제공', 194 | }, 195 | { 196 | value: 1, 197 | label: '일시불', 198 | }, 199 | ]; 200 | 201 | export const QUOTAS_FOR_INICIS_AND_KCP = [ 202 | { 203 | value: 0, 204 | label: 'PG사 기본 제공', 205 | }, 206 | { 207 | value: 1, 208 | label: '일시불', 209 | }, 210 | { 211 | value: 2, 212 | label: '2개월', 213 | }, 214 | { 215 | value: 3, 216 | label: '3개월', 217 | }, 218 | { 219 | value: 4, 220 | label: '4개월', 221 | }, 222 | { 223 | value: 5, 224 | label: '5개월', 225 | }, 226 | { 227 | value: 6, 228 | label: '6개월', 229 | }, 230 | ]; 231 | -------------------------------------------------------------------------------- /src/Payment/index.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import styled from 'styled-components'; 3 | import { Form, Select, Icon, Input, Switch, Button } from 'antd'; 4 | import { withRouter } from 'react-router-dom'; 5 | import { withUserAgent } from 'react-useragent'; 6 | import queryString from 'query-string'; 7 | 8 | import { 9 | PGS, 10 | METHODS_FOR_INICIS, 11 | QUOTAS_FOR_INICIS_AND_KCP, 12 | } from './constants'; 13 | import { getMethods, getQuotas } from './utils'; 14 | 15 | const { Item } = Form; 16 | const { Option } = Select; 17 | 18 | function Payment({ history, form, ua }) { 19 | const [methods, setMethods] = useState(METHODS_FOR_INICIS); 20 | const [quotas, setQuotas] = useState(QUOTAS_FOR_INICIS_AND_KCP); 21 | const [isQuotaRequired, setIsQuotaRequired] = useState(true); 22 | const [isDigitalRequired, setIsDigitalRequired] = useState(false); 23 | const [isVbankDueRequired, setIsVbankDueRequired] = useState(false); 24 | const [isBizNumRequired, setisBizNumRequired] = useState(false); 25 | const { getFieldDecorator, validateFieldsAndScroll, setFieldsValue, getFieldsValue } = form; 26 | 27 | function handleSubmit(e) { 28 | e.preventDefault(); 29 | 30 | validateFieldsAndScroll((error, values) => { 31 | if (!error) { 32 | /* 가맹점 식별코드 */ 33 | const userCode = 'imp19424728'; 34 | /* 결제 데이터 */ 35 | const { 36 | pg, 37 | pay_method, 38 | merchant_uid, 39 | name, 40 | amount, 41 | buyer_name, 42 | buyer_tel, 43 | buyer_email, 44 | escrow, 45 | card_quota, 46 | biz_num, 47 | vbank_due, 48 | digital, 49 | } = values; 50 | 51 | const data = { 52 | pg, 53 | pay_method, 54 | merchant_uid, 55 | name, 56 | amount, 57 | buyer_name, 58 | buyer_tel, 59 | buyer_email, 60 | escrow, 61 | }; 62 | 63 | if (pay_method === 'vbank') { 64 | data.vbank_due = vbank_due; 65 | if (pg === 'danal_tpay') { 66 | data.biz_num = biz_num; 67 | } 68 | } 69 | if (pay_method === 'card') { 70 | if (card_quota !== 0) { 71 | data.digital = { card_quota: card_quota === 1 ? [] : card_quota }; 72 | } 73 | } 74 | if (pay_method === 'phone') { 75 | data.digital = digital; 76 | } 77 | 78 | if (isReactNative()) { 79 | /* 리액트 네이티브 환경일때 */ 80 | const params = { 81 | userCode, 82 | data, 83 | type: 'payment', // 결제와 본인인증을 구분하기 위한 필드 84 | }; 85 | const paramsToString = JSON.stringify(params); 86 | window.ReactNativeWebView.postMessage(paramsToString); 87 | } else { 88 | /* 웹 환경일때 */ 89 | const { IMP } = window; 90 | IMP.init(userCode); 91 | IMP.request_pay(data, callback); 92 | } 93 | } 94 | }); 95 | } 96 | 97 | function callback(response) { 98 | const query = queryString.stringify(response); 99 | history.push(`/payment/result?${query}`); 100 | } 101 | 102 | function onChangePg(value) { 103 | /* 결제수단 */ 104 | const methods = getMethods(value); 105 | setMethods(methods); 106 | setFieldsValue({ pay_method: methods[0].value }) 107 | 108 | /* 할부개월수 설정 */ 109 | const { pay_method } = getFieldsValue(); 110 | handleQuotas(value, pay_method); 111 | 112 | /* 사업자번호/입금기한 설정 */ 113 | let isBizNumRequired = false; 114 | let isVbankDueRequired = false; 115 | if (pay_method === 'vbank') { 116 | if (value === 'danal_tpay') { 117 | isBizNumRequired = true; 118 | } 119 | isVbankDueRequired = true; 120 | } 121 | setisBizNumRequired(isBizNumRequired); 122 | setIsVbankDueRequired(isVbankDueRequired); 123 | } 124 | 125 | function onChangePayMethod(value) { 126 | const { pg } = getFieldsValue(); 127 | let isQuotaRequired = false; 128 | let isDigitalRequired = false; 129 | let isVbankDueRequired = false; 130 | let isBizNumRequired = false; 131 | switch (value) { 132 | case 'card': { 133 | isQuotaRequired = true; 134 | break; 135 | } 136 | case 'phone': { 137 | isDigitalRequired = true; 138 | break; 139 | } 140 | case 'vbank': { 141 | if (pg === 'danal_tpay') { 142 | isBizNumRequired = true; 143 | } 144 | isVbankDueRequired = true; 145 | break; 146 | } 147 | default: 148 | break; 149 | } 150 | setIsQuotaRequired(isQuotaRequired); 151 | setIsDigitalRequired(isDigitalRequired); 152 | setIsVbankDueRequired(isVbankDueRequired); 153 | setisBizNumRequired(isBizNumRequired); 154 | 155 | /* 할부개월수 설정 */ 156 | handleQuotas(pg, value); 157 | } 158 | 159 | function handleQuotas(pg, pay_method) { 160 | const { isQuotaRequired, quotas } = getQuotas(pg, pay_method); 161 | setIsQuotaRequired(isQuotaRequired); 162 | setQuotas(quotas); 163 | setFieldsValue({ card_quota: quotas[0].value }) 164 | } 165 | 166 | function isReactNative() { 167 | /* 168 | 리액트 네이티브 환경인지 여부를 판단해 169 | 리액트 네이티브의 경우 IMP.payment()를 호출하는 대신 170 | iamport-react-native 모듈로 post message를 보낸다 171 | 172 | 아래 예시는 모든 모바일 환경을 리액트 네이티브로 인식한 것으로 173 | 실제로는 user agent에 값을 추가해 정확히 판단해야 한다 174 | */ 175 | if (ua.mobile) return true; 176 | return false; 177 | } 178 | 179 | return ( 180 | 181 |
아임포트 결제 테스트
182 | 183 | 184 | {getFieldDecorator('pg', { 185 | initialValue: 'html5_inicis', 186 | })( 187 | 197 | )} 198 | 199 | 200 | {getFieldDecorator('pay_method', { 201 | initialValue: 'card', 202 | })( 203 | 213 | )} 214 | 215 | {isQuotaRequired && ( 216 | 217 | {getFieldDecorator('card_quota', { 218 | initialValue: 0, 219 | })( 220 | 226 | )} 227 | 228 | )} 229 | {isVbankDueRequired && ( 230 | {getFieldDecorator('vbank_due', { 231 | rules: [{ required: true, message: '입금기한은 필수입력입니다' }], 232 | })( 233 | , 234 | )} 235 | )} 236 | {isBizNumRequired && ( 237 | 238 | {getFieldDecorator('biz_num', { 239 | rules: [{ required: true, message: '사업자번호는 필수입력입니다' }], 240 | })( 241 | , 242 | )} 243 | 244 | )} 245 | {isDigitalRequired && ( 246 | 247 | {getFieldDecorator('digital', { 248 | valuePropName: 'checked', 249 | })()} 250 | 251 | )} 252 | 253 | {getFieldDecorator('escrow', { 254 | valuePropName: 'checked', 255 | })()} 256 | 257 | 258 | {getFieldDecorator('name', { 259 | initialValue: '아임포트 결제 데이터 분석', 260 | rules: [{ required: true, message: '주문명은 필수입력입니다' }], 261 | })( 262 | , 263 | )} 264 | 265 | 266 | {getFieldDecorator('amount', { 267 | initialValue: '39000', 268 | rules: [{ required: true, message: '결제금액은 필수입력입니다' }], 269 | })( 270 | , 271 | )} 272 | 273 | 274 | {getFieldDecorator('merchant_uid', { 275 | initialValue: `min_${new Date().getTime()}`, 276 | rules: [{ required: true, message: '주문번호는 필수입력입니다' }], 277 | })( 278 | , 279 | )} 280 | 281 | 282 | {getFieldDecorator('buyer_name', { 283 | initialValue: '홍길동', 284 | rules: [{ required: true, message: '구매자 이름은 필수입력입니다' }], 285 | })( 286 | , 287 | )} 288 | 289 | 290 | {getFieldDecorator('buyer_tel', { 291 | initialValue: '01012341234', 292 | rules: [{ required: true, message: '구매자 전화번호는 필수입력입니다' }], 293 | })( 294 | , 295 | )} 296 | 297 | 298 | {getFieldDecorator('buyer_email', { 299 | initialValue: 'example@example.com', 300 | rules: [{ required: true, message: '구매자 이메일은 필수입력입니다' }], 301 | })( 302 | , 303 | )} 304 | 305 | 308 | 309 |
310 | ); 311 | } 312 | 313 | const Wrapper = styled.div` 314 | padding: 5rem 0; 315 | display: flex; 316 | align-items: center; 317 | justify-content: center; 318 | flex-direction: column; 319 | `; 320 | 321 | const Header = styled.div` 322 | font-weight: bold; 323 | text-align: center; 324 | padding: 2rem; 325 | padding-top: 0; 326 | font-size: 3rem; 327 | `; 328 | 329 | const FormContainer = styled(Form)` 330 | width: 350px; 331 | border-radius: 3px; 332 | 333 | .ant-row { 334 | margin-bottom: 1rem; 335 | } 336 | .ant-form-item { 337 | display: flex; 338 | align-items: center; 339 | } 340 | .ant-col.ant-form-item-label { 341 | padding: 0 11px; 342 | width: 9rem; 343 | text-align: left; 344 | label { 345 | color: #888; 346 | font-size: 1.2rem; 347 | } 348 | & + .ant-col.ant-form-item-control-wrapper { 349 | width: 26rem; 350 | .ant-form-item-control { 351 | line-height: inherit; 352 | } 353 | } 354 | } 355 | .ant-col.ant-form-item-label > label::after { 356 | display: none; 357 | } 358 | .ant-row.ant-form-item.toggle-container .ant-form-item-control { 359 | padding: 0 11px; 360 | height: 4rem; 361 | display: flex; 362 | align-items: center; 363 | .ant-switch { 364 | margin: 0; 365 | } 366 | } 367 | 368 | .ant-form-explain { 369 | margin-top: 0.5rem; 370 | margin-left: 9rem; 371 | } 372 | 373 | .ant-input-group-addon:first-child { 374 | width: 9rem; 375 | text-align: left; 376 | color: #888; 377 | font-size: 1.2rem; 378 | border: none; 379 | background-color: inherit; 380 | } 381 | .ant-input-group > .ant-input:last-child { 382 | border-radius: 4px; 383 | } 384 | 385 | .ant-col { 386 | width: 100%; 387 | } 388 | 389 | button[type='submit'] { 390 | width: 100%; 391 | height: 5rem; 392 | font-size: 1.6rem; 393 | margin-top: 2rem; 394 | } 395 | `; 396 | 397 | const PaymentForm = Form.create({ name: 'payment' })(Payment); 398 | 399 | export default withUserAgent(withRouter(PaymentForm)); 400 | -------------------------------------------------------------------------------- /src/Payment/utils.js: -------------------------------------------------------------------------------- 1 | import { 2 | METHODS, 3 | METHOD_FOR_CARD, 4 | METHOD_FOR_PHONE, 5 | METHOD_FOR_VBANK, 6 | METHODS_FOR_INICIS, 7 | METHODS_FOR_UPLUS, 8 | METHODS_FOR_KCP, 9 | METHODS_FOR_MOBILIANS, 10 | METHODS_FOR_DANAL, 11 | QUOTAS, 12 | QUOTAS_FOR_INICIS_AND_KCP, 13 | } from './constants'; 14 | 15 | export function getMethods(pg) { 16 | switch (pg) { 17 | case 'html5_inicis': return METHODS_FOR_INICIS; 18 | case 'kcp': return METHODS_FOR_KCP; 19 | case 'uplus': return METHODS_FOR_UPLUS; 20 | case 'kcp_billing': 21 | case 'kakaopay': 22 | case 'kakao': 23 | case 'paypal': 24 | case 'smilepay': 25 | return METHOD_FOR_CARD; 26 | case 'danal': 27 | return METHOD_FOR_PHONE; 28 | case 'danal_tpay': 29 | return METHODS_FOR_DANAL; 30 | case 'mobilians': 31 | return METHODS_FOR_MOBILIANS; 32 | case 'settle': 33 | return METHOD_FOR_VBANK; 34 | default: return METHODS; 35 | } 36 | } 37 | 38 | export function getQuotas(pg, method) { 39 | if (method === 'card') { 40 | switch (pg) { 41 | case 'html5_inicis': 42 | case 'kcp': 43 | return { isQuotaRequired: true, quotas: QUOTAS_FOR_INICIS_AND_KCP }; 44 | default: 45 | return { isQuotaRequired: true, quotas: QUOTAS }; 46 | } 47 | } 48 | return { isQuotaRequired: false, quotas: QUOTAS }; 49 | } -------------------------------------------------------------------------------- /src/PaymentResult/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import styled from 'styled-components'; 3 | import { Icon, Button } from 'antd'; 4 | import { withRouter } from 'react-router-dom'; 5 | import queryString from 'query-string'; 6 | 7 | function PaymentResult({ history }) { 8 | const { location } = history; 9 | const { search } = location; 10 | const query = queryString.parse(search); 11 | 12 | const { merchant_uid, error_msg, imp_uid } = query; 13 | const isSuccessed = getIsSuccessed(); 14 | function getIsSuccessed() { 15 | const { success, imp_success } = query; 16 | if (typeof imp_success === 'string') return imp_success === 'true'; 17 | if (typeof imp_success === 'boolean') return imp_success === true; 18 | if (typeof success === 'string') return success === 'true'; 19 | if (typeof success === 'boolean') return success === true; 20 | } 21 | 22 | const iconType = isSuccessed ? 'check-circle' : 'exclamation-circle'; 23 | const resultType = isSuccessed ? '성공' : '실패'; 24 | const colorType = isSuccessed ? '#52c41a' : '#f5222d'; 25 | return ( 26 | 27 | 28 | 29 |

{`결제에 ${resultType}하였습니다`}

30 |
    31 |
  • 32 | 주문번호 33 | {merchant_uid} 34 |
  • 35 | {isSuccessed ? ( 36 |
  • 37 | 아임포트 번호 38 | {imp_uid} 39 |
  • 40 | ) : ( 41 |
  • 42 | 에러 메시지 43 | {error_msg} 44 |
  • 45 | )} 46 |
47 | 51 |
52 |
53 | ); 54 | } 55 | 56 | const Wrapper = styled.div` 57 | position: absolute; 58 | top: 0; 59 | left: 0; 60 | right: 0; 61 | bottom: 0; 62 | `; 63 | 64 | const Container = styled.div` 65 | display: flex; 66 | align-items: center; 67 | justify-content: center; 68 | flex-direction: column; 69 | background-color: #fff; 70 | border-radius: 4px; 71 | position: absolute; 72 | top: 2rem; 73 | left: 2rem; 74 | right: 2rem; 75 | bottom: 2rem; 76 | padding: 2rem; 77 | 78 | > .anticon { 79 | font-size: 10rem; 80 | text-align: center; 81 | margin-bottom: 2rem; 82 | color: ${props => props.colorType}; 83 | } 84 | p { 85 | font-size: 2rem; 86 | font-weight: bold; 87 | margin-bottom: 2rem; 88 | } 89 | 90 | ul { 91 | list-style: none; 92 | padding: 0; 93 | margin-bottom: 3rem; 94 | 95 | li { 96 | display: flex; 97 | line-height: 2; 98 | span:first-child { 99 | width: 8rem; 100 | color: #888; 101 | } 102 | span:last-child { 103 | width: calc(100% - 8rem); 104 | color: #333; 105 | } 106 | } 107 | } 108 | 109 | button, button:hover { 110 | border-color: ${props => props.colorType}; 111 | color: ${props => props.colorType}; 112 | } 113 | button:hover { 114 | opacity: 0.7; 115 | } 116 | `; 117 | 118 | export default withRouter(PaymentResult); -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | font-size: 10px; 3 | } 4 | 5 | body { 6 | margin: 0; 7 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", 8 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", 9 | sans-serif; 10 | -webkit-font-smoothing: antialiased; 11 | -moz-osx-font-smoothing: grayscale; 12 | } 13 | 14 | code { 15 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 16 | monospace; 17 | } 18 | 19 | input[type=number]::-webkit-inner-spin-button, 20 | input[type=number]::-webkit-outer-spin-button { 21 | -webkit-appearance: none; 22 | margin: 0; 23 | } 24 | 25 | #root { 26 | background-color: #f5f5f5; 27 | min-height: 100%; 28 | } -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | import * as serviceWorker from './serviceWorker'; 6 | 7 | ReactDOM.render(, document.getElementById('root')); 8 | 9 | // If you want your app to work offline and load faster, you can change 10 | // unregister() to register() below. Note this comes with some pitfalls. 11 | // Learn more about service workers: https://bit.ly/CRA-PWA 12 | serviceWorker.unregister(); 13 | -------------------------------------------------------------------------------- /src/serviceWorker.js: -------------------------------------------------------------------------------- 1 | // This optional code is used to register a service worker. 2 | // register() is not called by default. 3 | 4 | // This lets the app load faster on subsequent visits in production, and gives 5 | // it offline capabilities. However, it also means that developers (and users) 6 | // will only see deployed updates on subsequent visits to a page, after all the 7 | // existing tabs open on the page have been closed, since previously cached 8 | // resources are updated in the background. 9 | 10 | // To learn more about the benefits of this model and instructions on how to 11 | // opt-in, read https://bit.ly/CRA-PWA 12 | 13 | const isLocalhost = Boolean( 14 | window.location.hostname === 'localhost' || 15 | // [::1] is the IPv6 localhost address. 16 | window.location.hostname === '[::1]' || 17 | // 127.0.0.1/8 is considered localhost for IPv4. 18 | window.location.hostname.match( 19 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 20 | ) 21 | ); 22 | 23 | export function register(config) { 24 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 25 | // The URL constructor is available in all browsers that support SW. 26 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href); 27 | if (publicUrl.origin !== window.location.origin) { 28 | // Our service worker won't work if PUBLIC_URL is on a different origin 29 | // from what our page is served on. This might happen if a CDN is used to 30 | // serve assets; see https://github.com/facebook/create-react-app/issues/2374 31 | return; 32 | } 33 | 34 | window.addEventListener('load', () => { 35 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 36 | 37 | if (isLocalhost) { 38 | // This is running on localhost. Let's check if a service worker still exists or not. 39 | checkValidServiceWorker(swUrl, config); 40 | 41 | // Add some additional logging to localhost, pointing developers to the 42 | // service worker/PWA documentation. 43 | navigator.serviceWorker.ready.then(() => { 44 | console.log( 45 | 'This web app is being served cache-first by a service ' + 46 | 'worker. To learn more, visit https://bit.ly/CRA-PWA' 47 | ); 48 | }); 49 | } else { 50 | // Is not localhost. Just register service worker 51 | registerValidSW(swUrl, config); 52 | } 53 | }); 54 | } 55 | } 56 | 57 | function registerValidSW(swUrl, config) { 58 | navigator.serviceWorker 59 | .register(swUrl) 60 | .then(registration => { 61 | registration.onupdatefound = () => { 62 | const installingWorker = registration.installing; 63 | if (installingWorker == null) { 64 | return; 65 | } 66 | installingWorker.onstatechange = () => { 67 | if (installingWorker.state === 'installed') { 68 | if (navigator.serviceWorker.controller) { 69 | // At this point, the updated precached content has been fetched, 70 | // but the previous service worker will still serve the older 71 | // content until all client tabs are closed. 72 | console.log( 73 | 'New content is available and will be used when all ' + 74 | 'tabs for this page are closed. See https://bit.ly/CRA-PWA.' 75 | ); 76 | 77 | // Execute callback 78 | if (config && config.onUpdate) { 79 | config.onUpdate(registration); 80 | } 81 | } else { 82 | // At this point, everything has been precached. 83 | // It's the perfect time to display a 84 | // "Content is cached for offline use." message. 85 | console.log('Content is cached for offline use.'); 86 | 87 | // Execute callback 88 | if (config && config.onSuccess) { 89 | config.onSuccess(registration); 90 | } 91 | } 92 | } 93 | }; 94 | }; 95 | }) 96 | .catch(error => { 97 | console.error('Error during service worker registration:', error); 98 | }); 99 | } 100 | 101 | function checkValidServiceWorker(swUrl, config) { 102 | // Check if the service worker can be found. If it can't reload the page. 103 | fetch(swUrl) 104 | .then(response => { 105 | // Ensure service worker exists, and that we really are getting a JS file. 106 | const contentType = response.headers.get('content-type'); 107 | if ( 108 | response.status === 404 || 109 | (contentType != null && contentType.indexOf('javascript') === -1) 110 | ) { 111 | // No service worker found. Probably a different app. Reload the page. 112 | navigator.serviceWorker.ready.then(registration => { 113 | registration.unregister().then(() => { 114 | window.location.reload(); 115 | }); 116 | }); 117 | } else { 118 | // Service worker found. Proceed as normal. 119 | registerValidSW(swUrl, config); 120 | } 121 | }) 122 | .catch(() => { 123 | console.log( 124 | 'No internet connection found. App is running in offline mode.' 125 | ); 126 | }); 127 | } 128 | 129 | export function unregister() { 130 | if ('serviceWorker' in navigator) { 131 | navigator.serviceWorker.ready.then(registration => { 132 | registration.unregister(); 133 | }); 134 | } 135 | } 136 | --------------------------------------------------------------------------------