728x90
반응형

아임포트 웹훅-

https://docs.iamport.kr/tech/webhook

 

[가이드] Webhook

아임포트 Webhook 이 문서는 아임포트 webhook을 사용하여 아임포트 서버에 저장된 결제 정보를 가맹점 서버에 동기화하고 네트워크 불안정성을 보완하는 방법을 설명합니다. Webhook이란? Webhook(웹훅

docs.iamport.kr


아임포트에서 제공하는 웹훅으로 결제가 완료된 후 다음 결제를 예약할 수 있다.

데이터베이스에 저장된 구독 남은 개월 수를 측정해서 카운팅한 후

남은 개월 수가 있을 경우 결제완료 후 다음 달로 결제 예약을 거는 시스템을 구현해보도록 하자.

 

1. 아임포트 관리자 콘솔에서 웹훅에 대한 설정을 해준다.

웹훅 발송 공통 URL을 내가 백엔드에서 요청받을 라우터로 지정해주면 결제가 완료될 때 원하는 행동을 할 수 있다.

 

2. 백엔드를 만들고 위에 이미지의 호출 테스트를 눌러본다.

router.post("/repayments", async function (req, res, next) {
    const body = req.body
    console.log("body : ", body)
    try {
        res.send({ success: true })
    }
    catch (Err) {
        console.log("err : ", Err)
        res.send({ success: false })
    }
});

 

- 호출테스트 눌렀을 때 정상적인 경우

 

3. 라우터에 로직을 만든다. 

결제가 잘되거나 예약결제가 시도되었을 땐 paid, 예약결제가 실패했을 땐 failed 결과값이 온다.

 

  1. 구독 테이블을 조회해서 남은 개월 수를 측정한다.
  2. 남은 개월 수가 0보다 클 경우 토큰을 발급받고 imp_uid 또는 merchant_uid로 결제 정보를 조회하고 잘 되었는지 확인한다.
  3.  예약결제가 정상적으로 이루어진 상태면 카운팅을 해주고 merchant_uid를 새로운 예약결제에 맞게 수정하며 다음 달에 예약을 걸고 쿠키를 수정한다.( 로그인 정보에 담아줘서 나중에 화면에 달리보이게 하기 위함)
  4. 만약 잔액 부족 등으로 결제가 안 된 상태면 로그인정보를 수정해서 구독 시 사용 가능한 서비스를 불가능하게 하고 다음날을 예약일로 요청한다. (구독에 관한 데이터는 남아있음).
  5. 남은 개월 수가 0일 경우 예약을 걸지 않고 구독해지를 해준다 로그인 정보를 바꾸고 해당 컬럼을 삭제한다.
router.post("/repayments", async function (req, res, next) {
    const body = req.body;
    const { imp_uid, merchant_uid } = req.body;
    try {
        // 1. 구독 테이블을 조회해서 남은 개월 수를 측정한다.
        Subscribe.findOne({
            where: {
                subscribe_merchant_uid: merchant_uid
            }
        }).then(async (result) => {
            if (result) {
                // 2. 남은 개월 수가 0보다 클 경우 토큰을 발급받고 imp_uid 또는 merchant_uid로 결제 정보를 조회하고 잘 되었는지 확인한다.
                if (result.subscribe_count > 0) {
                    // 액세스 토큰(access token) 발급 받기
                    const getToken = await axios({
                        url: "https://api.iamport.kr/users/getToken",
                        method: "post", // POST method
                        headers: { "Content-Type": "application/json" }, // "Content-Type": "application/json"
                        data: {
                            imp_key: process.env.IMP_API_KEY, // REST API 키
                            imp_secret: process.env.IMP_API_SECRET_KEY // REST API Secret
                        }
                    });
                    const { access_token } = getToken.data.response; // 인증 토큰

                    // imp_uid 또는 merchant_uid로 아임포트 서버에서 결제 정보 조회
                    const paymentResult = await axios({
                        url: 'https://api.iamport.kr/payments/' + imp_uid,
                        method: "get",
                        headers: { "Authorization": access_token }, // 인증 토큰을 Authorization header에 추가
                        data: {
                            imp_uid: imp_uid
                        }
                    });

                    console.log("repayments result : ", paymentResult)

                    var merchant_uid_date = moment().format('YYYY-MM-DD_HH:mm:ss');

                    if (paymentResult.data.response.status === 'paid') {
                        // 3. 예약결제가 정상적으로 이루어진 상태면 카운팅을 해주고 merchant_uid를 새로운 예약결제에 맞게 수정하며 다음 달에 예약을 걸고 로그인정보를 수정한다.( 로그인 정보에 담아줘서 나중에 화면에 달리보이게 하기 위함) 
                        // 카운팅, merchant_uid 수정

                        Subscribe.update(
                            {
                                subscribe_count: result.subscribe_count - 1,
                                subscribe_merchant_uid: "subscribe_" + merchant_uid_date,
                                subscribe_type: "기본정기결제예약"
                            }, {
                            where: {
                                subscribe_merchant_uid: merchant_uid
                            }
                        }).then(async (updateResult) => {
                            // 로그인 정보를 수정해줌 ( 잔액부족 등으로 결제 실패 후 다시 결제 했을 때 서비스 이용 가능하게 함)
                            MUserInfo.update({
                                muser_subscribe_type: result.subscribe_type
                            }, {
                                where: {
                                    muser_id: result.subscribe_muser_id
                                }
                            })

                            // 새로운 결제 예약
                            var today = new Date();
                            var schedule_at_time = new Date(today.getFullYear(), today.getMonth() + 1, today.getDate());
                            await axios({
                                url: "https://api.iamport.kr/subscribe/payments/schedule", // 예: https://api.iamport.kr/subscribe/payments/schedule
                                method: "post",
                                headers: { "Authorization": access_token }, // 인증 토큰 Authorization header에 추가
                                data: {
                                    customer_uid: result.subscribe_customer_uid, // 카드(빌링키)와 1:1로 대응하는 값
                                    schedules: [
                                        {
                                            merchant_uid: "subscribe_" + merchant_uid_date, // 주문 번호
                                            schedule_at: Math.floor(schedule_at_time.getTime() / 1000), // 결제 시도 시각 in Unix Time Stamp. 예: 다음 달 1일
                                            amount: result.subscribe_amount,
                                            name: "기본정기결제예약",
                                        }
                                    ]
                                }
                            });
                        })
                    }
                    else {
                        // 4. 만약 잔액 부족 등으로 결제가 안 된 상태면 로그인정보를 수정해서 구독 시 사용 가능한 서비스를 불가능하게 하고 다음날을 예약일로 요청한다. (구독에 관한 데이터는 남아있음).
                        Subscribe.update(
                            {
                                subscribe_merchant_uid: "subscribe_" + merchant_uid_date,
                                subscribe_type: "기본정기결제예약"
                            }, {
                            where: {
                                subscribe_merchant_uid: merchant_uid
                            }
                        }).then((updateResult) => {
                            // 로그인 정보를 수정해줌 ( 구독 서비스를 이용하지 못하게 함. )
                            MUserInfo.update({
                                muser_subscribe_type: ""
                            }, {
                                where: {
                                    muser_id: result.subscribe_muser_id
                                }
                            })

                            // 새로운 결제 예약
                            var today = new Date();
                            var schedule_at_time = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
                            axios({
                                url: "https://api.iamport.kr/subscribe/payments/schedule", // 예: https://api.iamport.kr/subscribe/payments/schedule
                                method: "post",
                                headers: { "Authorization": access_token }, // 인증 토큰 Authorization header에 추가
                                data: {
                                    customer_uid: result.subscribe_customer_uid, // 카드(빌링키)와 1:1로 대응하는 값
                                    schedules: [
                                        {
                                            merchant_uid: "subscribe_" + merchant_uid_date, // 주문 번호
                                            schedule_at: Math.floor(schedule_at_time.getTime() / 1000), // 결제 시도 시각 in Unix Time Stamp. 예: 다음 달 1일
                                            amount: result.subscribe_amount,
                                            name: "기본정기결제예약",
                                        }
                                    ]
                                }
                            });
                        })
                    }
                }
                // 5. 남은 개월 수가 0일 경우 예약을 걸지 않고 구독해지를 해준다 로그인 정보를 바꾸고 해당 컬럼을 삭제한다.
                else {
                    MUserInfo.update({
                        muser_subscribe_type: ""
                    }, {
                        where: {
                            muser_id: result.subscribe_muser_id
                        }
                    })
                }
            }
            else {
            }
        })

        res.send({ success: true })
    }
    catch (Err) {
        console.log("err : ", Err)
        res.send({ success: false })
    }
});
 
728x90
반응형
728x90
반응형

빌링키 발급에 성공했을 경우 아임포트가 보안상의 이유로 빌링키에 직접 접근할 수 없기 때문에 customer_uid를 이용해

   ( https://api.iamport.kr/subscribe/payments/again ) REST API 를 호출해 빌링키로 결제를 요청하도록 한다.

 

1. 빌링키 발급이 성공했을 경우에 axios로 백엔드에 직접 만든 라우터로 경로를 설정해준다.

(구독 신청이 성공할 경우 로그아웃해서 쿠키 정보를 갱신해준다.)

참고로 빌링키와 결제요청은 merchant_uid를 다르게 해야함

    const subscribeItemClickHandler = (e: any) => {
        var IMP = window.IMP;
        IMP.init(process.env.NEXT_PUBLIC_IMP_CODE); // iamport 가맹점 식별코드
        IMP.request_pay({
            pg: 'kcp_billing',
            pay_method: 'card', // 'card'만 지원됩니다.
            merchant_uid: "order_monthly_0001", // 상점에서 관리하는 주문 번호 (날짜를 붙이면 중복되지않아 좋습니다.)
            name: '최초인증결제',
            amount: 500, // 결제창에 표시될 금액. 실제 승인이 이뤄지지는 않습니다. (PC에서는 가격이 표시되지 않음)
            customer_uid: 'your-customer-unique-id', // 필수 입력.
            buyer_email: 'iamport@siot.do',
            buyer_name: '아임포트',
            buyer_tel: '02-1234-1234',
            m_redirect_url: '{모바일에서 결제 완료 후 리디렉션 될 URL}' // 예: https://www.my-service.com/payments/complete/mobile
        }, function (rsp: any) {
            if (rsp.success) {
                axios.post('/api/subscribe/payments',{
                    muser_id: loginData.userId, // 로그인 아이디
                    radioItem: radioItem, // 구독 개월 수 라디오 버튼
                    merchant_uid: "위에 적은 merchant_uid",
                    customer_uid : "위에 적은 customer_uid",
                    name: "위에 적은 name",
                    amount: "위에 적은 amount"
                }).then(({ data }) => {
                    if (data.success) {
                        // 성공할 경우
                        axios.get('/api/auth/logout')
                            .then(({ data }) => {
                                if (data.success) {
                                    alert('구독신청이 완료되었습니다. 다시 로그인해주세요.')
                                    router.push('/');
                                }
                            })
                    }
                    else {
                        // 실패할 경우
                        alert('내용을 다시확인해주세요.')
                    }
                })
            } else {
                alert('내용을 다시확인해주세요.')
                console.log(rsp.error_msg);
            }
        });
    }

 

2. 백엔드에 클라이언트가 요청할 라우터를 만들어준다. 인증 토큰을 받고 결제 요청을 해야한다.

router.post("/payments", async function (req, res, next) {
    const body = req.body;
    console.log("body : ", body)
    try {
        // 인증 토큰 발급 받기
        const getToken = await axios({
            url: "https://api.iamport.kr/users/getToken",
            method: "post", // POST method
            headers: { "Content-Type": "application/json" }, // "Content-Type": "application/json"
            data: {
                imp_key: process.env.IMP_API_KEY, // REST API 키
                imp_secret: process.env.IMP_API_SECRET_KEY // REST API Secret
            }
        });
        const { access_token } = getToken.data.response; // 인증 토큰

        // 결제(재결제) 요청
        const paymentResult = await axios({
            url: 'https://api.iamport.kr/subscribe/payments/again',
            method: "post",
            headers: { "Authorization": access_token }, // 인증 토큰을 Authorization header에 추가
            data: {
                customer_uid: body.customer_uid,
                merchant_uid: body.merchant_uid, // 새로 생성한 결제(재결제)용 주문 번호
                amount: body.amount,
                name: body.name
            }
        });
        const { code, message } = paymentResult.data;

        if (code === 0) { // 카드사 통신에 성공(실제 승인 성공 여부는 추가 판단이 필요함)
            if (paymentResult.data.response.status === "paid") { //카드 정상 승인
                res.send({ success: true });
            } else { //카드 승인 실패 (예: 고객 카드 한도초과, 거래정지카드, 잔액부족 등)
                //paymentResult.status : failed 로 수신됨
                res.send({ success: false });
            }
        } else { // 카드사 요청에 실패 (paymentResult is null)
            res.send({ success: false });
        }
    }
    catch (Err) {
        console.log("err : ", Err)
        res.send({ success: false })
    }
});

 

3. 구독에 대한 DB를 사용하는 서비스에 따라 설계하고 결제 요청 성공 시에 추가해주도록 한다.

( 저는 구독 남은 개월 수를 표시하고 나중에 아임포트 웹훅으로 카운팅 해주도록 설계했습니다. )

( 또한 쿠키를 삭제하고 로그인 유저의 구독에 관한 데이터를 변경하여 로그아웃 시킨 다음 로그인 했을 시에 서비스를 이용 가능하게 했습니다. )

        if (code === 0) { // 카드사 통신에 성공(실제 승인 성공 여부는 추가 판단이 필요함)
            if (paymentResult.data.response.status === "paid") { //카드 정상 승인
                Subscribe.create({
                    subscribe_muser_id: body.muser_id,
                    subscribe_type: body.radioItem + "개월 구독",
                    subscribe_customer_uid: body.customer_uid,
                    subscribe_merchant_uid: body.merchant_uid,
                    subscribe_count: body.radioItem,
                    subscribe_amount: body.amount,
                    subscribe_createdAt: new Date(),
                    subscribe_updatedAt: new Date()
                })
                // 사옹자의 구독에 관한 데이터를 가진 컬럼을 수정해준다. (로그아웃하고 로그인하면 쿠키에 등록되어 서비스 이용 가능)
                MUserInfo.update({
                    muser_subscribe_type: body.name
                }, {
                    where: {
                        muser_id: body.muser_id
                    }
                })
                res.clearCookie('사용했던 쿠키');
                res.send({ success: true });
            } else { //카드 승인 실패 (예: 고객 카드 한도초과, 거래정지카드, 잔액부족 등)
                //paymentResult.status : failed 로 수신됨
                res.send({ success: false });
            }
        } else { // 카드사 요청에 실패 (paymentResult is null)
            res.send({ success: false });
        }

 

혹시나 금액을 500 이하로 설정했을 경우 '[8105] 포맷에러(지불|신용카드|금액)'  이 에러구문을 볼 수 있다. 금액을 500원 이상으로 설정해주자.

728x90
반응형
728x90
반응형

일반 결제는 한 번의 결제로 끝나지만 정기 결제같은 경우는 한 번의 결제로 빌링키를 받아와 다음 결제일을 예약하는 방법으로 해야한다.

  1. 예약 결제를 여러번 걸어둔다.
  2. 아임포트 웹훅을 이용해 다음 결제를 예약한다.

사실상 두가지 방법 모두 가능하다. 다만 첫 번째 방법은 반복문으로 구현하기 때문에 어렵지 않지만 추가해준 수 만큼 나중에 정기 결제를 취소할 경우 여러번 취소를 해주어야 한다.

 

그래서 나는 아임포트 웹훅을 사용한 정기결제를 이용해보기로 했다.

 

먼저 당연한 말이지만 아임포트에 회원가입을 하고 관리자 콘솔로 들어가 가맹점 식별코드, api key, api secret key 등 정보를 메모해두고 pg사를 원하는 곳으로 설정해준다.

 

1. _app.tsx 에 Head를 추가해주고 iamport 관련 script를 추가해준다.

_app.tsx

import "../styles/globals.css";
import Theme from "../styles/Theme"
import type { AppProps } from "next/app";
import { NextPage } from "next";
import React from "react";
import Head from "next/head";

const MyApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) => {
  return (
    <React.StrictMode>
      <Head>
        <script
          type="text/javascript"
          src="https://code.jquery.com/jquery-1.12.4.min.js">
        </script>
        <script
          type="text/javascript"
          src="https://cdn.iamport.kr/js/iamport.payment-1.2.0.js">
        </script>
      </Head >
      <Component {...pageProps} />
    </React.StrictMode>
  );
};
export default MyApp;

 

2. 결제를 하고자 하는 컴포넌트에 다음과같이 window.IMP가 가능하게끔 해준다.

import { useEffect, useReducer, useState } from "react";
declare const window: typeof globalThis & {
    IMP: any;
};

 

3. 구독하기 버튼을 구현해준다. ( 디자인은 무시하셔도 됩니다. )

                <WrapperDiv ju={`center`} margin={`10vh 0 0 0`}>
                    <Button
                        width={`80%`}
                        kindOf={`login`}
                        onClick={subscribeItemClickHandler}
                    >
                        구독하기
                    </Button>
                </WrapperDiv>

 

4. 버튼 클릭 이벤트 함수를 만들어준다.

    const subscribeItemClickHandler = (e: any) => {
        var IMP = window.IMP;
        IMP.init(process.env.NEXT_PUBLIC_IMP_CODE); // iamport 가맹점 식별코드
        IMP.request_pay({
            pg: 'kcp_billing',
            pay_method: 'card', // 'card'만 지원됩니다.
            merchant_uid: "order_monthly_0001", // 상점에서 관리하는 주문 번호
            name: '최초인증결제',
            amount: 500, // 결제창에 표시될 금액. 실제 승인이 이뤄지지는 않습니다. (PC에서는 가격이 표시되지 않음)
            customer_uid: 'your-customer-unique-id', // 필수 입력.
            buyer_email: 'iamport@siot.do',
            buyer_name: '아임포트',
            buyer_tel: '02-1234-1234',
            m_redirect_url: '{모바일에서 결제 완료 후 리디렉션 될 URL}' // 예: https://www.my-service.com/payments/complete/mobile
        }, function (rsp: any) {
            if (rsp.success) {
                alert('빌링키 발급 성공');
            } else {
                alert('빌링키 발급 실패');
            }
        });
    }

 

이제 버튼을 클릭하면 다음과 같은 화면을 볼 수 있다. ( 웹에서 모바일화면으로 볼 경우 )

728x90
반응형
728x90
반응형

1. Date 객체를 이용한 타임스탬프로의 전환

// 타임스탬프로 변환
function Unix_timestampConv()
{
    return Math.floor(new Date().getTime() / 1000);
}

 

2. 타임스탬프 값을 년월일로 변환

function Unix_timestamp(t){
    var date = new Date(t*1000);
    var year = date.getFullYear();
    var month = "0" + (date.getMonth()+1);
    var day = "0" + date.getDate();
    var hour = "0" + date.getHours();
    var minute = "0" + date.getMinutes();
    var second = "0" + date.getSeconds();
    return year + "-" + month.substr(-2) + "-" + day.substr(-2) + " " + hour.substr(-2) + ":" + minute.substr(-2) + ":" + second.substr(-2);
}

 

타임스탬프란 UTC 로부터 시간을 초 단위로 환산하여 나타낸 값이다. 

다시 Date 객체로 바꿔야 할 경우 반대로 1000을 곱 해주면 된다.

728x90
반응형
728x90
반응형

DB에서 데이터를 가져와 fileState 에 담아주었다면, fileState 의 file_location에 S3 파일 저장위치가 담겨있을 것이다.

( fileReducer 참고)

 

리스트 항목을 클릭했을 때 file_location이 있는 상태면 다운로드를 할 수 있게끔 구현해보자.

1. 이전 코드에 추가해준다.

<WrapperDiv
    kindOf={`input`}
>
    <TextField
        label={`사업자등록증`}
        type='file'
        color='primary'
        size='small'
        InputLabelProps={{
            shrink: true,
        }}
        name={`file_object`}
        onChange={(e: any) => {
            let reader = new FileReader();
            let file = e.target.files[0];
            reader.onloadend = () => {
                fileDispatch({ name: e.target.name, value: file });
                fileDispatch({ name: 'file_url', value: reader.result });
            };
            reader.readAsDataURL(file);
        }}
    />
</WrapperDiv>
{fileState.file_url !== '' &&
    <WrapperDiv>
        <img style={{ width: "100%", height: "100%" }} src={fileState.file_url} />
    </WrapperDiv>
}
{(!fileState.file_url && fileState.file_location !== '') &&
    <GridBoxDiv
        gtc={`1fr 1fr`}
        gap={`10px`}
        ai={`center`}
        kindOf={`input`}
    >
        <TextField
            label={`사업자등록증 파일`}
            type='text'
            color='primary'
            size='small'
            InputLabelProps={{
                shrink: true,
            }}
            value={'image.png'}
            disabled={true}
        />
        <Button
            width={`100%`}
            height={`35px`}
            padding={`0`}
            margin={`0`}
            type={`button`}
            kindOf={`greyButton`}
            hoverGrey={true}
            id={fileState.file_location}
            onClick={download}
        >
            download
        </Button>

    </GridBoxDiv>
}

 

리스트 항목을 클릭했을 때 file_url은 없는 상태(undefined)일 것이고 ( DB에 없는 내용이기 때문 ) file_location은 s3 저장위치를

나타낼 것이다.

 

2. 다운로드 버튼을 만들었고 이제 버튼 이벤트를 추가해준다.

    const download = (e: any) => {
        console.log(e.target.id);
        fetch(e.target.id, {
            method: "GET",
            headers: {}
        })
            .then(response => {
                response.arrayBuffer().then(function (buffer) {
                    const url = window.URL.createObjectURL(new Blob([buffer]));
                    const link = document.createElement("a");
                    link.href = url;
                    link.setAttribute("download", "image.png"); //or any other extension
                    document.body.appendChild(link);
                    link.click();
                });
            })
            .catch(err => {
                console.log(err);
            });
    }

 

url 데이터가 있을 때 클릭하면 다운로드를 할 수 있게 해주는 함수이다. 파일의 이름도 지정해줄 수 있다.

여기까지 내용을 정리해보면

 

  1. input type file 을 이용해 클라이언트 State 안에 file 내용을 저장하고, formData 로 만들어 백엔드에 전송한다.
  2. 백엔드에선 multer 로 file을 인식한 뒤 s3.upload 메소드로 파일을 저장해준다. 저장이 성공하면 DB에 key값이랑 location을 저장한다 (file은 인코딩 해아함 )
  3. 클라이언트에서 이미 등록된 사진을 확인할 경우 먼저 DB에 해당 key에 부합하는 데이터가 있는지 확인 후 있으면 location을 가져온다.
  4. location을 Reducer에서 s3 파일 위치로 만들어 state에 담아준다.
  5. url download 함수와 버튼을 만들어 다운로드 가능하게끔 만들어준다.

 

@@ 혹시 큰 용량의 파일이 업로드가 413에러가 뜰 때 nginx가 설치되어있다면 /etc/nginx/nginx.conf 파일에 

http {
    client_max_body_size 5M;

    ...
}

이 구문을 추가해주자.

728x90
반응형
728x90
반응형

전 포스트에서 업로드 하는 것까지 구현해보았다.

이번 포스트에서는 클라이언트에서 파일을 조회하는 것을 구현해보겠다.

 

1. 먼저 리스트 항목 등을 클릭했을 경우의 이벤트 함수를 구현해준다. 파라미터는 원하는 값으로 하면 된다.

// 이미 등록된 파일이 있는지 확인
axios.get(`/api/file`, {
    params: {
        file_customer_code: customerKey
    }
}).then(({ data }) => {
    if (data.success) {
        // 파일이 존재할 경우 
    }
})

 

2. 백엔드에서 데이터베이스를 조회하고 있으면 결과값을 반환해주는 라우터를 작성한다.

router.get('/', function (req, res, next) {
    const query = req.query;
    const customer_code = query.file_customer_code

    try {
        File.findOne(
            {
                where: {
                    file_customer_code: customer_code
                }
            }).then((result) => {
                if (result) {
                    res.send({ success: true, result })
                }
                else {
                    res.send({ success: false })
                }
            })
    }
    catch (Err) {
        res.send({ success: false })
    }
})

 

3. 데이터가 존재할 경우 클라이언트의 file State 를 변경해주기 위해 fileReducer를 수정한다.

( 데이터베이스의 값을 불러와 다운로드 할 때 필요한 파라미터들을 클라이언트로 전송 )

function fileReducer(state: any, action: any) {

    switch (action.type) {
        case 'init':
            return initFileState;
        case 'listClick':
            var newState = state;
            Object.keys(newState).map((item: any, index: any) => {
            	if(item === 'file_location') { // 위치로 s3 url을 만들어 다운로드 기능을 구현한다.
                	newState[item] = 'https://  s3 url ' +action.value[item]
                }
                else {
                	newState[item] = action.value[item]
                }
            })
            return newState;
        default:
            return {
                ...state,
                [action.name]: action.value
            }
    }
}

 

4. fileDispatch를 적용해준다. (DB 내용이 state에 잘 들어오게끔)

// 이미 등록된 파일이 있는지 확인
axios.get(`/api/file`, {
    params: {
        file_customer_code: customerKey
    }
}).then(({ data }) => {
    if (data.success) {
        fileDispatch({ type: 'listClick', value: data.result })
    }
})

 

 

이 파일의 key 값인 s3에 저장된 경로가 클라이언트로 넘어오면 클라이언트쪽에서 파일 다운로드 하는 로직을 구현할 수 있다.

 

  1. 리스트 항목같은 데이터를 조회하고자 하는 버튼을 클릭했을 때 해당 데이터에 맞는 파일이 존재하는지 데이터베이스를 조회한다.
  2. 데이터베이스에 없으면 그냥 패스. 만약 있을 경우 데이터베이스에 저장된 내용 중 file_location( s3 file key )를 클라이언트로 가져온다.
  3. 클라이언트에서 s3에 접속하여 다운로드를 한다.
728x90
반응형
728x90
반응형

전 포스트에서 클라이언트에서 백엔드에 데이터를 전송하는 것 까지 구현해보았다.

 

이번 포스트에서는 백엔드에서 s3에 접근하여 파일을 업로드 하는 것까지 구현해보겠다.

 

1. .env 파일을 만들어준다. ( 전에 버킷 생성할 때 key ID 와 secret key 필요 )

S3_ACCESS_KEY='액세스 키'
S3_SECRET_ACCESS_KEY='비밀 액세스 키'
S3_REGION='리전'
S3_BUCKET_NAME='버킷이름'

 

2. 서버 파일에 필요한 것들을 선언하고 라우터를 만들어준다. ( 서버 파일에 body-parser 필요 )

file.js

const multer = require('multer');
const aws = require('aws-sdk');
require('dotenv').config();

const s3 = new aws.S3({
    accessKeyId: process.env.S3_ACCESS_KEY,
    secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
    region: process.env.S3_REGION,
});

let upload = multer({
    limits: { fileSize: 5 * 1024 * 1024 } // 용량 제한
});


router.post("/upload", upload.array("file_object"), function (req, res, next) {
    try {

        var base64data = new Buffer(req.files[0].buffer, 'binary');

        const params = {
            Bucket: process.env.S3_BUCKET_NAME,
            Key: 'sample.png', // file name that you want to save in s3 bucket
            Body: base64data,
            ACL: "public-read",
            ContentType: "image/png"
        }

        s3.upload(params, (err, data) => {
            if (err) {
                console.log("err : ", err)
                res.send({ success: false });
            }
            else {
                console.log("data : ", data)
                res.send({ success: true, result: data })
            }
        });

    }
    catch (ERR) {
        console.log("ERR : ", ERR)
        res.send({ success: false })
    }

});



module.exports = router;

 

params의 key 부분에서 원하는 파일의 이름과 경로를 지정해줄 수 있다.

 

 

추가로 데이터베이스에 파일에 관한 내용을 추가하여 나중에 불러올 때 편하도록 설계하였다.

s3.upload(params, (err, data) => {
            if (err) {
                console.log("err : ", err)
                res.send({ success: false });
            }
            else {
                File.findOne(
                    {
                        where: {
                            file_customer_code: customer_code,
                        }
                    }).then((result) => {
                        if (result) {
                            File.update({
                                file_url: req.body.file_url,
                                file_location: key
                            }, {
                                where: {
                                    file_customer_code: customer_code,
                                }
                            })
                                .then((result) => {
                                    res.send({ success: true, result: data })
                                })

                        }
                        else {
                            File.create({
                                file_customer_code: customer_code,
                                file_url: req.body.file_url,
                                file_location: key
                            })
                                .then((result) => {
                                    res.send({ success: true, result: data })
                                })

                        }
                    })

            }
        });

 

나중에 파일이 존재하는지 확인할 때 데이터베이스 먼저 확인하고 파일에 접근할 수 있도록 했다.

 

  1. fileState, fileDispatch 설정
  2. input을 만들고 파일 선택 후 state에 담기도록 함.
  3. 담긴 데이터들을 formData로 만들어서 백엔드에 전송
  4. 백엔드에서 해당 파일을 s3에 저장함.
  5. 데이터베이스에 저장된 파일에 대한 내용 (경로 , 고유 키값 등)을 저장함

 

@@ 참고로 파일을 삭제하는 메소드는 deleteObject 이다.

ex)

s3.deleteObject({ Bucket: process.env.S3_BUCKET_NAME, Key: result.file_location }, (err, data) => {
    if (err) {
        console.log("err : ", err)
    }
})

 

728x90
반응형
728x90
반응형

1. aws에 접속하여 IAM 사용자를 생성한다

 

 

2. 권한으로 AmazonS3FullAccess를 할당해준다.

 

3. 해당 Access key ID, Secret access key를 알고 있어야 한다.

 

4. aws s3 화면에 접속해서 버킷을 만들어준다.

 

 

5. 퍼블릭 액세스 차단을 해제하고 만들어준다. (차후에 필요하면 수정 가능)

추가로 버킷정책과 CORS까지 설정해주자.

버킷정책

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "PublicListGet",
            "Effect": "Allow",
            "Principal": "*",
            "Action": [
                "s3:List*",
                "s3:Get*"
            ],
            "Resource": [
                "arn:aws:s3:::00nomubucket1",
                "arn:aws:s3:::00nomubucket1/*"
            ]
        }
    ]
}

 

CORS

[
    {
        "AllowedHeaders": [
            "*"
        ],
        "AllowedMethods": [
            "PUT",
            "POST",
            "DELETE",
            "GET"
        ],
        "AllowedOrigins": [
            "*"
        ],
        "ExposeHeaders": []
    }
]

 

 

이제 버킷까지 만들었으니 node.js 로 파일 입출력 하는 로직을 구성해보자.

 

6. node.js 프로젝트에 필요한 패키지를 설치해준다.

npm i multer aws-sdk --save

 

7. 클라이언트에서 파일 업로드에 필요한 state를 만들어준다.

(file_location은 나중에 받아올 데이터입니다.)

    // ----- 파일 상태값 -----
    const initFileState : any = {
        file_object: "",
        file_url: "",
        file_location: ""
    }

    function fileReducer(state: any, action: any) {

        switch (action.type) {
            default:
                return {
                    ...state,
                    [action.name]: action.value
                }
        }
    }

    const [fileState, fileDispatch] = useReducer(fileReducer, initFileState)

 

8. 클라이언트에 file input을 만들어준다 ( Next.js Mui 사용했으나 일반 input 태그도 가능합니다 )

<WrapperDiv
    kindOf={`input`}
>
    <TextField
        label={`사업자등록증`}
        type='file'
        color='primary'
        size='small'
        InputLabelProps={{
            shrink: true,
        }}
        name={`file_object`}
        onChange={(e: any) => {
            let reader = new FileReader();
            let file = e.target.files[0];
            reader.onloadend = () => {
                fileDispatch({ name: e.target.name, value: file });
                fileDispatch({ name: 'file_url', value: reader.result });
            };
            reader.readAsDataURL(file);
        }}
    />
</WrapperDiv>
{fileState.file_url !== '' &&
    <WrapperDiv>
        <img style={{ width: "100%", height: "100%" }} src={fileState.file_url} />
    </WrapperDiv>
}

 

9. 대충 이런 모습을 볼 수 있다. ( 스타일은 신경쓰지 않아도 됩니다. 아래는 사진 미리보기  )

10. 백엔드에 데이터를 전송하는 로직을 구성해준다. ( 저장하기 버튼 클릭 시 )

const formData = new FormData();

formData.append("file_customer_code", infoState.customer_code); // 파일의 고유 코드 (임의로 지정 가능)
formData.append("file_object", fileState.file_object);
formData.append("file_url", fileState.file_url);

const config = {
    headers: {
        "content-type": "multipart/form-data",
    },
};

axios.post('/api/fileupload', formData, config)
    .then(({ data }) => {
        if (data.success) {
            alert('파일 업로드에 성공하였습니다.')
        }
        else {
            alert('파일 업로드에 실패하였습니다.')
        }
    })

 

클라이언트에서 파일을 업로드하고 백엔드로 데이터를 전송하였다. 다음 포스트에서는 백엔드에서의 파일 저장을 살펴보겠다.

 

728x90
반응형
728x90
반응형

1. 설치

$npm install --save multer

 

2. multer 가져오기 ( 조건문에 따라 파일의 로컬 저장 위치를 변환시킬 수 있음)

const fs = require("fs");
const bodyParser = require("body-parser");
const path = require("path");
router.use("/image_file", express.static("./upload")); // 사용자가 접근할 수 있게 공유
const multer = require("multer");

const uploadCustomer = multer({
    storage: multer.diskStorage({
        destination(req, file, cb) {
            if (!fs.existsSync("../client/public/upload")) {
                fs.mkdirSync("../client/public/upload");
            }

            if (!fs.existsSync("../client/public/upload/customer")) {
                fs.mkdirSync("../client/public/upload/customer");
            }

            if (!fs.existsSync("../client/public/upload/customer/" + file.originalname.split('-')[0])) { // 경로가 존재하지 않을 때
                fs.mkdirSync("../client/public/upload/customer/" + file.originalname.split('-')[0]);

            } else {
                fs.rmdirSync("../client/public/upload/customer/" + file.originalname.split('-')[0], { recursive: true, force: true }); // 기존폴더 및 하위 자료 삭제 
                fs.mkdirSync("../client/public/upload/customer/" + file.originalname.split('-')[0]); // 폴더 생성 
            }
            cb(null, "../client/public/upload/customer/" + file.originalname.split('-')[0]); // 이미지 생성

        },
        filename(req, file, cb) {

            const ext = path.extname(file.originalname);
            cb(null, file.originalname.split('-')[1]);
        },
    }),
    limits: { fileSize: 5 * 1024 * 1024 },
});

 

 

3. upload.single()

router.post('/', upload.single('image'), (req, res) => {
 
    console.log(req.file); 
  // 클라이언트에서 넘어온 파일에 대한 정보가 req.file에 FILE 객체로 저장되어 있습니다. 

})


출처: https://juhi.tistory.com/10 [주하히의 기술 블로그]

 

4. upload.array()

router.post('/', upload.array('photos', 4), (req, res) => { 

console.log(req.files);
console.log(req.files[0]); // 파일의 인덱스로 접근

// 위 single에서와 다르게 req.file이 아닌 req.files에로 넘어옵니다.

})

출처: https://juhi.tistory.com/10 [주하히의 기술 블로그]



// 미발송 근로계약서 이미지 및 서명, 데이터 요청
router.post("/sendLabor", uploadCustomer.array("image_file"), function (req, res, next) {
    const laborCode = req.body.laborCode;

    const state = JSON.parse(req.body.state);
    const 기타입력정보 = state.기타입력정보;
    const 근로자동의 = state.근로자동의;
    const 메인서명 = state.메인서명;

    console.log("req : ", req.files);



    const update_column = [
        "기타입력정보=JSON_OBJECT('근무장소', '" + 기타입력정보.근무장소 + "','담당업무', '" + 기타입력정보.담당업무 + "','주휴요일', '" + 기타입력정보.주휴요일 + "')",
        "근로자동의=JSON_OBJECT('동의여부_1', '" + 근로자동의.동의여부_1 + "','동의여부_2', '" + 근로자동의.동의여부_2 + "','동의여부_3', '" + 근로자동의.동의여부_3 + "','동의여부_4', '" + 근로자동의.동의여부_4 + "','동의여부_5', '" + 근로자동의.동의여부_5 + "','동의여부_6', '" + 근로자동의.동의여부_6 + "','동의여부_7', '" + 근로자동의.동의여부_7 + "','동의여부_8', '" + 근로자동의.동의여부_8 + "')",
        "메인서명=JSON_OBJECT('근로계약서_동의', '" + 메인서명.근로계약서_동의 + "','개인정보_동의', '" + 메인서명.개인정보_동의 + "','사용자_서명', '" + 메인서명.사용자_서명 + "', '근로자_서명', '" + 메인서명.근로자_서명 + "','사용자_서명날짜', '" + 메인서명.사용자_서명날짜 + "','근로자_서명날짜', '" + 메인서명.근로자_서명날짜 + "','사용자서명경로', '" + 'upload/customer/' + laborCode + '/' + "customer.png" + "','근로자서명경로', '" + 메인서명.근로자서명경로 + "')",
    ]

    console.log(update_column);

    const update_data_query = "UPDATE LABOR SET  " + update_column + " WHERE labor_code ='" + laborCode + "'";

    db.sequelize.query(
        update_data_query,
        {
            type: QueryTypes.UPDATE
        }
    ).then((result) => {

        return res.send({ success: true, result });

    })
})

 

5. upload.fields()

router.post('/',
	upload.fields([
    	{ name: 'mainImage', maxCount: 1 },
        { name: 'subImages', maxCount: 5 } ]),
        (req, res) => {
        	console.log(req.files);
            console.log(req.files['접근하려는 fieldname']);
})

출처: https://juhi.tistory.com/10 [주하히의 기술 블로그]
728x90
반응형
728x90
반응형

location.href 메소드를 이용해서 아주 간단하게 전화걸기 기능을 구현할 수 있다.

 

1. 전화걸기

function phoneCall(phoneNumber) {
  location.href = "tel:" + num;
}

phoneCall("01011112222");

 

2. 영상전화걸기 

function phoneCall(phoneNumber) {
  location.href = "tel-av:" + num;
}

phoneCall("01011112222");

 

3. 문자 보내기

function phoneCall(phoneNumber) {
  location.href = "sms:" + num;
}

phoneCall("01011112222");

 

4. 메일 보내기

function phoneCall(phoneNumber) {
  location.href = "mailto:" + num;
}

phoneCall("01011112222");

 

 

이것들은 a 태그 또는 버튼태그로도 사용할 수 있다.

<button onclick="document.location.href='tel:010-1234-5678'"> 

<a href="tel:010-1234-5678">010-1234-5678로 전화걸기</a> </div>
728x90
반응형

+ Recent posts