numeric input 알잘딱 포맷팅

dalbodre·2025년 6월 14일
0

numeric input 알잘딱 포맷팅

휴대전화, 사업자번호 등 사용자가 입력한 값에 자동으로 하이픈(-)을 붙여주는 간단한 로직이 필요했습니다. 분명히 이전에도 설정했던 로직인데도 또 다시 머리를 싸매게되어 미래의 나를 위해 정리해둡니다.

요구사항

  • props로 포맷 자리수를 배열로 받기 (예: [3, 4, 4], 네이밍 numberTextFormat)
  • 해당 포맷 형식으로 하이픈 자동 삽입 (01012345678 → 010-1234-5678)
  • type="text" + inputMode="numeric" (010 등 0으로 시작하는 값 반영)
  • props로 setValue,onChange 등을 통해 포맷팅된 값을 전달

1차 시도: onChange에서 포맷팅

const formatNumberText = (raw: string, format: number[]) => {
  const result: string[] = []
  let cursor = 0

  for (const len of format) {
    const part = raw.slice(cursor, cursor + len)
    if (part) result.push(part)
    cursor += len
  }

  return result.join('-')
}

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const raw = e.target.value.replace(/\D/g, '')
  const formatted = formatNumberText(raw, numberTextFormat)
  // props로 받은 value, (formatted string을 파라미터로 받는) setValue
  setValue(formatted)
}

이 방식은 입력 자체는 잘 되지만, 예상치 못한 문제가 생깁니다. 010-123 상태에서 두번째 0을 지우려고 커서를 1 앞으로 이동하여 Backspace를 누르면, -가 지워지고 010123로 변경된 value는 다시 포맷팅되어, 의도한 대로 지워지거나 이동하는 것이 아니라 버벅이는 느낌이 듭니다. 따라서 사용자가 지우려던 하이픈 앞 숫자로 자연스럽게 이동하기 위해 입력 전후 하이픈의 개수 차이를 계산해서 커서를 보정해줘야 합니다.

2차 시도: 커서 위치 보정

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
  const prevFormatted = prevFormattedRef.current

  const maxLength = numberTextFormat.reduce((a, b) => a + b, 0)
  const raw = e.target.value.replace(/\D/g, '').slice(0, maxLength)
  const newFormatted = formatNumberText(raw, numberTextFormat)
  
  // 커서 위치 보정
  const cursor = e.target.selectionStart ?? newFormatted.length
  requestAnimationFrame(() => {
    const el = inputRef.current
    if (!el) {
      return
    }

    // 입력 전후 하이픈 개수 차이 → 커서가 밀려야 하는 거리
    const prevOffset = prevFormatted.slice(0, cursor).replace(/\d/g, '').length
    const newOffset = newFormatted.slice(0, cursor).replace(/\d/g, '').length
    const diff = newOffset - prevOffset
    
    const newCursor = cursor + diff
    el.setSelectionRange(newCursor, newCursor)
  })

  setValue(newFormatted)
  prevFormattedRef.current = newFormatted
}

이 방법으로도 충분하지만, 프로젝트에서는 파일, select 등 사용자에게 입력받는 컴포넌트들이 여러가지가 있었는데요. 이들의 공통적인 처리를 위하여 외부에서 change event를 받아 사용하고싶었습니다.

3차 시도: fake event 생성

const fakeEvent = {
  ...e,
  target: { ...e.target, value: newFormatted },
}
onChange(fakeEvent)

원래 있던 이벤트에 e.target.value만 변경하면 됐기 때문에, 위와 같이 이벤트를 만들어 onChange 함수를 호출하면 될 것이라 생각했습니다. 하지만 일부 DOM 속성들은 getter로만 존재하고 enumerable하지 않기 때문에 onChange 함수를 호출할 때에 e.target.id, name 등이 없었고, Uncaught TypeError: Illegal invocation 에러도 발생했습니다. 따라서 아래와 같이 Proxy 설정으로 우회가 필요했습니다.

const fakeEvent = Object.create(e)
Object.defineProperty(fakeEvent, 'target', {
  value: new Proxy(e.target, {
    get(target, prop) {
      return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
    },
  }),
})
Object.defineProperty(fakeEvent, 'currentTarget', {
  value: new Proxy(e.target, {
    get(target, prop) {
      return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
    },
  }),
})
onChange(fakeEvent as React.ChangeEvent<HTMLInputElement>)

이렇게 하면 value만 위장하고, id, name, dataset은 원래대로 유지됩니다.

결론

  • 자연스러운 numeric input 포맷팅을 위해서는 커서 보정도 추가로 필요
  • 외부에서 일반 onChange로 호출하려면 value만 오버라이드한 fake event 생성
  • 같은 방식으로 YYYY-MM-DD, 카드번호, 사업자번호 등에 쉽게 응용 가능

아래는 textArea와 함께 사용한 코드 전문입니다.

const formatNumberText = (raw: string, format: number[]) => {
  const result: string[] = []
  let cursor = 0

  for (const len of format) {
    const part = raw.slice(cursor, cursor + len)
    if (part) result.push(part)
    cursor += len
  }

  return result.join('-')
}
const handleChange = (_e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
  _e.preventDefault()
  if (!numberTextFormat?.length) {
    onChange(_e)
    return
  }

  const e = _e as React.ChangeEvent<HTMLInputElement>
  const maxLength = numberTextFormat.reduce((a, b) => a + b, 0)
  const raw = e.target.value.replace(/\D/g, '').slice(0, maxLength)
  const newFormatted = formatNumberText(raw, numberTextFormat)
  const prevFormatted = value

  // fake event 생성
  const fakeEvent = Object.create(e)
  Object.defineProperty(fakeEvent, 'target', {
    value: new Proxy(e.target, {
      get(target, prop) {
        return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
      },
    }),
  })
  Object.defineProperty(fakeEvent, 'currentTarget', {
    value: new Proxy(e.target, {
      get(target, prop) {
        return prop === 'value' ? newFormatted : target[prop as keyof HTMLInputElement]
      },
    }),
  })
  onChange(fakeEvent as React.ChangeEvent<HTMLInputElement>)

  // 커서 위치 보정
  const cursor = e.target.selectionStart ?? newFormatted.length
  requestAnimationFrame(() => {
    const el = inputRef.current
    if (!el) return

    const prevOffset = prevFormatted.slice(0, cursor).replace(/\d/g, '').length
    const newOffset = newFormatted.slice(0, cursor).replace(/\d/g, '').length
    const diff = newOffset - prevOffset
    const newCursor = cursor + diff

    el.setSelectionRange(newCursor, newCursor)
  })
}
profile
휘뚜루마뚜루

0개의 댓글