728x90
반응형

로그인

1. 카카오 디벨로퍼 로그인

https://developers.kakao.com/

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

2. 애플리케이션 추가

 

3. 플랫폼 도메인 설정 후 등록하러가기 버튼 클릭

4. 로그인 활성화 및 Redirect URI 설정

5. 동의 항목 설정

 

6. _app.tsx 파일에  script 추가

_app.tsx

const MyApp: NextPage<AppProps> = ({ Component, pageProps }: AppProps) => {


  return (
    <React.StrictMode>
      <Head>
        <title></title>
        <meta name="viewport" content="initial-scale=1.0, width=device-width" />

        <script src="https://developers.kakao.com/sdk/js/kakao.js"
          defer
        >
        </script>


      </Head >
      <ThemeProvider theme={Theme}>
          <Component {...pageProps} />
      </ThemeProvider>


    </React.StrictMode>
  );
};

 

7. 쿠키 생성 라우터 ( Back-end )

community.js

var express = require('express');
var router = express.Router();

const jwt = require("jsonwebtoken");

router.post("/login", async function (req, res, next) {

    const body = req.body;

    const tokenValue = {
        email: body.email
    }

    const token = jwt.sign({ isLogined: tokenValue }, "secretkey", {
        expiresIn: 86400000, // 토큰 제한시간
    });

    res.cookie("A12Ba5os9", token, {
        maxAge: 86400000, // 쿠키 제한시간
        path: "/",
        httpOnly: true,
    });

    return res.send({ success: true })
})

module.exports = router;

 

8.  컴포넌트에 구현

import { GetServerSideProps, NextPage } from "next";
import { useRouter } from "next/router";

const Community: NextPage<any> = (props: any) => {
    const router = useRouter();

    const kakaoInit = () => {
        const kakao = (window as any).Kakao;
        if (!kakao.isInitialized()) {
            kakao.init(process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY);
        }

        return kakao;
    }

    const kakaoLogin = async () => {
        // 카카오 초기화
        const kakao = kakaoInit();

        // 카카오 로그인 구현
        kakao.Auth.login({
            success: () => {
                kakao.API.request({
                    url: '/v2/user/me', // 사용자 정보 가져오기
                    success: (res: any) => {
                        // 로그인 성공할 경우 
                        if (!res.kakao_account.email || res.kakao_account.email === "") {
                            alert("해당 계정의 이메일이 존재하지 않습니다.")
                        }
                        else {
                        	// 쿠키 생성
                            await axios.post("/api/community/login", { email: res.kakao_account.email })
                                .then(({ data }) => {
                                    if (data.success) {
                                        dispatch({ name: "email", value: res.kakao_account.email })
                                    }
                                    else {
                                        return alert(" 로그인에 실패하였습니다.")
                                    }
                                })
                        }
                        
                    },
                    fail: (error: any) => {
                        console.log(error);
                    }
                })
            },
            fail: (error: any) => {
                console.log(error);
            }
        })
    }

    return (
        <>
            <button onClick={(e: any) => { kakaoLogin(); }}>
                카톡으로 로그인
            </button>
        </>
    )
}

export default Community;

export const getServerSideProps: GetServerSideProps = async (context) => {
    return {
        props: {

        }
    }
}

/api 로 시작하는 라우터는 next.config.js 로 백엔드 프록시 설정을 해서 그렇습니다.

9. 확인하기


로그아웃

카카오 로그아웃은 이 사이트 뿐만 아니라 카카오 소셜 계정 자체를 로그아웃 시켜야 하는데,

https://developers.kakao.com/docs/latest/ko/kakaologin/rest-api#logout-of-service-and-kakaoaccount

 

Kakao Developers

카카오 API를 활용하여 다양한 어플리케이션을 개발해보세요. 카카오 로그인, 메시지 보내기, 친구 API, 인공지능 API 등을 제공합니다.

developers.kakao.com

 

카카오에서 제공하는 api를 사용해야 한다.

 

1. Logout Redirect URI 설정

 

2. 쿠키 삭제 라우터 ( Back-end )

var express = require('express');
var router = express.Router();

const jwt = require("jsonwebtoken");

router.post("/login", async function (req, res, next) {

    const body = req.body;

    const tokenValue = {
        email: body.email
    }

    const token = jwt.sign({ isLogined: tokenValue }, "secretkey", {
        expiresIn: 86400000, // 토큰 제한시간
    });

    res.cookie("A12Ba5os9", token, {
        maxAge: 86400000, // 쿠키 제한시간
        path: "/",
        httpOnly: true,
    });

    return res.send({ success: true })
})

router.post("/logout", async function (req, res, next) {

    res.clearCookie("A12Ba5os9");

    return res.send({ success: true })
})


module.exports = router;

 

3. 컴포넌트 구현

import { GetServerSideProps, NextPage } from "next";
import { useRouter } from "next/router";

const Community: NextPage<any> = (props: any) => {
    const router = useRouter();

    const kakaoInit = () => {
        const kakao = (window as any).Kakao;
        if (!kakao.isInitialized()) {
            kakao.init(process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY);
        }

        return kakao;
    }

    const kakaoLogin = async () => {
        // 카카오 초기화
        const kakao = kakaoInit();

        // 카카오 로그인 구현
        kakao.Auth.login({
            success: () => {
                kakao.API.request({
                    url: '/v2/user/me', // 사용자 정보 가져오기
                    success: (res: any) => {
                        // 로그인 성공할 경우 
                        if (!res.kakao_account.email || res.kakao_account.email === "") {
                            alert("해당 계정의 이메일이 존재하지 않습니다.")
                        }
                        else {
                        	// 쿠키 생성
                            await axios.post("/api/community/login", { email: res.kakao_account.email })
                                .then(({ data }) => {
                                    if (data.success) {
                                        dispatch({ name: "email", value: res.kakao_account.email })
                                    }
                                    else {
                                        return alert(" 로그인에 실패하였습니다.")
                                    }
                                })
                        }
                    },
                    fail: (error: any) => {
                        console.log(error);
                    }
                })
            },
            fail: (error: any) => {
                console.log(error);
            }
        })
    }
    
    const kakaoLogout = async () => {
        // 쿠키 제거
        await axios.post("/api/community/logout", state)
            .then(({ data }) => {
                if (data.success) {
                    dispatch({ type: "init" })
                    // 카카오 로그아웃 페이지
                    router.push(`https://kauth.kakao.com/oauth/logout?client_id=${process.env.NEXT_PUBLIC_KAKAO_SHARE_KEY}&logout_redirect_uri=http://localhost:3001`);
                }
                else {
                    return alert(" 로그아웃에 실패하였습니다.")
                }
            })
    }

    return (
        <>
            <button onClick={(e: any) => { kakaoLogin(); }}>
                카톡으로 로그인
            </button>
            <button onClick={(e: any) => { kakaoLogout(); }}>
                로그아웃
            </button>
        </>
    )
}

export default Community;

export const getServerSideProps: GetServerSideProps = async (context) => {
    return {
        props: {

        }
    }
}

로그인을 했을 경우 SSR로 props를 넘기면 쿠키 데이터를 읽어 로그인 된 상태를 구현할 수 있다. 

( 로그인 or 로그아웃 버튼 보이게끔 하는거 구현 가능 )

4. 로그아웃 

  • 이 서비스만 로그아웃 : state 가 초기화 되었고, 쿠키가 삭제되었으므로 사이트는 로그아웃 됨. ( 하지만 로그인할 때 전에 했던 방식으로 로그인 됨 )
  • 카카오계정과 함께 로그아웃 : 소셜 계정이 로그아웃 되어 다른 계정으로 로그인 가능

 

728x90
반응형
728x90
반응형
    const [windowWidth, setWindowWidth] = useState(0);

    const resizeWindow = () => {
        setWindowWidth(window.innerWidth)
    }

    useEffect(() => {
        setWindowWidth(window.innerWidth)
        window.addEventListener("resize", resizeWindow)
        return () => {
            window.removeEventListener("resize", resizeWindow)
        }
    }, [windowWidth])

 

상태를 만들고 window 사이즈를 측정해서 쉽게 만들 수 있다.

728x90
반응형
728x90
반응형

아래와 같은 방법을 쓰면 사용할 수 있다.

class App extends React.Component {

  
  render() {
  let codes = "<b>Will This Work?</b>";
    return (
      <div dangerouslySetInnerHTML={ {__html: codes} }>
      </div>
    );
  }
};
728x90
반응형
728x90
반응형

kcp 를 쓰고 m_redirect_url을 지정해주면 모바일 웹앱 환경에서는 빌링키를 받을 때 콜백 함수가 실행이 되질 않는다..

하지만 이 방법을 쓰면 어떤 환경이든 다 가능하게끔 만들었다!

검색을 해보니 내가 redirect url 로 지정한 주소에 ?imp_uid="" 이런식으로 파라미터로 넘겨준다고 한다. 바로 방법이 떠올랐다.

  1. Next.js 에서 쓸 수있는 SSR 로 해당 파라미터를 읽는다.
  2. useEffect로 파라미터가 넘겨졌을때의 로직을 구성해준다.( 파라미터가 없을 경우엔 실행이 안되게끔)
  3. 파라미터로 넘겨졌을때의 라우터는 이전에 써왔던 것들과 동일하다.

1. 페이지들의 라우팅을 관리해주는 파일의 getServerSideProps 에서 파라미터를 읽는 부분을 추가해주자.

export const getServerSideProps: GetServerSideProps = async (context) => {
  const imp_uid = context.query.imp_uid ? context.query.imp_uid : "";
  const merchant_uid = context.query.merchant_uid ? context.query.merchant_uid : "";
  
  ...

이런식으로 imp_ud와 merchant_uid를 받고 구독을 신청하는 페이지까지 전달해준다. ( 컴포넌트끼리의 props 전달은 블로그에 있는 다른 글을 참조 바랍니다. )

 

abc.com/view/category?imp_uid="1234" 이런식으로 넘어오면 props에 "1234"라는 데이터가 들어올 것이고 아니면 "" 빈 스트링으로 올 것이다. 이걸 활용해보자.

 

2. useEffect 로 파라미터를 감지해서 빈 스트링이 아닐 때 이전에 콜백함수에 담겨있던 로직을 실행해준다.

interface LoginData {
    loginData: any
}

const Subscribe: NextPage<LoginData> = (props: any) => {

    useEffect(() => {
        if (props.imp_uid !== "" && props.merchant_uid !== "") {
            var merchant_uid_date = moment().format('YYYY-MM-DD_HH:mm:ss');
            axios.post('/api/subscribe/payments', {
                muser_id: loginData.userId, // 로그인 아이디
                radioItem: radioItem, // 구독 개월 수 라디오 버튼
                merchant_uid: "subscribe_" + merchant_uid_date,
                customer_uid: loginData.userCompanyCode + "_" + loginData.userId,
                name: "기본정기결제",
                amount: 500
            }).then(({ data }) => {
                console.log("data : ", data)
                if (data.success) {
                    // 성공할 경우
                    alert('구독신청이 완료되었습니다. 다시 로그인해주세요.')
                    router.push('/');
                }
                else {
                    // 실패할 경우
                    alert('내용을 다시확인해주세요.')
                }
            })
        }
    }, [props])
    
    ...

 

이제 모바일로 접속했을 때 빌링키를 받고 redirect 되고, 파라미터를 받아 useEffect 로 결제 신청을 하는 것까지 완료되었다.

아임포트 웹훅으로 정기결제 예약까지 완성되었다.

아임포트의 관리자 콘솔에서 정기결제예약 스케줄을 확인할 수 있다. 

 


참고로 m_redirect_url에 먼저 내가 원하는 파라미터를 넣어도 좋다. 페이지가 리렌더링 되기 때문에 기존의 상태값들은 초깃값으로 넘어가기 때문에 원하는 값도 같이 전달하고 싶으면 아래와 같은 방법으로 해주면 좋다.

m_redirect_url: 'abc.com/view/subscribe?subscribe_item=' + subscribeItem

 

728x90
반응형
728x90
반응형

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

    const unSubscribeClickHandler = (e: any) => {
        if (window.confirm('구독을 취소하시겠습니까?')) {
            axios.post('/api/subscribe/unsubscribe', {
                muser_id: loginData.userId
            }).then(({ data }) => {
                if (data.success) {
                    alert('구독취소가 완료되었습니다. 다시 로그인해주세요.')
                    router.push('/')
                }
                else {
                    alert('구독취소 오류입니다. 관리자에게 문의 바랍니다.')
                }
            })
        }
        else {

        }
    }

 

2. 백엔드에 구독을 취소하는 라우터를 만들어준다. 

router.post("/unsubscribe", async function (req, res, next) {
    try {
        Subscribe.findOne({
            where: {
                subscribe_muser_id: req.body.muser_id,
                subscribe_type: "기본정기결제예약"
            },
            order: [['subscribe_createdAt', 'DESC']],
        }).then(async (result) => {

            if (result) {
                // 인증 토큰 발급 받기
                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/unschedule',
                    method: "post",
                    headers: { "Authorization": access_token }, // 인증 토큰을 Authorization header에 추가
                    data: {
                        customer_uid: result.subscribe_customer_uid,
                        merchant_uid: result.subscribe_merchant_uid, // 새로 생성한 결제(재결제)용 주문 번호
                    }
                });

                MUserInfo.update({
                    muser_subscribe_type: ""
                }, {
                    where: {
                        muser_id: result.subscribe_muser_id
                    }
                })

                res.clearCookie('')
                res.send({ success: true })
            }
            else {
                res.send({ success: false })
            }
        })
    }
    catch (Err) {
        console.log("Err : ", Err)
        res.send({ success: false })
    }
})

module.exports = router;
728x90
반응형
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
반응형

브라우저에 접속한 사용자의 IP 주소를 확인하는 방법은 생각보다 간단하다.

 

하단의 사이트에서 그 기능을 지원해주는데, get 메소드로 쉽게 얻을 수 있다.

 

https://geolocation-db.com/

 

Geolocation DB - Geographic location By IP Address

 

geolocation-db.com

 

1. 먼저 axios 를 설치한다.

$npm i axios

 

2. 사용한다. ( hook 이나 함수 안에서 사용 )

const res = axios.get('https://geolocation-db.com/json/')
              .then((res) => {
                console.log("data : ", res)
              })

 

3. 결과값을 확인해본다.

IP 주소 뿐만 아니라 국가와 도시까지 알 수 있다.

728x90
반응형
728x90
반응형

쿠키와 세션만을 이용한 중복 로그인 방지를 구현할 때에는 몇가지 단점이 있었다.

  • 로그인을 한 상태에서 ( 세션은 유효 ) 사용자가 쿠키를 임의로 삭제할 경우
  • 기존 로그인 된 브라우저에서 ( 세션은 유효 ) 로그아웃을 안하고 다른 브라우저에 접속할 때

이러한 상황에서 사용자는 분명 로그아웃을 한거 같은데 이미 접속중인 사용자라고 뜰 것이다.

물론 개발자야 왜 그런지 이유를 알지만 쿠키와 세션에 관한 이해도가 없는 일반 사용자같을 경우엔 이유를 모르기 때문에 문제가 발생한다.

하지만 jwt를 생성한 뒤 세션에 토큰에 대한 내용을 담고, Next.js의 SSR에서 자신이 가지고 있는 토큰과 세션이 가진 토큰이 같은지 확인하고 다르면 ( 다른 사용자가 로그인을 시도하고 토큰의 내용을 수정할 경우 ) 로그아웃을 진행하면 이러한 문제를 사전에 방지할 수 있다. 

 

 

1. 먼저 서버에 필요한 npm 패키지를 설치한다.

$npm i jsonwebtoken

 

2. secretKey를 위해 파일을 만들고 다음과 같이 작성해준다.

config/jwt.js

let jwtObj = {};

jwtObj.secret = "key"

module.exports = jwtObj

 

3.  jwt 사용이 필요한 (로그인 api) 라우터에 선언을 해준다.

const jwt = require('jsonwebtoken');
const secretObj = require('../config/jwt');

 

4. 세션을 위한 준비를 해준다.

https://typo.tistory.com/entry/MySQL-%EC%84%B8%EC%85%98%EC%9D%84-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EC%A4%91%EB%B3%B5-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EB%B0%A9%EC%A7%80-with-sequelize

 

MySQL | 세션을 이용한 중복 로그인 방지 with sequelize

회사에서 사용자들 수에 대한 제한을 두어서 중복 로그인 방지를 구현하게 되었다. 쿠키는 브라우저에만 남기에 중복 로그인을 쿠키로만으로는 구현할 수 없다. 세션은 데이터베이스에 저장이

typo.tistory.com

 


db 조회 시 필요한 sequelize ( 자세한 내용은 블로그 참조 바랍니다. 이번 포스트에서는 raw query를 사용했습니다. )

const { QueryTypes, sequelize } = require('sequelize');
const db = require('../sequelize/models');

 


 

5. login 라우터에  token을 세션과 쿠키에 주입하는 문구를 추가해준다.

router.get("/login", function (req, res, next) {

	// 로그인 정보가 있는지 확인 
    
    	...
        
    if(로그인정보가 없으면) {
    		return res.send({success : false});
    }
    else {
       
	// 조회한 로그인 정보가 세션이 존재하는지 확인
    const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as token FROM SESSIONS WHERE JSON_EXTRACT(data, '$.uid') = :uid`

          db.sequelize.query(
            select_data_query,
            {
              replacements: { uid: 로그인정보.user_id }, // 위에 로그인 정보 있는지 확인해서 취합한 object ( db에 user_id로 저장되있음 )
              type: QueryTypes.SELECT
            }
          ).then((result) => {
            // 세션이 존재하지 않을 경우
            if(result.length === 0) {
            	const cookie = {
                	uid: 로그인정보.user_id
                }
                
                // jwt 토큰 생성
  		const token = jwt.sign(
    		{ isLogined: cookie },
    		secretObj.secret,
    		{ expiresIn: '86400000' }
  		);
        
        	// 쿠키 생성
		res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성
  		
        	// 세션 생성
        	req.session.uid = 로그인정보.user_id; 
  		req.session.token = token;
                
                res.send({success : true})
            }
            // 세션이 존재할 경우
            else {
            	return res.send({success : false, result : "isLogined"})
                }
          })
     }
});

 

 

6. client에서 세션이 존재할 경우 alert 창을 띄우고 기존 사용자를 로그아웃 할 것인지 물어보는 로직을 구성한다.

  const loginSubmit = (e: any) => {
    e.preventDefault();

    axios.post('/api/session/login', {
      user_id: id,
      user_password: pw
    }).then(({ data }) => {
      if (data.success) {
        router.push('/view/labor');
      }
      else if (data.result === "isLogined") {
        if (window.confirm('이미 로그인중인 사용자가 있습니다. 접속을 해제하시겠습니까?')) {
          axios.post('/api/session/disconnect', {
            user_id: id,
            user_password: pw
          })
            .then(({ data }) => {
              if (data.success) {
                router.push('/view/labor');
              }
            })
        }
      }
      else {
        alert('존재하지 않는 사용자입니다.');
      }
    })
  };

 

7. 만약 이미 접속중인 사용자를 로그아웃하고 본인이 로그인하길 원하면 세션 내의 토큰을 재발급해준다.

router.post('/disconnect', function (req, res, next) {

  // 로그인 정보가 있는지 확인 
    
    	...

  if(로그인정보가 없으면) {
    return res.send({success : false});
  }
  else {
  
  	const cookie = {
    		name: 로그인정보.name // 위에 로그인 정보가 있는지 확인한 곳에서 가져온 데이터
    	}
        
        // 토큰 발급
        const token = jwt.sign(
        { isLogined: cookie },
        secretObj.secret,
        { expiresIn: '86400000' }
      );
        
        // 세션 db를 조회하여 전에 발급한 토큰이 있는지 확인
        
        const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as uid FROM SESSIONS WHERE (JSON_EXTRACT(data, '$.uid') = :uid AND JSON_EXTRACT(data, '$.token') = :token)`

      db.sequelize.query(
        select_data_query,
        {
          replacements: { uid: 로그인정보.user_id, token: token },
          type: QueryTypes.SELECT
        }
      ).then((result) => {
        // 쿠키를 임의로 삭제하고 다시 로그인할 경우엔 세션 쿠키 안에 토큰 내용이 없다. 세션을 만들어주어야 한다.
        if (result.length === 0) {

          // 기존에 있던 세션의 token 값을 바꿔준다.
          let sql_update_data = "UPDATE SESSIONS SET data = JSON_SET(data, '$.token', :token) WHERE JSON_EXTRACT(data, '$.uid') = :uid";

          db.sequelize.query(
            sql_update_data,
            {
              replacements: { uid: 로그인정보.user_id, token: token },
              type: QueryTypes.UPDATE
            }
          ).then((result) => {
            console.log("result : ", result)
          });

          req.session.uid = 로그인정보.user_id; // 세션 생성
          req.session.token = token;

          res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성

          res.send({ success: true })


        }
        // 쿠키를 임의로 삭제하지 않았을 경우엔 세션을 따로 만들 필요가 없다.
        else {
          // 기존에 있던 세션의 token 값을 바꿔준다.
          let sql_update_data = "UPDATE SESSIONS SET data = JSON_SET(data, '$.token', :token) WHERE JSON_EXTRACT(data, '$.uid') = :uid";

          db.sequelize.query(
            sql_update_data,
            {
              replacements: { uid: 로그인정보.user_id, token: token },
              type: QueryTypes.UPDATE
            }
          ).then((result) => {
            console.log("result : ", result)
          });

          res.cookie('isLogined', token, { maxAge: 86400000, path: "/", httpOnly: true }); // jwt 토큰을 쿠키에 담아서 생성

          res.send({ success: true })

        }
      })
  }
})

로그인 정보가 존재할 경우에 접속을 해제할 경우의 수가 2가지 있다.

  1. 쿠키를 삭제한 뒤 재접속 할 경우 ( 세션 쿠키 자체는 존재하지만 임의로 만들어준 uid 데이터가 없다. )
  2. 로그아웃 한 뒤 재접속 할 경우 ( 세션 쿠키 자체가 존재하지 않다. )

첫번째의 경우 데이터가 사라진 상태기 때문에 세션을 생성해주어야 하고, 두번째는 필요없다.

 

 

8. getServerSideProps에서 쿠키가 없을 경우 redirect 시킨다.

( 로그인중이 아니였고 쿠키를 제거하고 로그인을 시도할 경우 방지)

export const getServerSideProps: GetServerSideProps = async (context) => {
  const isLogined = context.req.cookies.isLogined ? true : false;
  
  // 쿠키가 존재할 경우
  if (isLogined) {
  
  // 쿠키 안의 토큰을 decoded한 값
  const tokenValue = parseJwt(context.req.cookies.isLogined);
  
  	return {
          props: {
            cate,
            tokenValue
          },
        };
  
  }
  // 쿠키가 존재하지 않을 경우
  else {
  	return {
      	 redirect: {
        	permanent: false,
        	destination: "/",
      	},
    };
  }

 

8. 클라이언트에서 useEffect hook으로 접속한 사용자의 정보를 계속 확인해준다.

(자신이 발급한 토큰이 세션에 있는 토큰과 일치한지, 로그인 해 둔 상태에서 쿠키를 제거했을 때 redirect)

  useEffect(() => {
    axios.post('/api/session/islogined')
      .then(({ data }) => {
        if (data.success) {
          alert('다른 곳에서 로그인하여 로그아웃 됩니다.')
          axios.get('/api/session/isloginedout')
          router.push('/')
        }
        else if (data.result === 'notcookie') {
          alert('쿠키가 삭제되어 로그아웃 됩니다.')
          axios.get('/api/session/logout')
          router.push('/')
        }
        else {
        }
      })
  })

 

9. 세션 안에 내가 발급했던 토큰 값이 잘 들어가있는지 확인한다. ( islogined router )

// client SSR에서 세션에 로그인 데이터가 있는지 확인한다.
router.post('/islogined', function (req, res, next) {
  const cookie = req.cookies;

  try {
    // 쿠키 또는 세션이 존재하지 않을 경우
    if (!cookie.isLogined) {
      return res.send({ success: false, result: "notcookie" })
    }
    else {
      // 쿠키가 존재할 경우 브라우저 세션(쿠키) 안의 토큰을 decoded하여 db의 세션 안의 token값을 비교해본다.
      const verify = jwt.verify(
        cookie.isLogined,
        secretObj.secret,
        { expiresIn: '86400000' }
      )


        const select_data_query = `SELECT JSON_EXTRACT(data, '$.token') as data FROM SESSIONS WHERE (JSON_EXTRACT(data, '$.uid') = :uid AND JSON_EXTRACT(data, '$.token') = :token)`

	// 내가 발급했던 uid와 token이 잘 있는지 확인한다. ( 다른 사용자가 토큰을 재발급하지 않았는지 )
        db.sequelize.query(
          select_data_query,
          {
            replacements: { uid: verify.isLogined.user_id, token: cookie.isLogined },
            type: QueryTypes.SELECT
          }
        ).then((result) => {
          if (result.length === 0) {
            return res.send({ success: true });
          }
          else {
            return res.send({ success: false })
          }
        })
    }
  }
  catch (err) {
    console.log(err)

  }
})

 

 

 사용 도중 로그아웃이 될 경우의 수는

  1. 사용 도중 다른 곳에서 로그인을 했을 경우
  2. 사용 도중 쿠키를 삭제할 경우

2가지가 있다.

10. 각각의 경우를 고려하여 라우터를 만들어준다.

1) 다른 접속자가 로그인을 할 경우

// 다른 접속자가 로그인을 할 경우
router.get('/isloginedout', function (req, res, next) {

    // 토큰만 삭제
    delete req.session.token;

    // 쿠키 삭제
    res.clearCookie('isLogined');

    res.send({ success: true }); // 클라이언트에 결과 반환
})

쿠키와 토큰을 삭제해준다.

 

2) 쿠키가 삭제되거나 로그아웃을 시도할 경우

router.get('/logout', function (req, res, next) {
  console.log("req. : ", req.session)

  if (req.session.uid) {

    const delete_data_query = `DELETE FROM SESSIONS WHERE JSON_EXTRACT(data, '$.uid') = :uid`

    db.sequelize.query(
      delete_data_query,
      {
        replacements: { uid: req.session.uid },
        type: QueryTypes.DELETE
      }
    ).then((result) => {

      // 세션 삭제
      delete req.session.uid;
      delete req.session.token;

      // 쿠키 삭제
      res.clearCookie('isLogined');

      res.send({ success: true }); // 클라이언트에 결과 반환

    }).catch((err) => {
      console.log(err)
    })
  }
  else {
    // 세션 삭제

    delete req.session.token;

    // 쿠키 삭제
    res.clearCookie('isLogined');

    res.send({ success: true }); // 클라이언트에 결과 반환
  }
})

쿠키가 삭제되거나 로그아웃을 했을 경우엔 데이터베이스에서 해당 값을 아예 삭제해버리고

세션 또한 삭제해준다.

728x90
반응형

+ Recent posts