사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

이 글에는 프론트엔드 사이드 내용만 적고 백엔드 쪽은 이 글로 분리하려고 한다.

sockjs-client는 npm에서 관리가 안된지 오래 돼서 타입스크립트 사용 시 타입도 따로 추가해야한다.

따라서 types/sockjs-client 라이브러리를 추가로 넣어주도록 한다.

  • Next.js 14 / App router
  • TypeScript
  • @types/sockjs-client, sockjs-client, stompjs

채팅 기능 구현

원래는 STOMP Client를 brokerUrl을 설정해서 만들어보려다가 포기하고 SockJS를 구현체로 사용했다.

이건 백엔드 도메인을 http로 받는듯.brokerUrl은 ws://localhost:8080/ws 의 형식이다.대략적인 순서는 아래와 같다.

  1. useEffect로 마운트 시 STOMP Client를 만들고 소켓 연결
  2. Client 생성 시 설정해둔 onConnect 콜백이 동작하여 설정해둔 topic 구독
  3. 구독 시 설정해둔 onMessageReceived 콜백이 동작하여 구독중인 topic에서 메시지를 수신
  4. 메시지 종류에 따라 switch 문으로 분기하여 원하는 동작 실행
    본인은 메시지 출력을 위해 messages 배열에 push 하도록 함
  5. 메시지를 전송하고 싶으면 client.publish 함수를 이용하여 백엔드 엔드포인트로 메시지 전송
    백엔드 코드에서 MessageMapping("url")에 해당.
    prefix(본인은 "/app"으로 설정) + url 형식으로 엔드포인트를 설정해주면 됨
"use client";

import { Client } from "@stomp/stompjs";
import { useEffect, useState } from "react";
import SockJS from "sockjs-client";

const BASE_URL = "http://localhost:8080";

type Message = { content: string; sender: string; type: string };

type SocketUnitProps = {
  id: number;
};

export default function OldSocketUnit({ id }: SocketUnitProps) {
  const [client, setClient] = useState<Client | undefined>(undefined);
  const [connected, setConnected] = useState(false);
  const [messages, setMessages] = useState<string[]>([]);

  const onMessageReceived = (res: any) => {
    const data = JSON.parse(res.body);

    const { type, content, sender } = data;
    switch (type) {
      case "JOIN":
        messages.push(`${sender}님 입장`);
        break;
      case "CHAT":
        messages.push(content);
        break;
      case "LEAVE":
        messages.push(`${sender} 퇴장`);
        break;
    }
    setMessages([...messages]);
  };

  const sendMessage = () => {
    const chatMEssage = {
      sender: "mi",
      content: "내용",
      type: "CHAT",
    };
    if (client) {
      console.log(client);
      client.publish({
        destination: `/app/chat.sendMessage/${id}`, // 백엔드의 웹소켓 엔드포인트
        headers: {},
        body: JSON.stringify(chatMEssage),
      });
    }
  };

  const disconnect = () => {
    client?.deactivate();
  };

  useEffect(() => {
    const client = new Client({
      webSocketFactory: () => {
        return new SockJS("http://localhost:8080/ws");
      },
      onConnect: () => {
        setConnected(true);
        if (client) {
          client.subscribe(`/topic/${id}`, onMessageReceived); // url의 id = chatroomId
          client.publish({
            destination: `/app/chat.addUser/${id}`, // 백엔드의 웹소켓 엔드포인트
            headers: {},
            body: JSON.stringify({ sender: "name", type: "JOIN" }),
          });
        }
      },
      onStompError: () => {
        console.log("에러 발생");
      },
      debug: function (str) {
        console.log(str);
      },
      reconnectDelay: 5000,
      heartbeatIncoming: 4000,
      heartbeatOutgoing: 4000,
    });
    setClient(client);

    try {
      console.log("[웹소켓] 연결 시도");
      client.activate();
    } catch (e) {
      console.log(e);
    }
  }, []);

  return (
    <div>
      <div>connection: {connected ? "true" : "false"}</div>
      {messages.map((message, i) => (
        <div key={i}>{message}</div>
      ))}
      <div style={{ display: "flex", flexDirection: "column" }}>
        <button
          style={{ border: "1px solid green" }}
          onClick={sendMessage}
          type="button"
        >
          메시지 보내기
        </button>
        <button style={{ border: "1px solid red" }} onClick={disconnect}>
          채팅방 입장
        </button>
      </div>
    </div>
  );
}

'프론트엔드 > 연습' 카테고리의 다른 글

Next.js + 포트원 API로 결제 구현  (0) 2024.04.05
react-hook-form 이해하기  (0) 2024.03.13
Next.js 폴더 구조  (0) 2024.03.11
쿼리 스트링을 이용하여 모달 상태 관리  (0) 2024.01.27
페이지네이션 구현  (0) 2024.01.25

사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 포트원 API로 결제 기능을 구현해볼 것이다.

간단히 결제 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

이 글에는 프론트엔드 사이드 내용만 적고 백엔드 쪽은 이 글로 분리하려고 한다.

  • Next.js 14 / App router
  • TypeScript

사전 작업

아무래도 외부 라이브러리를 사용하는거다 보니 사전작업이 필요하다.

포트원 개발자센터를 찬찬히 읽어보면 이런저런 기능을 이용할 수 있을것이다.

1. 포트원에 회원가입

회원가입 -> 사이드 바의 결제 연동 탭 -> 식별코드/API Keys 탭

2. 결제용 채널 추가

2-1

2-2

2-3

3. Next.js에 SDK 추가

// app router 일 때
// RootLayout 에 Script 태그로 SDK 추가
...
<Script src="https://cdn.iamport.kr/v1/iamport.js" />
...

// page router 일 때 
// pages/_document.tsx에 Script 태그로 SDK 추가
...
<Script
     src="https://cdn.iamport.kr/v1/iamport.js"
     strategy="beforeInteractive"
/>
...

구현하기

@types > type.d.ts

정의만 해둔게 대부분이라 그냥 복붙해서 type 정의 해두자.

스크롤이 기니까 쭈욱 넘기면 된다.

// @types 경로에 아래 타입을 정의해두고 이용한다.

declare global {
  interface Window {
    IMP?: Iamport;
  }
}

export interface Iamport {
  init: (accountID: string) => void;
  request_pay: (
    params: RequestPayParams,
    callback?: RequestPayResponseCallback,
  ) => void;
  loadUI: (
    type: PaypalUI,
    params: PaypalRequestPayParams,
    callback?: RequestPayResponseCallback,
  ) => void;
  updateLoadUIRequest: (type: PaypalUI, params: PaypalRequestPayParams) => void;
}

export interface RequestPayAdditionalParams {
  /**
   * ### 디지털 구분자
   * - 휴대폰 결제수단인 경우 필수 항목
   * - 결제제품이 실물이 아닌 경우 true
   * - 실물/디지털 여부에 따라 수수로율이 상이하게 측정됨
   */
  digital?: boolean;
  /**
   * ### 가상계좌 입금기한
   * - 결제수단이 가상계좌인 경우 입금기한을 설정할 수 있습니다.
   * @example
   * - YYYY-MM-DD
   * - YYYYMMDD
   * - YYYY-MM-DD HH:mm:ss
   * - YYYYMMDDHHmmss
   */
  vbank_due?: string;
  /**
   * ### 결제완료이후 이동될 EndPoint URL 주소
   * - 결제창이 새로운 창으로 리다이렉트 되어 결제가 진행되는 결제 방식인 경우 필수 설정 항목 입니다.
   * - 대부분의 모바일 결제환경에서 결제창 호출시 필수 항목입니다.
   * - 리다이렉트 환경에서 해당 필드 누락시 결제 결과를 수신 받지 못합니다.
   */
  m_redirect_url?: string;
  /**
   * ### 모바일 앱 결제중 가맹점 앱복귀를 위한 URL scheme
   * - WebView 환경 결제시 필수설정 항목 입니다.
   * - ISP/앱카드 앱에서 결제정보인증 후 기존 앱으로 복귀할 때 사용합니다.
   */
  app_scheme?: string;
  /**
   * ### 사업자등록번호
   * - 다날-가상계좌 결제수단 사용시 필수 항목
   */
  biz_num?: string;
}

/**
 * Interface for Display configurations.
 *
 * @property {number[]} [card_quota] - 할부결제는 5만원 이상 결제 요청시에만 이용 가능합니다.
 *
 * @example
 * // 일시불만 결제 가능
 * { card_quota: [] }
 *
 * @example
 * // 일시불을 포함한 2, 3, 4, 5, 6개월까지 할부개월 선택 가능
 * { card_quota: [2,3,4,5,6] }
 */
export interface Display {
  card_quota?: number[];
}

// TODO: 다시 확인 필요, 공식문서에서 페이지마다 다른 정보를 알려주고있음
type PG =
  | "html5_inicis"
  | "inicis"
  | "kcp"
  | "kcp_billing"
  | "uplus"
  | "nice"
  | "kicc"
  | "bluewalnut"
  | "kakaopay"
  | "danal"
  | "danal_tpay"
  | "mobilians"
  | "payco"
  | "paypal"
  | "eximbay"
  | "naverpay"
  | "naverco"
  | "smilepay"
  | "alipay"
  | "paymentwall"
  | "tosspay"
  | "smartro"
  | "settle"
  | "settle_acc"
  | "daou"
  | "tosspayments"
  | "paypal_v2"
  | "nice_v2"
  | "smartro_v2"
  | "ksnet";

type PaymentMethod =
  | "card"
  | "trans"
  | "vbank"
  | "phone"
  | "paypal"
  | "applepay"
  | "naverpay"
  | "samsung"
  | "kpay"
  | "kakaopay"
  | "payco"
  | "lpay"
  | "ssgpay"
  | "tosspay"
  | "cultureland"
  | "smartculture"
  | "happymoney"
  | "booknlife"
  | "point"
  | "wechat"
  | "alipay"
  | "unionpay"
  | "tenpay";

interface EscrowProduct {
  id: string;
  /** 상품명 */
  name: string;
  /** 상품 코드 */
  code: string;
  /** 상품 단위 가격 */
  unitPrice: number;
  /** 수량 */
  quantity: number;
}

interface Card {
  /**
   * - 현재 KG이니시스, KCP, 토스페이먼츠, 나이스페이먼츠, KICC, 다날 6개 PG사에 대해서만 카드사 결제창 direct 호출이 가능합니다.
   * - 일부 PG사의 경우, 모든 상점아이디에 대하여 카드사 결제창 direct 노출 기능을 지원하지 않습니다. 반드시 포트원을 통해 현재 사용중인 상점아이디가 카드사 결제창 direct 호출이 가능하도록 설정이 되어있는지 PG사에 확인이 필요합니다.
   */
  direct?: {
    /** 카드사 금융결제원 표준 코드.  {@link https://chaifinance.notion.site/53589280bbc94fab938d93257d452216?v=eb405baf52134b3f90d438e3bf763630 링크} 참조 */
    code: string;
    /** 할부 개월 수. 일시불일 시 0 으로 지정. */
    quota: number;
  };
  detail?: {
    /** 금결원 카드사 코드 {@link https://chaifinance.notion.site/53589280bbc94fab938d93257d452216?v=eb405baf52134b3f90d438e3bf763630 링크} 참조 */
    card_code: string;
    /** 해당카드 활성화 여부 */
    enabled: boolean;
  }[];
}

export interface RequestPayParams extends RequestPayAdditionalParams {
  /**
   * ### PG사 구분코드
   *
   * @example
   * PG사코드.{상점ID}
   */
  pg?: string;
  /**
   * ### 결제수단 구분코드
   * - PG사별 지원되는 결제수단이 모두 상이합니다.
   */
  pay_method: PaymentMethod;
  /**
   * ### 에스크로 결제창 활성화 여부
   * - 일부 PG사만 지원됩니다.
   * - 에스크로 설정은 PG사와 협의 이후 진행되어야 하는점 주의하세요
   */
  escrow?: boolean;
  /**
   * ### 에스크로 결제 정보
   * - 에스크로 결제(escrow: true)시에만 유효하고, 필수 값은 아닙니다.
   * - 토스페이먼츠 신모듈 (pg: tosspayments.~)시에만 유효합니다
   */
  escrowProducts?: EscrowProduct[];
  /**
   * ### 가맹점 주문번호
   * - 주문번호는 매 결제 요청시 고유하게 채번 되어야 합니다.
   * - 40Byte 이내로 작성해주세요
   * - 결제 승인완료 처리된 주문번호를 동일하게 재 설정시 사전거절 처리 됩니다.
   */
  merchant_uid: string;
  /**
   * ### 결제대상 제품명
   * - 16byte 이내로 작성해주세요
   */
  name?: string;
  /**
   * 결제금액
   */
  amount: number;
  /**
   * ### 사용자 정의 데이타
   * - 결제 응답시 echo 로 받아보실수 있는 필드 입니다.
   * - JSON notation(string)으로 저장됩니다.
   * - 주문 건에 대해 부가정보를 저장할 공간이 필요할 때 사용합니다
   */
  custom_data?: Record<any, any>;
  /**
   * ### 면세금액
   * - 결제 금액 중 면세금액에 해당하는 금액을 입력합니다.
   */
  tax_free?: number;
  /**
   * ### 부가세
   * - 결제 금액 중 부가세(기본값: null)
   * - 지원되는 PG사
   *   - 나이스페이먼츠
   */
  vat_amount?: number | null;
  /**
   * ### 결제통화 구분코드
   * - PayPal은 원화(KRW) 미 지원으로 USD가 기본
   * - PayPal에서 지원하는 통화는 {@link https://developer.paypal.com/docs/reports/reference/paypal-supported-currencies/ PayPal 지원 통화} 참조
   */
  currency?: string;
  /** ### 결제창 언어 설정 (지원되지 않은 일부 PG사 존재) */
  language?: "en" | "ko";
  /** ### 주문자명 */
  buyer_name?: string;
  /**
   * ### 주문자 연락처
   * - 일부 PG사에서 해당 필드 누락시 오류 발생
   */
  buyer_tel?: string;
  /**
   * ### 주문자 이메일
   * - 일부 PG사에서 해당 필드 누락시 오류 발생(페이먼트월)
   */
  buyer_email?: string;
  /**
   * ### 주문자 주소
   */
  buyer_addr?: string;
  /**
   * ### 주문자 우편번호
   */
  buyer_postcode?: string;
  /**
   * ### confirm_process 사용 시 가맹점 endpoint url 설정
   * - 기술지원 메일로 별도 요청이 필요합니다. (support@portone.io)
   */
  confirm_url?: string;
  /**
   * ### 웹훅(Webhook) 수신 주소
   * - 포트원 관리자 콘솔에 설정한 웹훅 주소대신 사용할 웹훅 주소를 결제시마다 설정할 수 있습니다.
   * - 해당 값 설정시 관리자 콘솔에 설정한 주소로는 웹훅발송이 되지 않는점 유의하시기 바랍니다.
   */
  notice_url?: string | string[];
  /**
   * ### 가맹점 정의 빌링키
   * - 비인증 결제 이용시 빌링키와 1:1로 맵핑되는 가맹점 정의 고객 빌링키입니다.
   */
  customer_uid?: string;
  display?: Display;
  card?: Card;
}

export interface RequestPayAdditionalResponse {
  /**
   * ### 신용카드 승인번호
   * - 신용카드 결제수단에 한하여 제공
   */
  apply_num?: string;
  /**
   * ### 가상계좌 입금 계좌번호
   * - PG사로부터 전달된 정보 그대로 제공에 따라 숫자 외 dash(-) 또는 기타 기호가 포함되어 있을 수 있음
   */
  vbank_num?: string;
  /**
   * ### 가상계좌 입금은행 명
   */
  vbank_name?: string;
  /**
   * ### 가상계좌 예금주
   * - 계약된 사업자명으로 표시됨, 단, 일부 PG사의 경우 null 을 반환하므로 자체 처리 필요
   */
  vbank_holder?: string | null;
  /**
   * ### 가상계좌 입금기한 (UNIX timestamp)
   */
  vbank_date?: string;
}

export interface RequestPayResponse extends RequestPayAdditionalResponse {
  /**
   * ### 결제 성공여부
   * - 결제승인 혹은 가상계좌 발급이 성공한 경우, True\
   * - PG사/결제수단에 따라 imp_success로 반환됨
   */
  success?: boolean;
  /**
   * ### 결제 실패코드
   * - 결제가 실패하는 경우 PG사 원천코드가 내려갑니다.
   */
  error_code?: string;
  /**
   * ### 결제 실패메세지
   * - 결제가 실패하는 경우 PG사 원천메세지가 내려갑니다.
   */
  error_msg?: string;
  /**
   * ### 포트원 고유 결제번호
   * - success가 false이고 사전 validation에 실패한 경우, imp_uid는 null일 수 있음
   */
  imp_uid?: string | null;
  /** ### 주문번호 */
  merchant_uid: string;
  /** ### 결제수단 구분코드 */
  pay_method?: PaymentMethod;
  /** 결제금액 */
  paid_amount?: number;
  /** 결제상태 */
  status?: string;
  /** 주문자명 */
  name?: string;
  /** PG사 구분코드 */
  pg_provider?: PG;
  /**
   * ### 간편결제 구분코드
   * - 결제창에서 간편결제 호출시 결제 승인된 PG사 구분코드
   * - 일부 PG사 또는 간편결제로 결제가 발생되지 않은 경우 해당 파라미터는 생략됩니다.
   */
  embb_pg_provider?:
    | "naverpay"
    | "kakaopay"
    | "payco"
    | "samsungpay"
    | "ssgpay"
    | "lpay";
  /**
   * ### PG사 거래번호
   * - PG사에서 거래당 고유하게 부여하는 거래번호입니다.
   */
  pg_tid?: string;
  /** ### 주문자명 */
  buyer_name?: string;
  /** ### 주문자 이메일 */
  buyer_email?: string;
  /** ### 주문자 연락처 */
  buyer_tel?: string;
  /** ### 주문자 주소 */
  buyer_addr?: string;
  /** ### 주문자 우편번호 */
  buyer_postcode?: string;
  /** ### 가맹점 임의 지정 데이터 */
  custom_data?: string;
  /** ### 결제승인시각 (UNIX timestamp) */
  paid_at?: string;
  /** ### 거래 매출전표 URL */
  receipt_url?: string;
}

export type RequestPayResponseCallback = (response: RequestPayResponse) => void;

type PaypalUI = "paypal-spb" | "paypal-rt";

export interface PaypalRequestPayParams extends RequestPayParams {
  pg: string;
  pay_method: "paypal";
  /**
   * ### 국가코드
   * - 주의: 페이팔 일반결제 테스트 모드시에만 유효
   */
  country?: string;
  /**
   * ### 구매자 이름 주의:
   * - 페이팔에서만 유효하며 buyer_name이 아닌 buyer_first_name과 buyer_last_name 입력을 권장
   */
  buyer_first_name?: string;
  /**
   * ### 구매자 이름 주의:
   * - 페이팔에서만 유효하며 buyer_name이 아닌 buyer_first_name과 buyer_last_name 입력을 권장
   */
  buyer_last_name?: string;
  /**
   * ### 결제 상품 정보
   * - 구매 상품 상세 정보를 의미하며 전달 한 값 중 name(상품 명), quantity(상품 수량), unitPrice(상품 단위 금액)만 결제창에 표기됩니다.
   * - 페이팔은 해당 파라미터 입력을 강력 권장하고 있으니, 되도록 입력해주시기 바랍니다.
   * - 각 상품의 수량 * 단위 가격의 총 합이 주문 총 금액과 반드시 일치해야합니다. 일치하지 않는 경우 에러 메시지가 리턴되면서 결제창이 호출되지 않습니다.
   */
  products?: {
    id?: string;
    name?: string;
    code?: string;
    unitPrice?: number;
    quantity?: number;
    tag?: string;
  }[];
  /**
   * ### 결제 통화
   * @default USD
   */
  currency?: string;
}

app > pay > page.tsx

위에서 사전준비 할 때 봤던 고객사 식별코드(가맹점 식별코드)를 여기서 사용한다.

편의상 이렇게 적어뒀지만 구현에 따라 변수로 받아서 사용하면 된다.

"use client";

import axios from "axios";
import { RequestPayParams, RequestPayResponse } from "../../@types/type";

export default function Test() {
  const onClickPayment = () => {
    if (!window.IMP) return;
    /* 1. 가맹점 식별하기 */
    const { IMP } = window;
    IMP.init("가맹점 식별코드"); // 가맹점 식별코드

    /* 2. 결제 데이터 정의하기 */
    const data: RequestPayParams = {
      pg: "html5_inicis", // PG사 : https://developers.portone.io/docs/ko/tip/pg-2 참고
      pay_method: "card", // 결제수단
      merchant_uid: `mid_${new Date().getTime()}`, // 주문번호
      amount: 100, // 결제금액
      name: "아임포트 결제 데이터 분석", // 주문명
      buyer_name: "홍길동", // 구매자 이름
      buyer_tel: "01012341234", // 구매자 전화번호
      buyer_email: "example@example.com", // 구매자 이메일
      buyer_addr: "신사동 661-16", // 구매자 주소
      buyer_postcode: "06018", // 구매자 우편번호
    };
    console.log(data);

    /* 4. 결제 창 호출하기 */
    IMP.request_pay(data, callback);
  };

  function callback(response: RequestPayResponse) {
    const { success, error_msg, imp_uid } = response;
    console.log("Payment success!");
    console.log("Payment ID : " + response.imp_uid);
    console.log("Order ID : " + response.merchant_uid);
    console.log("Payment Amount : " + response.paid_amount);

    // 성공 시 백엔드로 주문번호 전송
    if (success) {
      axios.post(`http://localhost:8080/orders/${imp_uid}`);
      alert("결제 성공");
    } else {
      alert(`결제 실패: ${error_msg}`);
    }
  }

  return (
    <>
      <div className="bg"> hi </div>
      <button onClick={onClickPayment}>결제하기</button>
    </>
  );
}


결제까지 진행하진 않았지만 결제도 테스트 계정에서 돈이 빠져나가는 식으로 잘 된다.

이제 백엔드와 연동해서 검증/환불 로직을 구현하면 완성

이니시스

카카오페이

 

참고 자료

 

포트원(아임포트) Front-end 연동하기(react, next.js, typescript)

포트원(구 아임포트) 프론트엔드에 붙여보기 (with Next.js + Typescript)

velog.io

 

react-hook-form

14주차 숙제에서 rhf를 써보긴 했지만 이해가 부족했었다.

이번에 프로젝트에서 써보면서 rhf를 좀 더 이해할 수 있었다.

useForm의 반환값

useForm<T>의 반환값은 여러개가 있지만 내가 프로젝트에서 쓴건 이정도이다.

register

form에서 값을 입력하는 태그를 등록하는 함수이다.

최종적으로는 input, textarea와 같은 태그에 아래처럼 넣어줘야한다.

name 뒤는 option이라 유효성 검사가 필요없으면 넣지 않아도 된다.

 

본인은 컴포넌트에 prop으로 내려줄 때 타입을 찾느라 고생했는데 타입은 아래와 같다.

당연하다면 당연하겠지만 register 과 register(name)의 타입은 다르다.

// const { register } = useForm<T>(); 일 때
// register: UseFormRegister<T>; 의 형식

// userForm<EditFormData>의 반환 값인 register 일 경우
register: UseFormRegister<EditFormData>;

// register 함수에 name을 지정해준 register(name) 일 경우
register: UseFormRegisterReturn;

handleSubmit

form 데이터를 제출하는 함수이다.

아래처럼 제출 시 동작할 onSubmit 함수를 만들어서 사용한다.

formState

form의 상태값을 나타낸다. 

여러 상태가 있지만 본인은 유효성 검사에서 에러 필드와 메시지를 꺼내 쓰기 위해 사용했다.

setValue

등록한 input, textarea 태그 등에 값을 변경할 때 사용한다.

본인은 프로필 수정 페이지에서 초기 데이터를 바인딩하기 위해 사용했다.

폴더 구조

백엔드든 프론트엔드든 프로젝트 폴더 구조를 설계하는건 참 어려운것 같다.

원티드에서 주워들은걸로는 FSD 라는 설계도 있고 프로젝트마다 제각각이라 참 어렵다.

우선은 멘토링 시간에 추천받은 구조로 가고자 한다.

폴더 구조 트리

페이지 라우터를 이용하는 기준이고  멘토링 시간에 추천받은 구조는 아래와 같다. 

각 페이지에서만 사용하는것들은 페이지 폴더에서 관리하고 공용으로 사용하면 밖으로 빼기로 했다.

root
├── pages // 페이지 라우터 구분
│   ├── 페이지1
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── 페이지2
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── 페이지3
│   │   ├── components
│   │   ├── styles
│   │   ├── utils
│   │   └── hooks
│   ├── _app.tsx
│   ├── _document.tsx
│   └── index.tsx
├── components  // 공용 컴포넌트
│   ├── Modal.tsx
│   ├── Header.tsx
│   ├── Footer.tsx
│   ├── Input.tsx
│   ├── Container.tsx
│   └── Card.tsx
├── hooks // 공용 커스텀 훅
├── lib   // 공용 유틸리티 함수, 상수, API 호출 함수 등
│   ├── apis
│   ├── utils
│   ├── constants
│   └── types
├── styles
│   └── global.ts
└── public

 

쿼리 스트링을 이용하는 이유

이전 글에서 구현한 모달을 열고 닫으려면 부모 컴포넌트에서 모달 open 여부의 state를 이용해야 하고, 모달을 종료하는 setOpen(false) 함수도 전달해야 한다.

또한 뒤로가기를 누르면 모달창이 꺼지는게 아닌 리스트 페이지로 가버렸다.

모달로 넘겨야 하는 prop 도 줄이고, 뒤로가기를 누르면 모달을 끌 수 있게 바꿔보고 싶었다.

이러한 이유로 간단하고도 효과적인 쿼리 스트링을 이용하여 리팩토링 해보았다.


모달 상태 관리

위의 움짤과 같이 URL 쿼리 스트링을 이용하여 모달 상태를 관리한다.

function Post() {
  const [searchParams, setSearchParams] = useSearchParams();
  const isModalOpen = searchParams.get("open"); // router로 쿼리 스트링 이용
  const navigate = useNavigate();
  const { subjectId } = useParams(); // router의 url parameter

  // 쿼리 스트링을 이용하여 open 값으로 모달 종류 선택 및 열고 닫기 가능
  const handleModalOpen = () => navigate(`/post/${subjectId}?open=true`);

  return (
    <>      
      {isModalOpen && (
        <Modal>
          모달 내용
          <button onClick={() => navigate(-1)}>모달 닫기</button>
        </Modal>
      )}
      <button onClick={handleModalOpen}>모달 열기</button>
    </>
  );
}

export default Post;

 

 

'프론트엔드 > 연습' 카테고리의 다른 글

react-hook-form 이해하기  (0) 2024.03.13
Next.js 폴더 구조  (0) 2024.03.11
페이지네이션 구현  (0) 2024.01.25
6주차 코드 리뷰 후기  (0) 2024.01.03
반응형 웹 사이트 만들기  (0) 2023.12.30

페이지네이션

게시판에서 글이 10000개면 모두 렌더링 할 수 없고 나눠서 렌더링해야 한다.

이렇게 나눠서 다음 또는 이전 페이지로 이동하거나 특정 페이지로 이동할 수 있게 하는걸 페이지네이션이라고 한다.

리액트로 페이지네이션을 구현하기 위해서는 직접 구현하는 방법이 있고 라이브러리를 사용하는 방법이 있다.

라이브러리를 쓰는 방법도 알아야 하겠지만, 지금은 공부용이니 직접 만들어 봤다.

 


페이지네이션 구현

구현할 부분은 페이지네이션의 페이지 부분이다.

이전, 다음 페이지로 이동하는 버튼과 특정 페이지로 이동하는 버튼을 구현하려한다.한 번에 몇 개의 페이지 버튼을 렌더링할지도 정해야하고 총 데이터의 개수에 따라 페이지도 달라진다.

만약 위 사진처럼 한 번에 보여 줄 페이지 수가 5개라면, DB에 13개의 데이터가 있고 한 페이지에 5개의 데이터를 출력하면 페이지는 3개이다. DB에 50개의 데이터가 있고 한 페이지에 5개의 데이터를 출력하면 페이지는 5개이다.또한 이전 페이지 혹은 이후 페이지가 없으면 각각의 화살표 버튼을 비활성화 한다.

pagination.js

페이지네이션에 필요한 유틸 함수들을 모아놓은 js 파일이다.

매개변수로 받을지, 지정된 값을 사용할지 고민이 됐지만 우선은 지정된 값을 사용하기로 했다.

// 한 번에 보여줄 페이지 수
const PAGE_ARRAY_LIMIT = 5;

// 입력 받은 페이지를 기준으로 화면에 출력해줄 페이지 목록을 배열로 반환
// currentPage: 입력받은 페이지
// count: DB에 저장된 데이터 수
// limit: 화면에 렌더링 할 데이터 개수
export function getCurrentPageArray(currentPage, count, limit) {
  currentPage = currentPage >= 1 ? currentPage : 1;
  const totalPage = calculateTotalPage(count, limit);
  const startIndex = getStratIndex(currentPage);
  const length = getCurrentPageArrayLength(startIndex, currentPage, totalPage);

  const arr = length > 0 ? new Array(length).fill(0) : [];

  return arr.map((x, i) => {
    if (i < PAGE_ARRAY_LIMIT) {
      return startIndex + i;
    }
  });
}

// 전체 페이지 계산
function calculateTotalPage(count, limit) {
  return Math.ceil(count / limit);
}

// 시작 인덱스 계산 (1, 6, 11, 16, ...)
function getStratIndex(currentPage) {
  currentPage = currentPage >= 1 ? currentPage : 1;
  return (
    Math.floor((currentPage - 1) / PAGE_ARRAY_LIMIT) * PAGE_ARRAY_LIMIT + 1
  );
}

// 입력 받은 페이지를 기준으로 반환해야 할 페이지 목록 길이 계산
function getCurrentPageArrayLength(startIndex, currentPage, totalPage) {
  if (currentPage > totalPage) {
    return 0;
  }

  if (startIndex + PAGE_ARRAY_LIMIT - 1 <= totalPage) {
    return PAGE_ARRAY_LIMIT;
  } else {
    return totalPage - startIndex + 1;
  }
}

pagination.jsx

pagination.js 유틸 함수들을 이용하여 페이지네이션을 구현한다.

페이지네이션하는 부분만 따로 구현한거라 데이터 렌더링하는 부분과 합치면 불필요한 부분이 있을 수 있다.

function Pagination({ limit, initPage, onClick }) {
  const [searchParams, setSearchParams] = useSearchParams();
  const pageParam = searchParams.get("page");
  const page = Number(pageParam >= 1 ? pageParam : initPage);
  const offset = (page - 1) * limit;
  const { data } = useQuery(subjectListUrl(limit, offset), { data: [] });

  const { count, next, previous } = data;
  const currentPageArray = getCurrentPageArray(page, count, limit);

  const handlePageClick = (pageIndex) => {
    onClick(pageIndex);
    setSearchParams({ page: pageIndex });
  };

  const handlePrevClick = () => {
    handlePageClick(page - 1);
  };

  const handleNextClick = () => {
    handlePageClick(page + 1);
  };

  return (
    <PaginationWrapper>
      <PrevButton onClick={handlePrevClick} disabled={previous === null} />
      <PageButtonWraaper>
        {currentPageArray &&
          currentPageArray.map((idx) => (
            <PageButton
              key={idx}
              pageIndex={idx}
              onClick={handlePageClick}
              page={page}
            />
          ))}
      </PageButtonWraaper>
      <NextButton onClick={handleNextClick} disabled={next === null} />
    </PaginationWrapper>
  );
}

function PageIndex({ className, pageIndex, onClick, page }) {
  const navigate = useNavigate();
  const isNow = page == pageIndex;

  const handleClick = () => {
    onClick(pageIndex);
    navigate(`/list?page=${pageIndex}`);
  };

  return (
    <button
      className={`${className} ${isNow ? "now" : ""}`}
      onClick={handleClick}
      disabled={isNow}
    >
      {pageIndex}
    </button>
  );
}

const PaginationWrapper = styled.div`
  페이지네이션 래퍼 스타일
`;

const PageButtonWraaper = styled.div`
  페이지 버튼 래퍼 스타일
`;

const PageButton = styled(PageIndex)`
  페이지 버튼 스타일
`;

export default Pagination;

 

'프론트엔드 > 연습' 카테고리의 다른 글

Next.js 폴더 구조  (0) 2024.03.11
쿼리 스트링을 이용하여 모달 상태 관리  (0) 2024.01.27
6주차 코드 리뷰 후기  (0) 2024.01.03
반응형 웹 사이트 만들기  (0) 2023.12.30
input 태그의 값 이용하기  (0) 2023.12.19

코드 리뷰

내 코드를 다른 사람이 봐주고 그에 대해 의견을 주는 코드 리뷰를 받는건 언제나 좋다.

더군다나 나는 지금 배우는 입장이기 때문에 코드 리뷰를 통해서 얻을 수 있는게 정말 많다.

사실 부트캠프에서 크게 기대를 하진 않았고 공유 오피스를 빌려주니까 거기서 공부할 생각이었다.

하지만 매주 이런 코드 리뷰도 받을 수 있어서 더욱 공부할 맛이 난다.

 

이번 부트캠프에서 목적은 "내가 이용할 수 있는 환경은 모두 이용하자 였다."

코드 리뷰에서도 올바른 질문 방법을 통해 최대한의 효용을 끌어낼 수 있도록 노력해야지.

이번주 부터는 리액트를 이용하기도 하니까 코드 리뷰도 알찰거라 예상이 된다.

겸사겸사 복습도 할겸 코드 리뷰를 받고 배운점과 리뷰 내용도 정리해보고자 한다.


수정 사항

1. UI 분리

기존에는 부모 컴포넌트 한 군데에서 API 호출을 관리하면 관리 포인트가 줄어서 좋지 않나? 싶었다.

하지만 이러면 부모 컴포넌트가 너무 과도한 책임을 지는것 같고, 코드 양도 길어지기 때문에 분리하기로 했다.

UI 를 분리하고 나니 부모 컴포넌트에 의존하지도 않으므로 기존보다 변경에 유연한 코드라고 느껴졌다.

기존

function App() {
  const [folders, setFolders] = useState([]);
  const [userProfile, setUserProfile] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const folders = await getFolders();
        const userProfile = await getUserProfile();  // 부모 컴포넌트가 API 요청 두개를 처리
            ...생략... 
  return (
    <>
      <Nav userProfile={userProfile} />
      <div className={styles.body}>
        <FolderListPage folders={folders} />
      </div>
      <Footer />
    </>
  );
}

export default App;
-------------------------------------------------------------------------
function Nav({ userProfile }) {
	...생략...
}

export default Nav;
-------------------------------------------------------------------------------
function FolderListPage({ folders }) {
	...생략...
}

export default FolderListPage;

변경 후

function App() {
  return (
    <>
      <Header />
      <div className={styles.body}>
        <Outlet />
      </div>
      <Footer />
    </>
  );
}

export default App;
--------------------------------------------------------
function Nav() {
  const [userProfile, setUserProfile] = useState(null);
  
  useEffect(() => {
    const fetchData = async () => {
      try {
        const userProfile = await getUserProfileById(1);   // Nav, SharedPage 각자 API 호출
			...생략...
}

export default Nav;
-----------------------------------------------------------
function SharedPage() {
  const [folder, setFolder] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      try {
        const { folder } = await getFoldersSample();   // Nav, SharedPage 각자 API 호출
			...생략....
}

export default SharedPage;

 

2. 내용이 너무 길어지면 ... 표기

입문자의 어려움은 "모르는게 있는데 어떻게 검색해야할지도 잘 모르는거" 라고 생각한다.

나도 이번에 과제를 하면서 내용이 너무 길어지면 ... 으로 표기하고 생략했으면 좋겠는데,

이걸 뭐라고 검색해야할지 몰라서 우선은 지정한 영역을 벗어난 부분은 스크롤 하게만 만들어 두었다.

이에 대해 멘토님께 물어봤더니 아래처럼 답변을 들었고, 내 페이지에도 적용해보았다.

 

기존

변경 후

 

이것 말고도 리뷰 받은 부분을 몇개 더 고쳤다.

이번주엔 궁금한것도 생각나는건 다 물어봤고, 덕분에 해결된 부분도 많아서 만족스러운 코드 리뷰였다.

백엔드 할때는 프론트엔드 정말 별거 있나? 싶었는데 아직 갈 길이 멀다는걸 느낀다.

특히 CSS 는 진짜 너무 힘들어 ㅋㅋ

'프론트엔드 > 연습' 카테고리의 다른 글

Next.js 폴더 구조  (0) 2024.03.11
쿼리 스트링을 이용하여 모달 상태 관리  (0) 2024.01.27
페이지네이션 구현  (0) 2024.01.25
반응형 웹 사이트 만들기  (0) 2023.12.30
input 태그의 값 이용하기  (0) 2023.12.19

반응형

사실 CSS 는 커리큘럼 되게 앞 쪽에 있어서 한 번쯤 보고 넘어가긴 했지만 나는 CSS 가 너무 싫었다.

그래서 적당히 보고만 넘어갔고 나중에 해야지... 했는데 이제서야 그 벌을 받고 있다.

하지만 일단은 프론트엔드 공부를 하는 한, 반응형은 필수가 아닌가 싶다.

PC 로 보는 사람도 많겠지만 모바일은 더 많을테니...

그래서 어차피 받아야 할 벌이라면 시간을 좀 부어볼까 했다.

다행히 이전 멘토링 시간에 들었던 조언들이 많은 도움이 되었고, 특히 아래의 조언이 정말 좋았다.

모바일 화면부터 만들고 이후에 다른 기기 화면을 만들면 편하다

 

아무래도 모바일에서 한 화면에 렌더링 할 수 있는게 적다보니 요소 배치를 신경써야 한다.

그래서 모바일부터 해놓고 태블릿, PC 를 하면 점진적으로 늘려가는거라 비교적 쉽게 느껴진다.

반면 큰 화면부터 하고 모바일로 가면 기분 상으로도 기존 배치했던걸 없애는 느낌이라 싫더라.

 

결과

간단한 페이지라 대단한건 없지만 결과물은 아래와 같다.

모바일

태블릿

PC

 

아직 익숙하지 않아서 오래 걸리지만 하다보면 늘겠지...

이렇게 반응형도 해봤으니 몰루 사이트로 더 연습해봐야겠다.

'프론트엔드 > 연습' 카테고리의 다른 글

Next.js 폴더 구조  (0) 2024.03.11
쿼리 스트링을 이용하여 모달 상태 관리  (0) 2024.01.27
페이지네이션 구현  (0) 2024.01.25
6주차 코드 리뷰 후기  (0) 2024.01.03
input 태그의 값 이용하기  (0) 2023.12.19

+ Recent posts