날짜 입력을 위한 정규 표현식

#regex#javascript#input-validation
• • •

텍스트 인풋으로 날짜 입력 받기

웹에서 날짜 입력을 커스텀하려면 <input type="date"> 대신 <input type="text">를 사용하게 된다. 브라우저 기본 날짜 인풋은 스타일링과 동작을 제어하기 어렵기 때문이다.

텍스트 인풋을 쓰면 자유도는 높아지지만, 그만큼 직접 처리해야 할 것들이 생긴다:

  • 숫자 외 문자 입력 제한
  • 구분자(., - 등) 자동 삽입
  • 삭제 시 포맷이 깨지지 않도록 처리

이 글에서는 yyyy.MM.dd 형식의 날짜 입력을 정규 표현식으로 다루는 방법을 정리한다.

<input type="text" />
<script src="index.js"></script>

숫자만 입력 받기

날짜 입력의 기본은 숫자다. keydown 이벤트에서 숫자가 아닌 키 입력을 차단한다.

const input = document.querySelector('input');

input.addEventListener('keydown', (e) => {
  if (!e.key.match(/\d/)) {
    e.preventDefault();
  }
});

\d[0-9]와 같은 의미의 메타 문자로, 숫자 한 자리에 매칭된다.

하지만 이렇게 하면 방향키, 백스페이스, 엔터까지 막혀버린다. 허용할 키를 화이트리스트로 정의한다.

input.addEventListener('keydown', (e) => {
  const whitelist = /(\d|ArrowLeft|ArrowRight|Backspace|Enter)/;

  if (!whitelist.test(e.key)) {
    e.preventDefault();
  }
});

test()는 문자열이 정규식 패턴에 매칭되는지를 boolean으로 반환한다. match()가 매칭 결과 배열을 반환하는 것과 달리, 단순히 매칭 여부만 확인할 때 적합하다.

자동 포맷팅: 캡처링 그룹과 replace

숫자만 입력받으므로 구분자 .은 자동으로 삽입해야 한다. 아이디어는 이렇다:

  1. 입력값에서 숫자만 추출한다
  2. 캡처링 그룹으로 연·월·일을 분리한다
  3. replace()의 치환 패턴 $n으로 구분자를 끼워 넣는다
input.addEventListener('input', (e) => {
  let date = e.target.value;

  if (e.inputType === 'insertText') {
    date = date
      .replace(/[^0-9]/g, '')
      .replace(/^(\d{0,4})(\d{0,2})(\d{0,2})$/g, '$1.$2.$3')
      .replace(/(\.{2})/g, '');
  }

  input.value = date;
});

각 단계를 풀어보면:

  • /[^0-9]/g — 숫자가 아닌 문자를 모두 제거한다. 이전 포맷팅에서 삽입된 .도 여기서 정리된다.
  • /^(\d{0,4})(\d{0,2})(\d{0,2})$/g — 숫자열을 4자리, 2자리, 2자리 그룹으로 나눈다. {0,4}처럼 최솟값을 0으로 두면 입력 중간에도 포맷팅이 동작한다. 치환 문자열 '$1.$2.$3'에서 $1, $2, $3은 각 캡처링 그룹에 매칭된 값으로 대체된다.
  • /(\.{2})/g — 아직 입력이 짧을 때 2025. 뒤에 빈 그룹이 .을 만들어 2025..이 되는 것을 방지한다.

e.inputType === 'insertText' 분기는 중요하다. 백스페이스로 삭제할 때도 input 이벤트가 발생하는데, 이때 포맷팅이 다시 실행되면 구분자가 재삽입되어 삭제가 동작하지 않는다. 텍스트 입력 시에만 포맷팅을 적용한다.

날짜 범위로 확장하기

단일 날짜가 아닌 yyyy.MM.dd - yyyy.MM.dd 형식의 기간을 입력받아야 한다면, 캡처링 그룹을 6개로 늘린다.

const regexPatterns = [
  { regex: /[^0-9]/g, replaceWith: '' },
  {
    regex: /^(\d{0,4})(\d{0,2})(\d{0,2})(\d{0,4})(\d{0,2})(\d{0,2})$/g,
    replaceWith: '$1.$2.$3 - $4.$5.$6',
  },
  { regex: /(\.{2})/g, replaceWith: '' },
  { regex: /\s*-\s*(?!\s*\d)/g, replaceWith: '' },
];

input.addEventListener('input', (e) => {
  let date = e.target.value;

  if (e.inputType === 'insertText') {
    date = regexPatterns.reduce(
      (value, { regex, replaceWith }) => value.replace(regex, replaceWith),
      date
    );
  }

  input.value = date;
});

추가된 정규식 하나만 살펴보면:

/\s*-\s*(?!\s*\d)/g — 시작 날짜만 입력한 상태에서 하이픈이 홀로 남는 것을 제거한다. (?!\s*\d)부정 전방 탐색(negative lookahead)으로, 하이픈 뒤에 숫자가 오지 않는 경우에만 매칭한다. 즉 종료 날짜 입력이 시작되면 하이픈은 유지된다.

한계

정규식만으로 날짜 포맷팅을 다루면 커서 위치를 고려할 수 없다는 근본적 한계가 있다. 사용자가 순서대로 입력하고 순서대로 지울 때는 잘 동작하지만, 커서를 중간으로 옮겨 구분자를 지우거나 여러 자리를 한꺼번에 삭제하면 포맷이 깨진다.

lookbehind 등 복잡한 정규식을 추가해 엣지 케이스를 하나씩 막을 수는 있지만, 정규식이 늘어날수록 유지보수가 어려워지고 여전히 커버하지 못하는 케이스가 남는다.

더 견고한 접근이 필요하다면 입력 영역을 연·월·일로 분리하거나, 각 섹션의 커서 위치와 선택 범위를 직접 관리하는 방식을 고려해야 한다.

published almost 3 years ago · last updated 7 minutes ago