사용 기술

프론트엔드는 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

 

문제 발생

페이지네이션 컴포넌트를 만들면서 url 쿼리 스트링을 통해 페이징을 하였다.

처음 만들 때는 생각없이 '?' 를 붙여서 쿼리 스트링을 추가해주었지만 여러개의 쿼리 스트링을 쓸 때 문제가 생겼다.

쿼리 스트링이 없을 때와 기존에 쿼리 스트링이 1개 이상 있을 때 붙여줄 부호가 달랐다.

// 쿼리 스트링이 없을 때 -> '?'로 연결
/path?page=1

// 쿼리 스트링이 있을 때 -> '&'로 연결
/path?keyword=hi&page=1

문제 해결

URL 쿼리 스트링을 포매팅 해주는 유틸 함수를 만들어서 해결했다.

유틸함수는 사용하는 쿼리 스트링들을 객체 형식으로 key: value 값으로 받아서 쿼리 스트링으로 포매팅 한다.

이렇게 만들어진 쿼리 스트링을 기존 path에 '?'로 연결하면 되게끔 만들어뒀다.

objectToQueryString.ts

export default function objectToQueryString(obj: {
  [key: string]: any;
}): string {
  return Object.entries(obj)
    .map(
      ([key, value]) =>
        `${encodeURIComponent(key)}=${encodeURIComponent(value)}`,
    )
    .join("&");
}

문제 발생

Next.js의 페이지 라우터를 쓰고 있었고, 지금껏 개발할 때는 아무 문제가 없었다.

하지만 배포를 시작하면서 빌드를 해보니 그동안 없었던 문제가 생기기 시작했다.

에러 메시지는 아래와 같았다.

// 에러 메시지
Build optimization failed: found pages without a React Component as default export in
pages/my-shop/register/components/Input/types/types.ts
pages/signin/types/types.ts
pages/signup/types/types.ts
pages/home/hooks/userRequest.ts
pages/home/hooks/useUserQuery.ts

문제 해결

결론부터 말하자면 pages 폴더 내에 리액트 컴포넌트 이외의 파일을 만들어서 생긴 문제였다.

팀 프로젝트에서 폴더 구조 및 네이밍 컨벤션을 정할 때 아래처럼 네이밍 하기로 했다.

// 컴포넌트
/pages/{페이지 이름}/components/{컴포넌트 이름}/index.tsx  

// 타입
/pages/{페이지 이름}/types/types.ts    

 

Next.js에서는 pages 폴더 내의 파일을 페이지 컴포넌트로 읽는다.

따라서 타입 파일도 페이지 컴포넌트로 읽는데, 리액트 컴포넌트가 아니기 때문에 발생한 문제였다.

그래서 페이지 컴포넌트라는걸 명시해주기 위해 아래와 같은 설정을 추가해주고 페이지 파일의 이름을 변경했다.

// 페이지 파일 이름 변경
index.tsx -> index.page.tsx

next.config.js

const nextConfig = {
  ...
  pageExtensions: ["page.tsx", "page.ts", "page.jsx", "page.js"],
  ...
}
module.exports = nextConfig;

문제 발생

Next.js와 TS를 쓰면서 프로젝트를 진행하고 있었다.

axios로 API 요청을 하고 서버에서 내려주는 에러 메시지를 이용해 에러 핸들링을 하려고 했다.

try ~ catch 문을 이용해 error를 잡고, error 객체의 message를 이용하려고 하니 아래와 같은 컴파일 에러가 발생했다.

 

console.log를 찍어보면 error 객체는 아래처럼 분명 존재한다.

문제 해결

JS를 쓸 때는 타입 체크를 하지 않기 때문에 사용에 문제 없었지만 

TS에서는 쓰면 error가 unknown 타입이라 error 객체의 프로퍼티로 바로 접근할 수 없다.

여러 방법이 있겠지만 axios에서 제공하는 isAxiosError라는 내부 메서드를 이용했다.

일종의 조건문으로 타입 좁히기를 하는거라고 생각하면 될것 같다.

아래의 else if문처럼 try ~ catch문 안에서 특정 조건에 따라 특정 에러 객체를 반환하도록 설정하거나

혹은 axios 인스턴스 내에서 인터셉터로 에러를 던지도록 하고 catch문 내에서 분기 처리하면 여러 에러 상황을 핸들링 할 수도 있다.

 

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 태그 등에 값을 변경할 때 사용한다.

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

이모션으로 변수 이용하기

팀원이 작성한 코드를 보면서 이모션으로 변수를 이용하여 스타일을 적용하는법을 배웠다.

폰트 스타일과 처럼 정해진 여러개의 스타일을 하나의 변수로 지정해서 쓰면 중복 코드를 많이 줄일 수 있다.

아마 스타일드 컴포넌트에서도 똑같은 문법으로 쓸 수 있을것 같다.

변수 설정

아래처럼 따로 ts 파일을 만들어서 폰트 스타일을 변수로 지정한다.

fontStyles.ts

변수 사용

아래처럼 필요한 곳에 ${변수명} 의 형식으로 import 하여 사용한다.

'프론트엔드 > HTML 및 CSS' 카테고리의 다른 글

[Emotion] Emotion써보기  (0) 2024.03.08
[Tailwind CSS] Tailwind CSS 써보기  (0) 2024.02.02
[CSS] position 정리  (0) 2023.11.21
[HTML] 시맨틱 태그  (0) 2023.11.19
[HTML] <input> 태그 정리  (0) 2023.11.18

폴더 구조

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

원티드에서 주워들은걸로는 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

 

+ Recent posts