결제 비밀번호 입력을 위한 숫자패드 컴포넌트 개발 과정

커버 이미지

결제 시스템을 작업하면서 숫자패드 컴포넌트를 직접 개발했습니다. 단순한 숫자 입력기처럼 보이지만, 실제로 구현하면서 고려해야 할 사항이 상당히 많았습니다.

왜 우리는 숫자패드(이하. Numpad)를 직접 만들었나?

핀테크 서비스에서 결제 비밀번호 입력 화면은 사용자가 가장 빈번하게, 그리고 긴장하며 마주하는 화면 중 하나입니다. 겉보기에는 숫자 6개를 입력받는 단순한 기능이지만, 실제로는 고려해야 할 사항이 많았습니다.

초기 기획 단계에서 네이티브 <input type="number"> 사용을 검토했지만, 곧 다음과 같은 한계에 봉착했습니다:

  1. 보안 취약점: 모바일이나 데스크톱 기본 키보드를 사용할 경우 키로깅 공격에 노출될 수 있으며, 화면에 숫자가 표시되면 숄더 서핑(어깨너머 훔쳐보기) 위험이 있습니다.
  2. 일관되지 않은 UX: iOS와 Android의 키패드가 각각 다르게 생겨서, 서비스만의 일관된 브랜드 경험을 유지하기 어려웠습니다.
  3. 복잡한 요구사항: 단순 결제뿐만 아니라 '비밀번호 등록', '변경(현재 비밀번호 확인 → 새 비밀번호 입력 → 재입력)', '초기화' 등 다양한 시나리오를 하나의 컴포넌트로 처리해야 했습니다.

이러한 이유로 보안, 상태 관리, 재사용성을 모두 충족하는 자체 Numpad 컴포넌트를 개발하기로 결정했습니다. 이 글에서는 설계 과정에서의 고민과 기술적 선택의 이유를 정리합니다.

설계 의도 1. 왜 전역 상태(Zustand)인가?

가장 먼저 고민한 것은 "이 컴포넌트를 어떻게 호출할 것인가?"였습니다.

일반적인 React 컴포넌트처럼 Props로 제어하는 방식은 Numpad에 적합하지 않았습니다. 결제 비밀번호 입력은 특정 페이지에 귀속되지 않고, 결제창, 마이페이지, 설정, 회원가입 등 서비스 전역에서 갑자기 나타나야 하는 모달 성격이 강했기 때문입니다.

모든 페이지에 <Numpad />를 배치하고 props를 전달하는 것은 비효율적이었습니다. "어디서든 함수 호출 한 번으로 Numpad를 띄울 수 있어야 한다"는 목표를 세웠고, 이를 위해 가볍고 직관적인 상태 관리 라이브러리인 Zustand를 선택했습니다.

// src/stores/useNumpadStore.ts
 
// ... imports
 
interface NumpadType {
  data: {
    open: boolean;
    mode?: NumpadMode; // 결제, 등록, 변경 등 모드 설정
    step: NumpadStep; // 다단계 입력 처리를 위한 단계
    onBeforeClose?: (val: string) => Promise<boolean | string>; // 제어권 역전을 위한 콜백
    // ...
  };
  setData: (data: Partial<NumpadType["data"]>) => void;
  // ...
}
 
export const useNumpadStore = create<NumpadType>((set, get) => ({
  data: {
    open: false,
    mode: NUMPAD_MODE.DEFAULT,
    step: NUMPAD_STEP.ONE,
    // ... 기본값 설정
  },
  setData: (data) =>
    set((state) => ({
      data: { ...state.data, ...data },
    })),
  // ...
}));

이러한 설계 덕분에 개발자는 어떤 컴포넌트에서든 다음과 같이 간단하게 Numpad를 호출할 수 있게 되었습니다.

// 사용 예시
numpadStore.setData({
  open: true,
  mode: NUMPAD_MODE.PAYMENT,
  onBeforeClose: async (password) => {
    // 비밀번호 검증 로직 수행
    return await verifyPassword(password);
  },
});

설계 의도 2. 상태가 UI를 그린다 (선언적 UI)

Numpad는 단순히 숫자만 입력받는 것이 아니라, 현재 상황(mode)과 진행 단계(step)에 따라 사용자에게 다른 안내 메시지를 보여줘야 합니다.

switch-case 문으로 로직을 분산시키지 않고, 상태값이 변경될 때 텍스트가 자동으로 계산되도록 useMemo를 활용했습니다. 렌더링 최적화 효과도 있지만, "상태에서 파생되는 데이터를 명시적으로 선언"하여 코드의 가독성을 높이려는 의도가 더 컸습니다.

// src/components/numpad/Numpad.tsx
 
const modeText = useMemo(() => {
  // 1. 결제 모드일 때
  if (mode === NUMPAD_MODE.PAYMENT) {
    return "비밀번호를 입력해 주세요.";
  }
 
  // 2. 비밀번호 변경 모드일 때 (다단계 플로우)
  if (mode === NUMPAD_MODE.MODIFY) {
    switch (step) {
      case NUMPAD_STEP.ONE:
        return "비밀번호를 입력해 주세요."; // 구 비밀번호 확인
      case NUMPAD_STEP.TWO:
        return "새로운 비밀번호를 입력해 주세요."; // 신규 비밀번호 입력
      case NUMPAD_STEP.THREE:
        return "다시 한번 입력해 주세요."; // 신규 비밀번호 확인
    }
  }
 
  // ... 3. 등록, 초기화 모드 등
 
  return "";
}, [mode, step]);

이 방식 덕분에 기획 변경으로 문구가 바뀌거나 새로운 단계가 추가되더라도, 이 useMemo 블록만 수정하면 안전하게 반영할 수 있습니다.

핵심 구현 1. 보안은 타협할 수 없다

자체 키패드를 만드는 가장 큰 이유는 보안입니다. 다음과 같은 세 가지 방어 기제를 구현했습니다.

1) 엿보기 방지 (Randomized Keypad)

사용자가 고정된 위치(예: 1은 왼쪽 위)를 누르는 것에 익숙해지면, 공격자는 손가락의 위치만 보고도 비밀번호를 유추할 수 있습니다. 또한 화면에 묻은 지문 자국도 힌트가 될 수 있습니다.

이를 방지하기 위해 Numpad가 열릴 때마다 숫자 배열을 무작위로 섞는 sortNumbers 기능을 구현했습니다.

// src/components/numpad/Numpad.tsx
 
const sortNumbers = useCallback(() => {
  if (sort) {
    // 단순하지만 효과적인 셔플
    setNumberArray((prev) => [...prev].sort(() => Math.random() - 0.5));
  }
}, [sort]);
 
useEffect(() => {
  if (open) {
    sortNumbers(); // 모달이 열릴 때마다 키패드 재배열
    // ...
  }
}, [open, sortNumbers]);

참고: Math.random()은 암호학적으로 완벽한 난수는 아니지만, 키패드 위치를 섞는 UI 로직에는 충분한 무작위성을 제공한다고 판단하여 적용했습니다.

2) 취약한 비밀번호 차단 (Client-side Validation)

"123" 같은 연속 숫자, "111" 같은 반복 숫자, 생년월일, 전화번호 등은 유추하기 쉬운 비밀번호입니다. 서버에서도 검증하지만, 클라이언트에서 즉각적인 피드백을 제공하는 것이 UX 측면에서 훨씬 효과적입니다.

정규식을 적극 활용하여 사용자가 입력한 직후 빠르게 검증하도록 구현했습니다.

// src/components/numpad/Numpad.tsx
 
async function validateNewPassword(val: string) {
  // 사용자 정보 가져오기 (생일, 전화번호 등)
  const userInfo = await checkIdentifyVerification();
 
  // 연속 숫자 패턴 체크 정규식
  const REGEX_CONSECUTIVE = /.../;
  // 동일 숫자 반복 패턴 체크 정규식
  const REGEX_REPEATED = /.../;
 
  if (REGEX_CONSECUTIVE.test(val)) {
    // 즉시 오류 모달 표시 후 입력 초기화
    modalStore.setModalStatus({
       desc: "연속되는 3자리 숫자는 사용하실 수 없어요.",
       onConfirm: () => numpadStore.setData({ value: "" })
    });
    return true;
  }
  // ... 생년월일, 전화번호 포함 여부 검사 로직
  return false;
}

3) 무차별 대입 공격(Brute-Force) 방어

공격자가 비밀번호를 맞출 때까지 계속 시도하는 것을 막기 위해, 일정 횟수 이상 오류 시 잠금 처리 정책을 적용했습니다.

이 로직에서 중요한 것은 서버의 데이터를 전적으로 신뢰해야 한다는 점입니다. 클라이언트 상태만으로는 새로고침 등으로 횟수를 초기화할 수 있기 때문입니다.

// src/components/numpad/Numpad.tsx
 
async function validateCurrentPassword(val: string) {
  // 1. 서버에 비밀번호 검증 요청
  // HTTPS를 통해 전송되므로 TLS가 전송 구간 암호화를 처리합니다.
  const { isMatch, authStatus } = await request("/api/v1/auth/verify-pin", {
    body: { pin: val }
  });
 
  if (!isMatch) {
    // 2. 서버가 알려준 실패 횟수 확인 (MAX_FAIL_COUNT: 임계값 상수)
    if (authStatus.failCount >= MAX_FAIL_COUNT) {
      // 3. 임계값 초과 시 강제 재설정 플로우 진입
      modalStore.setModalStatus({
        desc: "비밀번호 입력 횟수를 초과하여<br/>비밀번호 재설정을 진행합니다.",
        onConfirm: () => handleResetPassword()
      });
      return true;
    }
    // ... 안내 모달 표시
    return true;
  }
  return false;
}

보안 검증 및 모바일 UI 테스트

보안 기능을 구현한 뒤에는 실제 효과를 검증하는 과정이 빠질 수 없었습니다.

키로깅 방지 검증

자체 Numpad를 만든 가장 큰 이유가 키로깅 방지였기 때문에, 이 부분은 꼼꼼하게 확인했습니다. 네이티브 <input> 대신 커스텀 버튼의 onClick으로만 입력을 처리하므로 keydown, keyup, keypress 이벤트 자체가 발생하지 않습니다. DevTools Event Listeners 패널에서 키보드 이벤트가 등록되지 않는 것을 확인했고, 입력값이 DOM에 평문으로 노출되지 않는 점도 검증했습니다.

비밀번호 전송은 HTTPS(TLS)를 통해 이루어지므로 전송 구간 암호화는 프로토콜 레벨에서 보장됩니다. 서버 측에서는 수신한 PIN을 해싱하여 저장된 값과 비교하는 방식으로 검증합니다.

모바일 UI/UX 테스트

Numpad는 모바일에서 가장 자주 쓰이는 컴포넌트인 만큼, iOS Safari와 Android Chrome 양쪽에서 집중적으로 테스트했습니다.

  • 키패드 랜덤 배열 -- iOS, Android 모두 정상 동작
  • 터치 반응 속도 -- 양 플랫폼 50ms 이내로 즉각 반응
  • 배경 스크롤 잠금 / 슬라이드 애니메이션 -- 60fps 유지, 양쪽 이상 없음
  • 소프트 키보드 미노출 -- 커스텀 버튼 방식이므로 시스템 키보드가 뜨지 않는 것을 확인

iOS Safari에서는 overflow: hidden 적용 시 스크롤 위치가 초기화되는 이슈가 있었는데, 아래 UX 섹션에서 다룬 스크롤 위치 저장/복원 로직으로 해결했습니다. iPhone SE부터 iPad, Galaxy S 시리즈부터 Galaxy Tab까지 다양한 화면 크기에서 키패드 버튼 크기와 간격을 수동 검수했고, 최소 터치 영역 44x44px 가이드라인을 준수하도록 조정했습니다.

핵심 구현 2. 유연한 제어권을 위한 콜백 패턴 (Inversion of Control)

범용 컴포넌트를 만들 때 가장 흔한 실수는 컴포넌트 내부에 특정 비즈니스 로직(예: 결제 API 호출)을 강하게 결합하는 것입니다. 이렇게 되면 결제 외에 '본인 인증' 등 다른 곳에서 사용하기 어려워집니다.

onBeforeClose라는 콜백 함수를 상태로 받아, 제어의 역전(Inversion of Control)을 구현했습니다. Numpad는 "입력이 끝났다"는 사실만 알릴 뿐, 그 입력값으로 무엇을 할지는 Numpad를 호출한 쪽에서 결정합니다.

// Numpad 내부 로직
async function handlePayment(val: string) {
  // ...
  // Numpad는 무엇을 할지 모른다. 호출자가 전달한 함수를 실행할 뿐이다.
  if (onBeforeClose !== undefined) {
    const res = await onBeforeClose(val);
 
    // 호출자가 false를 반환하면 닫지 않고 유지 (예: 비밀번호 틀림)
    if (res === false) return;
  }
  // ...
}

이 패턴 덕분에 Numpad 컴포넌트 코드를 수정하지 않고도 다양한 요구사항을 수용할 수 있었습니다.

UX 향상을 위해 했던 고민들

result

사용자는 Numpad가 '웹 컴포넌트'가 아닌 '앱의 일부'처럼 느껴지길 원합니다. 이를 위해 사소한 UX 디테일도 놓치지 않았습니다.

  1. 배경 스크롤 잠금: 모달이 떴을 때 뒤에 있는 페이지가 스크롤 되면 산만해 보입니다. useEffect에서 bodyoverflow-hidden을 적용하여 스크롤을 막고, 현재 스크롤 위치를 기억했다가 닫힐 때 복원합니다.
  2. 애니메이션 제어: isTransitioning 상태를 두어 슬라이드 애니메이션이 진행 중일 때는 입력을 차단하여 오동작을 방지하고 부드러운 느낌을 줍니다.
// src/components/numpad/Numpad.tsx
 
useEffect(() => {
  if (open) {
    // 현재 스크롤 위치 저장 및 잠금
    scrollHeight.current = document.documentElement.scrollTop;
    document.body.classList.add("overflow-hidden");
    document.body.style.top = `-${scrollHeight.current}px`;
  } else {
    // 닫힐 때 스크롤 복원
    document.body.classList.remove("overflow-hidden");
    if (scrollHeight.current !== -1) scrollTo(0, scrollHeight.current);
  }
  // ...
}, [open]);

마치며..

단순해 보이는 '숫자 입력기' 하나를 만드는 데에도 아키텍처, 보안, UX, 재사용성에 대한 수많은 고민이 필요했습니다.

정리하자면:

  • Zustand를 통해 복잡한 전역 모달 로직을 깔끔하게 추상화했고,
  • 선언적 UI 패턴으로 상태에 따른 화면 변화를 예측 가능하게 만들었으며,
  • 철저한 검증 로직콜백 패턴으로 보안과 유연성을 동시에 확보했습니다.

결과적으로 개발팀은 어떤 기능이든 numpadStore.open() 한 줄로 안전하고 통일된 결제 경험을 사용자에게 제공할 수 있게 되었습니다. 이 Numpad 컴포넌트는 현재 결제 시스템의 모든 결제와 인증 과정에서 핵심적인 역할을 수행하고 있습니다.