smob_logo
smob_logo
header-image
REACTPOS 결제 모달 리팩토링부수효과 제거와 상태 관리 최적화2024.08.25

부수효과: 함수나 코드가 자신의 입력값 이외의 외부 상태(전역 변수, 파일 시스템, 네트워크 등)를 변경하거나 참조할 때 발생하는 영향 (출처: ChatGPT)

매장 POS시스템에서 결제 작업을 결제 모달 컴포넌트을 통해 이루어집니다. 그런데 결제 모달에서 예기치 않은 문제들이 빈번하게 발생하면서 QA 채널에 다양한 오류 보고가 접수 되었습니다. 예를 들어:

  • 결제 완료 후 결제/상세 페이지로 이동하지 않음
  • 무한 로딩 현상
  • 모달이 자동으로 열리고 닫히는 오류

이러한 간헐적으로 발생하는 문제를 해결하기 위해 부수효과를 없애는 리팩토링을 하기로 결정하게 되었습니다.

주문서 정보 암묵적 인자 제거

암묵적인자: 함수나 메서드가 명시적으로 전달받지 않고도 사용할 수 있는 인자를 말합니다. 이러한 인자는 함수 외부의 상태, 전역 변수, 클래스의 속성, 혹은 다른 컨텍스트에서 암묵적으로 접근할 수 있는 값입니다. 즉, 함수에 명시적으로 전달되지 않았지만, 함수 내에서 참조하거나 사용하는 모든 외부 상태가 암묵적 인자라고 할 수 있습니다. (출처: ChatGPT)

결제 모달 컴포넌트는 일반 판매, 퇴장 정산, 시즌 패스, 결제/상세 페이지 등의 다양한 곳에서 사용됩니다. 이때, 결제에 필요한 데이터는 전역 상태에서 직접 가져와 처리하고 있었습니다.

// 기존 코드 예시
const 결제_모달_컴포넌트 = (...) => {
	...
	// 장바구니에 담겨진 상품 결제할 때 필요한 state
	const cart = useRecoilValue(cartState);
	// 퇴장정산 결제 할 때 필요한 state
	const [exitBandList, setExitBandList] = useRecoilState(realExitBandListAtom);
	// 후할인 적용하여 결제할 때 필요한 state
	const [discountAfterPayList, setDiscountAfterPayList] = useRecoilState(DiscountAfterPayListAtom);
	// 결제/상세페이지에서 환불할 때 필요한 state
	const [selectedProductKeys, setSelectedProductKeys] = useRecoilState(SelectedProductsKeysAtom);
	...
	const getOrderController = () => {
		// 상태에 따라 주문서 생성 로직 분기
	  if(cartState) {
	    // 장바구니 결제 로직
	  } else if(exitBandState) {
	    // 퇴장 정산 로직
	  } else {
	    // 기타 로직
	  }
	}

	useEffect(() => {
    (async () => {
      ...
      await getOrderController();
    })();
  }, [...]);

  return (
	  ...
  )
}

이 코드의 문제는 결제 모달 컴포넌트가 전역 상태에 의존한다는 점입니다. 각 결제 방식에 맞는 상태를 전역에서 직접 가져오다 보니, 관리가 어렵고 부수 효과(Side Effect)가 발생하기 쉽습니다. 이로 인해 예기치 않은 오류가 자주 발생했습니다.

결제 모달 컴포넌트를 사용하는 페이지마다 결제 데이터를 다르게 관리하지만, 궁극적으로 주문서 생성이라는 동일한 작업을 수행합니다. 그래서 **추상 클래스(Abstract Class)**를 사용해 공통된 인터페이스를 작성했습니다..

스크린샷 2024-09-06 09.54.17.png
abstract class OrderForPayment {
  abstract get_오더번호();
  abstract get_주문내역();
  abstract get_상품_금액();
  abstract get_할인_금액();
  abstract get_총_결제_금액();
}

이 추상 클래스를 상속받아, 페이지별로 필요한 주문서 생성 로직을 구현했습니다.

class CartOrderForPayment extends OrderForPayment {
	...장바구니 상태값이 주문서를 만드는 비즈니스 로직
}

class ExitBandOrderForPayment extends OrderForPayment {
	... 퇴장정산 시 필요한 주문서를 만드는 비즈니스 로직
}

... 기타 등등

리팩토링된 결제 모달은 이제 주문서를 위한 전역 상태를 참조하지 않습니다. 대신, 결제모달을 사용하는 컴포넌트에서 주문서 데이터를 인자로 전달받아 처리하도록 변경했습니다.

interface 결제_모달_컴포넌트_Props {
	...;
	// 입력 인자로 수정
+	주문서_정보: OrderForPayment;
}

+ const 결제_모달_컴포넌트 = ({..., 주문서_정보}: 결제_모달_컴포넌트_Props) => {
	// 전역 상태 참조 코드 제거
-	// 장바구니에 담겨진 상품 결제할 때 필요한 state
-	const cart = useRecoilValue(cartState);
-	// 퇴장정산 결제 할 때 필요한 state
-	const [exitBandList, setExitBandList] = useRecoilState(realExitBandListAtom);
-	// 후할인 적용하여 결제할 때 필요한 state
-	const [discountAfterPayList, setDiscountAfterPayList] = useRecoilState(DiscountAfterPayListAtom);
-	// 결제/상세페이지에서 환불할 때 필요한 state
-	const [selectedProductKeys, setSelectedProductKeys] = useRecoilState(SelectedProductsKeysAtom);

-	// 위의 정의된 데이터에 따라 각자 맞는 방법(?)으로 Order를 만들어 주고 있었음.
-	const getOrderController = useCallback(async () => {
-		 const orderController = new OrderController({
-      ...
-    });

-	  // 각각에 State Type(?)에 따른 분기 처리
-    if(...) {
-    } else if(...) {
-    } else (...) {
-    }
-	}, [...]);

-	useEffect(() => {
-    (async () => {
-      ...
-      await getOrderController();
-    })();
-  }, [...]);

	return (
		...
	)
}

이렇게 함으로써 결제 모달 컴포넌트는 더 이상 전역 상태에 의존하지 않고, 결제모달 컴포넌트를 사용하는 곳에서 주문서 객체를 받아 처리할 수 있게 되었습니다.

결제 처리 후 작업을 위한 Callback 인자 추가

스크린샷 2024-09-06 10.54.34.png

저희 회사 POS 결제는 지점마다 설정이 다르게 되어 있어, 결제 방식(신용카드, 현금 등)도 다양합니다. 현재 결제 관련 컴포넌트는 전역 상태에서 결제 설정 값을 가져와서 컴포넌트 내부에서 처리하고 있습니다.

예를 들어, 아래는 현금 결제 컴포넌트의 코드입니다:

const 현금_결제_컴포넌트 = (...) => {
	...
	const 결제설정값 = useRecoilValue(결제_설정_State);

	const handleTerminalPayment = (...) => {
    // 터미널 타입 결제 처리
    ...
  };

  const handleDonglePayment = (...) => {
    // 동글 타입 결제 처리
  };

	...

	const proceedPayment = () => {
		// 다양한 조건 분기 처리
		if(개발환경일_경우) {
			...
		} else if(결제 단말기_1로 설정되어 있는 경우) {
			handleTerminalPayment();
		} else (다른 결제단말기로 설정되어 있는 경우) {
			handleDonglePayment();
		}
	}
}

주문서 생성 방식과 동일하게 결제 처리 로직도 동일하게 리팩토링할 수 있었습니다. 하지만 결제 후에는 결제 결과값에 따라 후속 작업을 처리할 수 있도록, 해당 작업도 인자로 받아야 합니다.

// 기존 결제 모달 컴포넌트 내부에서 결정된 결제 이후 행위
const 결제_모달_컴포넌트 = () => {
	const afterPayActionController = new ActionAfterPayController({
	  // 아까 삭제한 전역 상태값들
	  orderUid: orderUidProps,
	  parkUid,
	  exitBandList,
	  discountList: discountAfterPayList,
	  selectedProductKeys,
	  CAT: enablePOS ? await getCAT() : '99',
	});

	// 할인 적용에서 결제 후 실행할 행위
	if (afterPayActionController.actionType === 'discount') {
	  ...
	// 퇴장정산에서 결제 후 실행할 행위
	} else if (afterPayActionController.actionType === 'exit') {
	  ...
	} else if (afterPayActionController.actionType === 'refund') {
	  ...
	} else {
	  ...
	}

	...
};

먼저, 주문서를 추상화한 클래스와 마찬가지로, 결제 로직을 처리할 인터페이스를 정의합니다.

interface PaymentProcessor {
  /**
   * 현금 결제
   */
  paymentByCash: (...) => 결제결과값;

  /**
   * 신용카드 결제
   */
  paymentByCreditCard: (...) => 결제결과값;

  /**
   * 신용카드 수기 결제
   */
  paymentByHandWrittenCreditCard: (...) => 결제결과값;

  /**
   * 계좌이체
   */
  paymentByWireTransfer: (...) => 결제결과값;
}

이 인터페이스를 기반으로, 각 비즈니스 로직에 맞는 결제 구현 클래스를 생성합니다.

class DevelopmentPayment implements PaymentProcessor {
  // 개발환경에서의 결제 로직
}

class TerminalPayment implements PaymentProcessor {
  // 터미널 타입의 결제 로직
}

class DonglePayment implements PaymentProcessor {
  // 동글 타입의 결제 로직
}

결제 컴포넌트에서 전역 상태에 따라 결정되던 값들은 이제 인자로 전달받도록 수정합니다.

interface 현금_결제_컴포넌트_Props {
+	결제로직: PaymentProcessor;
}

const 현금_결제_컴포넌트 = ({ 결제로직 }: 현금_결제_컴포넌트_Props) => {
	// 기존의 로직들은 컴포넌트를 사용하는 곳에서 인자로 전달
-	const 결제설정값 = useRecoilValue(결제_설정_State);

-	const handleTerminalPayment = (...) => {
-    // 터미널 타입 결제 처리
-    ...
-  };

-  const handleDonglePayment = (...) => {
-    // 동글 타입 결제 처리
-    ...
-  };
	...

+	const proceedPayment = 결제로직.현금결제;

	return (
		...
	)
}

결제 성공 후 결과값에 대한 행위도 인자로 전달받아, Tanstack Query(React Query)를 사용해 콜백 함수로 처리할 수 있습니다.

interface 현금_결제_컴포넌트_Props {
	결제로직: PaymentProcessor;
+	afterAddPayment: (결제결과값) => void;
}

const 현금_결제_컴포넌트 = ({ 결제로직 }: 현금_결제_컴포넌트_Props) => {
+	 const { mutate: addPaymentByCash, isLoading } = useMutation({
+    mutationFn: paymentByCash,
+    onSuccess: (결제결과값) => {
+			afterAddPayment(결제결과값);
+    }
+  });
  ...
}

결제 예외 상황에 대한 처리도 필요한데, 기존 코드에서 다양한 예외 처리가 이루어지고 있었습니다.

// 기존 코드 예시
const proceedPayment = (...) => {
	const result = await 결제();
	if(result.error === "01") {
		Modal.error("결제 정보가 누락되었습니다");
	} else (result.error === "02") {
		message.error("결제 단말기 연결이 되어있지않습니다");
	}...
}

이러한 예외처리들을 컴포넌트를 사용하는 측에서 콜백으로 처리할 수 있도록 커스텀 예외 객체를 생성했습니다.

// 현금 연수증 오류
class CashReceiptHardwareException extends Error {
	...
}
// 단말기 연결 오류
class DisconnectedDeviceException extends Error {
	...
}

... 기타 등등

PaymentProcessor 구현체에서 로직에 맞게 예외 처리를 진행합니다.

 class 결제구현체 implements PaymentProcessor {
	async 현금결제() {
		const result = await 현금결제하기();

		if(영수증 X) throw new CashReceiptHardwareException();

		else if(단말기 연결 X) throw new DisconnectedDeviceException();
		...
	}
}

이제 예외 처리 콜백도 인자로 전달받습니다.

interface 현금_결제_컴포넌트_Props {
	결제로직: PaymentProcessor;
	afterAddPayment: (결제결과값) => void;
	afterCashReceiptError: (error: CashReceiptHardwareException) => void;
	afterDisconnectedDeviceError: (error: DisconnectedDeviceException) => void;
}

const 현금_결제_컴포넌트 = ({
	결제로직,
	afterAddPayment,
	afterCashReceiptError,
	afterDisconnectedDeviceError
}: 현금_결제_컴포넌트_Props) => {

	const { mutate: addPaymentByCash, isLoading } = useMutation({
		mutationFn: paymentByCash,
		useErrorBoundary: false, // 해당 설정이 되어있어야 onError에서 예외처리할 수 있습니다.
		onSuccess: afterAddPayment,
		onError: (error: unknown) => {
			if (error instanceof CashReceiptHardwareError) {
				afterCashReceiptError(error);
			} else if(error instanceof DisconnectedDeviceException) {
				afterDisconnectedDeviceError(error);
			} ....
		}
	});

  ...
}

이제 결제 모달을 사용하는 각 페이지에서 요구사항에 맞는 로직을 처리할 수 있게 되었습니다.

const 일반판매_페이지 = () => {
	return (
		...
		<결제모달
			주문서_정보={...}
			afterAddPayment={() => {
				// 결제 정보 노출
				// 결제 완료 후 결제/상세 페이지로 이동
			}}
			afterCashReceiptError={() => {
				// 모달에 에러 메시지 노출
			}}
			결제로직={new TerminalPayment()}
			...
		/>
	);
}

const 퇴장정산_페이지 = () => {
	return (
		...
		<결제모달
			주문서_정보={...}
			afterAddPayment={() => {
				// 결제 정보 노출
				// 결제 완료 후 결제 모달 창 닫기
			}}
			afterCashReceiptError={() => {
				// 메시지 컴포넌트로 에러 메시지 노출
			}}
			결제로직={new TossPayment()}
			...
		/>
	);
}

마무리

  • 각 결제 모달을 사용하는 컴포넌트와의 결합도가 낮아져 더 유연하게 관리 가능
  • 상태 관리가 간소화되어 코드의 가독성과 유지보수성이 향상
  • 오류 발생 시 추적이 더 쉬워져, 디버깅 용이
  • 확장성이 개선되어, 새로운 결제 방식을 추가하거나 다른 컴포넌트에 결제 모달을 쉽게 추가 가능
  • 비즈니스 로직에 대한 단위 테스트 작성이 용이
#react
#refactoring
#frontend
avatar
By. 정규재안녕하세요. 잘 부탁 드립니다.