Skip to content

Conversation

@onegood07
Copy link
Owner

📍 BackGround

  • 세종대학교 아롬 동아리의 리액트 스터디에서 진행한 <우아한테크코스 - 페이먼츠 구현>을 React + Tsx를 사용해서 구현하기

😎 Content

목표

  • 파일 분리, 상수화, 고차함수 사용
  • 분리 (특히 함수 재사용)
  • 타입스크립트이니 '타입' 만들어서 사용하기
  • Jsx가 아닌 Tsx 사용하기 (TypeScript 활용)

파일 구조

  • 최종 파일 구조는 아래와 같음
📦src
 ┣ 📂assets
 ┃ ┣ 📜Mastercard.svg
 ┃ ┗ 📜Visa.svg
 ┣ 📂components
 ┃ ┣ 📜Card.tsx
 ┃ ┣ 📜CardForm.tsx
 ┃ ┗ 📜CardFormInput.tsx
 ┣ 📂constants
 ┃ ┣ 📜errorMessage.ts
 ┃ ┣ 📜index.ts
 ┃ ┣ 📜textConstants.ts
 ┃ ┗ 📜usingNumbers.ts
 ┣ 📂pages
 ┃ ┗ 📜Home.tsx
 ┣ 📂stories
 ┃ ┗ 📜App.stories.tsx
 ┣ 📂styles
 ┃ ┣ 📜Card.module.css
 ┃ ┣ 📜CardForm.module.css
 ┃ ┗ 📜CardFormInput.module.css
 ┣ 📂types
 ┃ ┣ 📜cardTypes.ts
 ┃ ┣ 📜errorType.ts
 ┃ ┗ 📜index.ts
 ┣ 📂utils
 ┃ ┣ 📜cardNumberValidators.tsx
 ┃ ┣ 📜errorHelpers.tsx
 ┃ ┣ 📜expirationDateValidators.tsx
 ┃ ┣ 📜index.ts
 ┃ ┣ 📜inputFilters.tsx
 ┃ ┗ 📜ownerNameValidators.tsx
 ┣ 📜App.tsx
 ┣ 📜index.css
 ┣ 📜main.tsx
 ┗ 📜vite-env.d.ts

상수화

  • constants 폴더에 에러 메시지, 화면에 보이는 글자 등을 담아서 사용함
// textConstants.ts
const CARD_FORM_LABELS = {
  CARD_NUMBER: "결제할 카드 번호를 입력해 주세요",
  CARD_NUMBER_CAPTION: "본인 명의의 카드만 결제 가능합니다.",
  EXPIRATION_DATE: "카드 유효기간을 입력해 주세요",
  EXPIRATION_DATE_CAPTION: "월/년도(MMYY)를 순서대로 입력해 주세요.",
  CARD_OWNER: "카드 소유자 이름을 입력해주세요",
};

// usingNumbers.ts
export const CARD_LOGO_NUMBER = {
  VISA_NUMBER: "4",
  MASTER_MIN_NUMBER: 51,
  MASTER_MAX_NUMBER: 55,
};

분리

  • 저번 스터디 때 가장 인상깊었던 부분이었고 신경쓰고 싶었던 부분이었음.
  • 고민했던 점은 input을 각각 분리해서 만들지, 아니면 하나로 쓰되 props를 활용할지였음 ➡️ input을 하나로 사용하고, props를 활용하기로 결정
  • isDisablehasError을 사용
// CardFormInput.tsx
interface CardFormProps {
  name: string;
  cardPlaceHolder: string;
  cardInput: number | string;
  handleChange: (e: ChangeEvent) => void;
  width: string;
  isDisable?: boolean;
  hasError: boolean;
}
  • 검증하는 함수를 만들 때, 각 함수 하나씩 기능을 분리해서 구현 (함수 하나당 하나의 기능을 수행할 수 있도록 노력)
// expirationDateValidators.tsx
// 입력한 2자리 연도를 4자리 연도로 변경
const convertYear = (year: number): number => (생략)

// 두자리로 월을 반환 (ex. 3월 입력 시 03월로 변환)
export const convertMonth = (month: string): string => (생략)

// 2글자를 입력했는지 검증
const isLengthTwo = (value: string): boolean => (생략)

// 입력한 달이 1월부터 12월 사이인지 검증
const isInValidMonthRange = (month: number): boolean => (생략)

타입

  • 타입 지정을 해줘야 하는 TS를 사용하므로, 내가 직접 타입을 만들어서 사용해보기가 목표였음
  • 가장 고민이 많았던 부분이 Error 처리였는데, Error를 타입으로 만들어서 사용함
  • useState를 사용하여 error를 관리하기 위해 CardError 타입을 하나 만들어서 전체로 관리하고, 세부 error 타입을 선언
  • 기본 ErrorType 타입은 hasErrorerrorMessage를 가지는데, 카드번호 에러는 inputdisable 사용을 위해 확장으로 isDisable 속성 추가해줌
// errorTyps.ts
export interface CardError {
  cardNumbersError: CardNumbersError;
  expirationDateError: ExpirationDateError;
  ownerError: ErrorType;
}

export interface ErrorType {
  hasError: boolean;
  errorMessage: string;
}

export interface CardNumberBlockError extends ErrorType {
  isDisable: boolean;
}

export interface ExpirationDateError {
  month: ErrorType;
  year: ErrorType;
}

export interface CardNumbersError {
  firstBlock: CardNumberBlockError;
  secondBlock: CardNumberBlockError;
  thirdBlock: CardNumberBlockError;
  fourthBlock: CardNumberBlockError;
}

📸 Screenshot

default.mp4

추가로 고려해볼 것

  • 카드번호 입력에서 포커스 자동으로 변환되는 걸 시도해보기

@onegood07 onegood07 self-assigned this Apr 6, 2025
Copy link

@greetings1012 greetings1012 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

제 로컬 환경에서는 npm run dev를 실행하면 import문 에러가 발생하면서 preview가 정상적으로 동작하지 않네요! 이 부분 고쳐주시면 다시 확인해보겠습니다 😭

@onegood07
Copy link
Owner Author

@greetings1012 import 오류 수정했습니다 알려주셔서 감사해요 😊

Copy link

@greetings1012 greetings1012 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

수고하셨습니다!
전체적으로 폴더 구조 설계와 기능 분리에 대해 생각해서 설계한 노력이 보였어요!
몇 가지 생각할 거리를 남겨 두었으니, 시간 날 때 한번 확인해주세요 👍

Comment on lines +5 to +40
📦src
┣ 📂assets
┃ ┣ 📜Mastercard.svg
┃ ┗ 📜Visa.svg
┣ 📂components
┃ ┣ 📜Card.tsx
┃ ┣ 📜CardForm.tsx
┃ ┗ 📜CardFormInput.tsx
┣ 📂constants
┃ ┣ 📜errorMessage.ts
┃ ┣ 📜index.ts
┃ ┣ 📜textConstants.ts
┃ ┗ 📜usingNumbers.ts
┣ 📂pages
┃ ┗ 📜Home.tsx
┣ 📂stories
┃ ┗ 📜App.stories.tsx
┣ 📂styles
┃ ┣ 📜Card.module.css
┃ ┣ 📜CardForm.module.css
┃ ┗ 📜CardFormInput.module.css
┣ 📂types
┃ ┣ 📜cardTypes.ts
┃ ┣ 📜errorType.ts
┃ ┗ 📜index.ts
┣ 📂utils
┃ ┣ 📜cardNumberValidators.tsx
┃ ┣ 📜errorHelpers.tsx
┃ ┣ 📜expirationDateValidators.tsx
┃ ┣ 📜index.ts
┃ ┣ 📜inputFilters.tsx
┃ ┗ 📜ownerNameValidators.tsx
┣ 📜App.tsx
┣ 📜index.css
┣ 📜main.tsx
┗ 📜vite-env.d.ts

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

폴더 구조를 굉장히 깔끔하게 잘 짜주셨네요 👍

<h1>React Payments</h1>
</>
);
return <Home></Home>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

사소한 내용이지만, 안에 children을 받지 않는 컴포넌트의 경우 아래처럼 명확히 단일 태그라는 것을 보여주면 좋아요.

Suggested change
return <Home></Home>;
return <Home />;

@@ -0,0 +1,13 @@
export type ChangeEvent = React.ChangeEvent<HTMLInputElement>;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 타입 선언은 단순히 길게 써야 하는 선언을 줄이기 위해 만든 걸까요?
조금 더 명시적으로 타입을 선언해서
잘못된 코드를 작성하는 걸 컴파일 수준에서 방지할 수 있을 것 같아요!

Suggested change
export type ChangeEvent = React.ChangeEvent<HTMLInputElement>;
export type InputName =
| "firstBlock"
| "secondBlock"
| "thirdBlock"
| "fourthBlock"
| "month"
| "year"
| "owner";
export type ChangeInputEvent = ChangeEvent<HTMLInputElement> & {
target: {
name: InputName;
value: string;
};
};
export type CardData = {
numbers: {
firstBlock: string;
secondBlock: string;
thirdBlock: string;
fourthBlock: string;
};
expiration: {
month: string;
year: string;
};
owner: string;
};

혹은... 각 필드 이름을 두 번 사용하기 싫으면 아래와 같은 방법도 있어요.

Suggested change
export type ChangeEvent = React.ChangeEvent<HTMLInputElement>;
export type CardData = {
numbers: {
firstBlock: string;
secondBlock: string;
thirdBlock: string;
fourthBlock: string;
};
expiration: {
month: string;
year: string;
};
owner: string;
};
type FlattenKeys<T> = T extends object
? { [K in keyof T]: K | FlattenKeys<T[K]> }[keyof T]
: never;
export type InputName = FlattenKeys<CardData>;
export type ChangeInputEvent = ChangeEvent<HTMLInputElement> & {
target: {
name: InputName;
value: string;
};
};

제네릭을 활용한 재귀 타입 지정 방식인데, 이건 아직 생소한 개념일 수 있으니
'이런 방법도 있구나~' 라고 생각하고 넘어가도 돼요!

Comment on lines +1 to +26
export interface CardError {
cardNumbersError: CardNumbersError;
expirationDateError: ExpirationDateError;
ownerError: ErrorType;
}

export interface ErrorType {
hasError: boolean;
errorMessage: string;
}

export interface CardNumberBlockError extends ErrorType {
isDisable: boolean;
}

export interface ExpirationDateError {
month: ErrorType;
year: ErrorType;
}

export interface CardNumbersError {
firstBlock: CardNumberBlockError;
secondBlock: CardNumberBlockError;
thirdBlock: CardNumberBlockError;
fourthBlock: CardNumberBlockError;
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기도 인터페이스를 계층화해서 관리하면 조금 더 깔끔하게 사용할 수 있을 것 같아요!
또한, CardNumberBlockError는 ErrorType을 상속받는데 서로 이름의 형식이 달라 코드를 처음 보는 사람은 정확한 상속 관계를 유추하기 어려울 수 있을 것 같아요!

Suggested change
export interface CardError {
cardNumbersError: CardNumbersError;
expirationDateError: ExpirationDateError;
ownerError: ErrorType;
}
export interface ErrorType {
hasError: boolean;
errorMessage: string;
}
export interface CardNumberBlockError extends ErrorType {
isDisable: boolean;
}
export interface ExpirationDateError {
month: ErrorType;
year: ErrorType;
}
export interface CardNumbersError {
firstBlock: CardNumberBlockError;
secondBlock: CardNumberBlockError;
thirdBlock: CardNumberBlockError;
fourthBlock: CardNumberBlockError;
}
export interface ErrorType {
hasError: boolean;
errorMessage: string;
}
export interface CardNumberErrorType extends ErrorType {
isDisable: boolean;
}
export interface CardFormError {
cardNumbers: {
firstBlock: CardNumberErrorType;
secondBlock: CardNumberErrorType;
thirdBlock: CardNumberErrorType;
fourthBlock: CardNumberErrorType;
};
expiration: {
month: ErrorType;
year: ErrorType;
};
owner: ErrorType;
}

Comment on lines +51 to +56
const keys: CardNumbersKeys[] = [
"firstBlock",
"secondBlock",
"thirdBlock",
"fourthBlock",
];

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 상수로 분리하거나 미리 지정해둔 type 등을 사용하면 어떨까요?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • 다음과 같이 상수로 분리했습니다!
// constants/cardConstants.ts
export const CARD_INFORMATION = {
  EXPIRATION_DATE: ["month", "year"] as const,
  CARD_NUMBER_BLOCK: [
    "firstBlock",
    "secondBlock",
    "thirdBlock",
    "fourthBlock",
  ] as const,
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

Comment on lines +15 to +19
export const convertMonth = (month: string): string => {
const monthNumber = Number(month);
if (isNaN(monthNumber)) return "";
return monthNumber < 10 ? `0${monthNumber}` : `${monthNumber}`;
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

월을 썼다가 지우면 00이 계속 남아 있던데, 없앨 방법은 없을까요?

Copy link
Owner Author

@onegood07 onegood07 May 8, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • input의 onBlur를 사용해서 focus 해제 시에 값을 변환하는 걸로 바꾸어 보았습니다!
// pages/Home.tsx
// 숫자 한자리 입력 시 두자리로 반환 (ex. 3 입력 시 03으로 변환)
  const handleExpirationDateBlur = (
    date: keyof CardData["expirationDate"],
    e: React.FocusEvent<HTMLInputElement>
  ) => {
    const value = e.target.value;

    if (value.length === 1) {
      setExpirationDate((prev) => ({
        ...prev,
        [date]: `0${value}`,
      }));
    }
  };

import type { ErrorType } from "../types";

// 대문자로 변경
export const formatStringToUpper = (text: string): string => text.toUpperCase();

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 친구는 validator보다는 util의 성격을 띄고 있는 것 같아요!

Comment on lines +2 to +28
export const isOnlyNumber = (inputValue: string): boolean => {
return /^[0-9]+$/.test(inputValue);
};

// 글자수 제한
const limitToDigits = (value: string, max: number) => {
return value.length <= 2 ? value : value.slice(0, max);
};

// 숫자만 입력 가능
const inputOnlyNumber = (value: string): string => value.replace(/[^0-9]/g, "");

// 영어만 입력 가능
const inputOnlyEnglish = (value: string): string =>
value.replace(/[^a-zA-Z\s]/g, "");

// 입력한 max 수만큼 숫자만 입력 가능
export const filterNumber = (value: string, max: number) => {
const result = limitToDigits(value, max);
return inputOnlyNumber(result);
};

// 입력한 max 수만큼 영어만 입력 가능
export const filterString = (value: string, max: number) => {
const result = limitToDigits(value, max);
return inputOnlyEnglish(result);
};

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

이 부분은 html 태그의 속성을 잘 살펴보면 훨씬 간단하게 해결할 수 있어요.
(max, maxlength 등)
MDN Docs를 한번 읽어보는 것도 좋아요!

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

한번 input의 조건을 만족하면 다음 input으로 자동으로 포커스가 이동하게 설계해 보는 건 어떨까요?
(카드 번호 첫 네자리를 입력하면 다음 칸으로 focus, 카드 번호를 모두 입력하면 month로 focus....)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants