문미새 개발일지

24.09.23 day69 작업 일지 본문

개발 TIL

24.09.23 day69 작업 일지

문미새 2024. 9. 23. 23:51
728x90

이제 처음에 기획한 부분과 이 후 필요한 기획한 부분을 거의다 구현한 것 같다. 백 쪽은 남은 부분이 거의 없어 다른 학습을 하고 있고 프론트 쪽은 기능먼저 구현하느라 UI들이 망가져있기 때문에 남은 기능 및 UI를 구현 중이다. 특별한 버그가 있지 않으면 목요일정도에 끝낼 생각을 하고 있어서 그 때까진 빡세게 해야겠다.

주말부터 작업한게 많은데, 메인기능 위주로 작성하려고 한다. 이번주는 정글 때보다 열심히 한 것 같은데 좀 더 성장했기를..

 

루틴과 제품 페이지에 리뷰 기능을 구현했다. 리뷰는 하단에 있으며 리뷰를 쓰려면 로그인을 해야하고 로그인이 안되어있으면 아예 리뷰 작성 칸이 보이지 않는다.

리뷰는 별점과 리뷰 내용을 입력할 수 있으며 리뷰 작성 버튼을 누르면 하단에 리뷰가 렌더링된다.

별점은 드래그로 수정이 가능하며 반 별 마다 0.5점씩 총 5점이 최대이다.

처음에 기획했을 때 이걸 어떻게 해야 하나 막막해서 지피티로 찾아가면서 구현했는데, 드래그 슬라이드도 그렇고 마우스 이벤트로 작동하는 이벤트들은 아직 어색한 것 같다. 너무 어려워

 

재희님이 제품 페이지와 루틴 페이지에서 검색기능을 구현하여 프론트에서도 화면에 보이게 구현했다.

검색창에 텍스트를 작성하고 검색 버튼 클릭 시 특정 get방식의 특정 api로 검색쿼리를 전달해주면 서버에서 쿼리값을 받아 해당 값이 있는 리스트를 보내준다. 제품은 제대로 작동하나 루틴 쪽은 지금 피부 타입 같은 problem배열을 보내주기 위해 데이터를 수정하며 검색 쪽에 에러가 생겨 수정해야 한다.

 

그리고 mypage에 배송지 관리를 추가해줬다. 

배송지 관리를 클릭하면 배송지 페이지로 이동하며 모든 배송지의 리스트를 확인할 수 있다.

배송지가 등록되지 않았다면 등록한 배송지가 없다는 텍스트가 뜬다. 이 부분은 장바구니 처럼 좀 더 가독성있게 UI를 수정할 예정이다. 추가 버튼을 누르면 배송지 추가 페이지로 이동하며 입력을 마치면 배송지 페이지에 리스트가 추가된다.

UI가 없어서 이상해보이지만 기능은 작동한다.

배송지가 있다면 위 사진대로 출력되며 배송지들 확인할 수 있고 우측 상단에 수정과 삭제 버튼이 있다.

수정 버튼을 누르면 배송지 추가와 동일한 기능으로 작동하며 수정 시 PUT으로 서버에 업데이트 데이터를 보낸다.

삭제 버튼을 누르면 삭제하겠냐는 confirm 창이 뜨며 확인버튼을 누를 시 DELETE로 서버에 addr_key값을 보내며 배송지가 재랜더링된다.

기본 배송지는 모든 배송지 리스트 중 한 개만 출력되며 수정이나 추가를 통해 기본 배송지를 변경하면 기존에 작성되있던 값은 사라진다.

 

제품 페이지나 루틴 페이지에 장바구니 추가 버튼이 있는데 해당 버튼을 클릭 시 아이템이 장바구니 페이지로 이동한다.

제품이 장바구니에 담겨있을 때의 UI이다. 제품 좌측의 체크박스를 체크하면 체크한 제품들의 총 가격이 하단 버튼에 출력되며, 제품마다 수량을 지정할 수 있어 동적으로 총액이 변경된다.

할인 금액이나 배송비같은건 현재 추가할 수 있는 요소가 없어서 페이지 간지로 냅뒀다. 저런게 있으면 실제 상업 페이지 같아서 괜찮은 것 같다.

그리고 특정 제품에 마우스를 대면 우측 상단에 삭제 버튼이 활성화 되는데 해당 버튼을 클릭 시 삭제하겠냐는 confirm창이 뜨며 확인을 누르면 삭제되고 페이지가 재렌더링된다.

 

장바구니에 제품을 담아 구매 버튼을 클릭하면 주문서 페이지로 넘어가며 구매하기 전 최종 확인을 할 수 있다.

배송지 변경을 누르면 마이페이지의 배송지 페이지가 팝업창으로 띄워지며 특정 배송지를 선택했을 때 팝업창이 닫히며 배송지가 변경되야 하는데 이 부분에서 데이터 전달이 잘 안되서 구현 중이다.

그 외엔 제품들과 결제금액이 얼마인지를 확인할 수 있다.

 

이 페이지에서 버튼을 클릭하면 결제 모듈로 이동되는데 포트원의 결제 모듈 api를 사용해서 구현했다.

import axios from "axios";
import { useEffect } from "react";
import styled from "styled-components";

interface itemData {
  average_rating: number;
  brand_name: string;
  category: string;
  description: string;
  item_key: number;
  item_name: string;
  item_price: number;
  volume: number;
}

interface cartItem {
  cart_key: number;
  item: itemData;
  item_key: number;
  quantity: number;
  user_key: number;
}

interface PaymentData {
  pg: string;
  pay_method: string;
  merchant_uid: string;
  amount: number;
  name: string;
  buyer_name: string;
  buyer_tel: string;
  buyer_email: string;
  buyer_addr: string;
  buyer_postcode: string;
}

interface totalPriceData {
  totalPrice: number;
  cartList: cartItem[];
}

const BuyBtn: React.FC<totalPriceData> = ({ cartList, totalPrice }) => {
  useEffect(() => {
    const loadIMPScript = () => {
      return new Promise((resolve) => {
        const script = document.createElement("script");
        script.src = "https://cdn.iamport.kr/v1/iamport.js";
        script.onload = () => resolve(window.IMP);
        document.body.appendChild(script);
      });
    };

    loadIMPScript().then((IMP) => {
      if (!IMP) {
        console.error("포트원(아임포트) 결제 모듈이 로드되지 않았습니다.");
      }
    });

    return () => {
      const script = document.querySelector(
        "script[src='https://cdn.iamport.kr/v1/iamport.js']"
      );
      if (script) {
        document.body.removeChild(script);
      }
    };
  }, []);

  const handlePayment = async () => {
    const { IMP } = window as any; // 타입스크립트에서 window 객체를 사용하는 방법
    const clientKey = process.env.REACT_APP_PORTONE_CLIENT_KEY;
    const backPort = process.env.REACT_APP_BACKEND_PORT;
    const channelKey = process.env.REACT_APP_PORTONE_CHANNEL_KEY;

    console.log("클라키", clientKey);
    console.log("임프", IMP);
    console.log("채널키", channelKey);

    if (!IMP) {
      console.error("포트원(아임포트) 결제 모듈이 로드되지 않았습니다.");
      return;
    }

    IMP.init(clientKey); // 포트원 가맹점 식별코드

    const paymentData: PaymentData = {
      pg: "uplus", // PG사
      pay_method: "card", // 결제수단
      merchant_uid: `mid_${new Date().getTime()}`, // 주문 고유 ID
      amount: totalPrice, // 결제 금액
      name: "corou 제품", // 주문명
      buyer_name: "문미새",
      buyer_tel: "010-1234-5678",
      buyer_email: "gildong@example.com",
      buyer_addr: "역삼로 123",
      buyer_postcode: "12345",
    };

    IMP.request_pay(paymentData, async (response: any) => {
      console.log(paymentData);
      if (response.success) {
        console.log("응답 데이터", response.data);
        console.log(1);
        console.log("12312", response.imp_uid);
        try {
          const result = await axios.get(
            `${backPort}/api/payments/${response.imp_uid}`,
            {
              headers: {
                Authorization: `Bearer ${channelKey}`,
              },
            }
          );

          console.log(result.data);
          if (result.data.status === "paid") {
            const token = sessionStorage.getItem("authToken");
            const payData = axios.post(
              `${backPort}/api/order/itemorder`,
              {
                addr_key: 1,
                price_total: totalPrice,
                items: [
                  {
                    count: 1,
                    purchase_price: 1000,
                    item_key: 1,
                  },
                ],
              },
              {
                headers: {
                  Authorization: `Bearer ${token}`,
                },
              }
            );
            alert("결제가 정상적으로 완료되었습니다.");
            console.log("결제 검증 성공:", result.data);
          } else {
            alert("결제 검증 실패: " + result.data.message);
            console.error("결제 검증 실패:", result.data);
          }
        } catch (error) {
          console.error("서버로 결제 정보 전송 중 에러:", error);
        }
      } else {
        alert("결제 실패");
        console.error("결제 실패:", response.error_msg);
      }
    });
  };

  return (
    <>
      <BuyBtnWrapper>
        <button onClick={handlePayment}>
          {totalPrice.toLocaleString()}원 구매하기
        </button>
      </BuyBtnWrapper>
    </>
  );
};
export default BuyBtn;

이 코드는 포트원에서 제공해주는 결제 모듈 코드인데 어느정도 수정이 들어갔다.

포트원 외부 api를 사용할 수 있게 라이브러리 코드를 작성하고 해당 api를 window전역을 사용해 가지고 온다. 그리고 포트원 사이트에서 신청한 API_KEY들을 가져와 연결해주면 해당 결제모듈이 뜨며 결제할 은행을 선택하라고 뜬다.

결제 모듈이 진행되는걸 확인할 수 있으며, 결제 타입을 카드로만 해놔서 계좌연결이 안되는데, 카뱅을 사용하는지라 테스트를 못해서 난감했다. 그래서 재희님이 직접 테스트하다가 계속 맡기는 것도 좀 그래서 신한 어플로 등록해서 테스트를 진행했다.

실제 어플로 qr을 찍으면 결제 확인 창이 뜬다.

 

휴대폰으로 결제를 완료하고 결제 확인 버튼을 클릭하면 정상적으로 완료됐다는 alert이 뜨며 결제가 완료된다.

결제가 완료될 경우 서버로 api요청과 함꼐 결제 성공 시 반환받는 imp_uid를 서버에 보내줘서 응답이 성공하면 완료된다.

서버와의 응답이 성공하면 결제 데이터인 배송지키와 총액, 제품 종류 데이터를 다시 api로 보내며 성공하면 주문 내역 페이지에 뜨게 된다.

결제에 성공하면 결제 기록이 등록되는데 마이페이지의 주문 내역 조회를 클릭하면 주문내역 페이지로 이동하며 결제 내역을 확인할 수 있다.

그리고 간단하게 정적인 페이지인 공지사항도 추가했는데 UI는 나중에 가독성있게 수정할 계획이다.

처음엔 작은 박스로 닫혀있는데 해당 공지사항을 클릭하면 높이가 늘어나며 전체 내용이 보여지게 된다.

마지막은 카카오 로그인인 소셜로그인 구현인데, 여기서 시간을 좀 썼었다.

import styled from "styled-components";
import kakaoIcon from "../../img/kakao.png";
import React, { useEffect } from "react";

const SocialKakao: React.FC = () => {
  const REST_API_KEY = process.env.REACT_APP_KAKAO_KEY;
  const REDIRECT_URI = "http://localhost:3001/kakao/oauth";
  const kakaoLink = `https://kauth.kakao.com/oauth/authorize?client_id=${REST_API_KEY}&redirect_uri=${REDIRECT_URI}&response_type=code`;

  const loginHandler = () => {
    window.location.href = kakaoLink;
  };

  return (
    <>
      <KakaoBtnWrapper>
        <KakaoBtn onClick={loginHandler}>
          <img src={kakaoIcon} alt="kakaoIcon" />
          카카오로 시작하기
        </KakaoBtn>
      </KakaoBtnWrapper>
    </>
  );
};
export default SocialKakao;

const KakaoBtnWrapper = styled.div`
  width: 80%;
  margin: 50px auto 0 auto;
`;

const KakaoBtn = styled.button`
  width: 100%;
  border: none;
  border-radius: 7px;
  background-color: #fce436;
  font-size: 14px;
  font-weight: bold;
  padding: 15px 0;
  position: relative;
  cursor: pointer;

  img {
    width: 15px;
    position: absolute;
    left: 5%;
    top: 40%;
  }
`;

먼저 카카오 로그인 버튼 컴포넌트에서 카카오 api 가이드에 있는대로 특정 카카오 uri에 쿼리를 담아 페이지로 이동하게 된다.

 

해당 버튼을 클릭하면 카카오 로그인 창으로 이동한다.

카카오 이메일과 아이디를 입력하면 카카오 페이지와 통신하며 쿼리에 인가 코드를 보내주게 된다. 해당 값을 redirection페이지에서 param값으로 받아 해당 인자 코드를 서버에 body로 보내준다.

원래 300 status값은 요청에 대해 하나 이상의 응답이 가능하다는 메세지코드인데 이 부분을 예외처리로 두어 서버에서 두 개로 분류했다.

만약 카카오 로그인을 시도했을 때 해당 이메일값이 DB에 없으면 서버는 300번을 보내주며 서버에서 보내주는 이메일과 비밀번호값을 가지고 회원가입 페이지로 이동한다.

반대로 DB에 해당 이메일값이 있으면 200번 성공번호를 받으며 로그인할 때와 같이 토큰과 유저 데이터를 받아 세션에 저장하게 된다.

로그인이 성공할 때까지 어이없는 부분 때문에 에러들이 발생했는데, 카카오로그인 api는 이메일을 받아와야 할 경우에 상업적인 가맹점 신청이 필요하다고 해서 기본적인 개발의 경우 이메일을 못받아오는 것 같더라. 이걸 코드에서만 에러를 찾다가 너무 늦게 알게 됐다.

또 하나는 로그인 요청 테스트를 위해 한 번 테스트를 진행하면 실제 카카오계정에 현재 테스트중인 페이지 값이 저장되게 되는데 이걸 삭제하지 않으면 api요청에서는 게속 에러를 뱉게 되더라. 이것도 재희님이 발견 못했으면 계속 맨땅만 파고 있었을 것이다. 구글 로그인은 세진이를 통해 들었는데 따로 계정 데이터에 대한 특수 권한이 필요없다고 한다. 조금 꼴받긴 한데 구글로 방향을 안 튼 본인 잘못이다.

import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import axios from "axios";
import { jwtDecode, JwtPayload } from "jwt-decode";

interface MyTokenPayload extends JwtPayload {
  exp: number;
  iat?: number;
  userId?: string;
  email?: string;
}

const Redirection: React.FC = () => {
  const navigate = useNavigate();
  const location = useLocation();
  const params = new URLSearchParams(location.search);
  const authorizationCode = params.get("code");
  const backPort = process.env.REACT_APP_BACKEND_PORT;
  const [isApiCalled, setIsApiCalled] = useState(false);

  useEffect(() => {
    if (authorizationCode && !isApiCalled) {
      console.log(authorizationCode);

      axios
        .post(
          `${backPort}/api/kakao/login`,
          {
            code: `${authorizationCode}`,
          },
          {
            validateStatus: function (status) {
              return status >= 200 && status <= 300;
            },
          }
        )
        .then((response) => {
          const data = response.data;
          console.log(data);

          if (response.status === 200) {
            console.log(response.data);
            const token = response.data.token;
            const userKey = response.data.user.user_key;
            const userName = response.data.user.username;
            const decodedToken = jwtDecode<MyTokenPayload>(token);
            const expirationTime = decodedToken.exp * 1000;

            sessionStorage.setItem("authToken", token);
            sessionStorage.setItem("userKey", userKey);
            sessionStorage.setItem("userName", userName);
            sessionStorage.setItem(
              "tokenExpiration",
              expirationTime.toString()
            );

            axios.defaults.headers.common["Authorization"] = `Bearer ${token}`;

            setIsApiCalled(true);
            navigate("/");
          } else if (response.status == 300) {
            alert("가입 계정이 없어 회원가입 페이지로 이동합니다.");
            setIsApiCalled(true);
            navigate("/register", {
              state: {
                email: data.email,
                password: data.password,
              },
            });
          }
        })
        .catch((error) => {
          console.error(
            "오류 발생",
            error.response ? error.response.data : error
          );
        });
    }
  }, [authorizationCode, isApiCalled, navigate]);

  return (
    <>
      <div>로그인 중입니다.</div>
    </>
  );
};
export default Redirection;

이제 서버와 통신하는 api들은 거의 다 구현됐고 남은 건 프론트엔드의 데이터 출력과 UI부분인 것 같다. 자잘한 편의성이나 버그들도 있어 쭉 테스트를 진행하며 찾아야 할 것 같다.

728x90

'개발 TIL' 카테고리의 다른 글

24.09.25 day71 작업 일지  (0) 2024.09.25
24.09.24 day70 취업 설명회  (3) 2024.09.24
24.09.20 day68 작업 일지  (0) 2024.09.20
24.09.19 day67 작업 일지  (4) 2024.09.19
24.09.13 day66 작업 일지  (0) 2024.09.13