어느 금요일 오후, 회원가입 폼에 이상한 값이 들어온다는 제보를 받았다. 이메일 칸에 abc@만 적어도 가입이 되고 있었다. "그거 정규식 하나면 되지 않아요?" 옆자리에서 던진 말이 맞긴 했는데, 문제는 그 '하나'를 만드는 데 반나절이 걸렸다는 점이다. 인터넷에서 복사한 패턴은 동작하는 듯하다가 한글 도메인에서 터졌고, 급하게 고친 패턴은 정상적인 주소까지 막아버렸다.

정규표현식(regular expression)은 개발자라면 언젠가 반드시 만나는 도구다. 그런데 대부분은 제대로 배운 적 없이 검색해서 복사해 쓴다. 그래서 매번 처음처럼 어렵다. 오늘은 외울 기호를 열두 개로 줄이고, 실무에서 자주 쓰는 패턴을 상황별로 정리해 보려 한다.

정규식은 '문자열 지도'를 그리는 일이다

정규식을 어렵게 느끼는 이유는 기호가 낯설어서가 아니라, 무엇을 찾을지 스스로 정의하지 못한 채 패턴부터 쓰기 때문이다. 전화번호를 찾겠다고 마음먹었다면 먼저 말로 풀어야 한다. "숫자 2~3개, 하이픈, 숫자 3~4개, 하이픈, 숫자 4개."

이 문장을 그대로 기호로 옮기면 정규식이 된다. 순서대로 대응시키면 이렇다.

말로 쓴 조건정규식 조각
숫자 하나\d
2~3개 반복{2,3}
하이픈-
숫자 3~4개\d{3,4}

합치면 \d{2,3}-\d{3,4}-\d{4} 다. 어렵지 않다. 정규식 실력은 기호 암기가 아니라 조건을 문장으로 쪼개는 습관에서 나온다.

열두 개만 알면 8할은 해결된다

실무에서 쓰는 정규식의 대부분은 아래 기호 조합이다. 이것만 손에 익혀도 검색 없이 쓸 수 있는 패턴이 확 늘어난다.

  • . 아무 글자 하나 / \d 숫자 / \w 영문·숫자·밑줄 / \s 공백
  • * 0번 이상 / + 1번 이상 / ? 0번 또는 1번
  • {n,m} n번에서 m번 / [] 이 중 하나 / [^] 이것만 빼고
  • ^ 문자열 시작 / $ 문자열 끝

여기에 대문자 버전은 반대라는 규칙 하나만 더 얹으면 된다. \D는 숫자가 아닌 것, \S는 공백이 아닌 것이다. 나머지 고급 문법은 필요할 때 찾아 써도 늦지 않다.

정규식은 다 외우는 게임이 아니다. 자주 쓰는 열두 개를 확실히 알고, 나머지는 필요할 때 찾는 게 훨씬 빠르다.

실무에서 반복되는 네 가지 패턴

가장 자주 만나는 상황을 정리해 두면 복사·붙여넣기 사고를 줄일 수 있다.

AD
<?php
// 1) 이메일 — 완벽 검증보다 '명백한 오타 거르기'가 현실적이다
$email = '/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/u';

// 2) 국내 휴대폰 번호 (하이픈 있어도 없어도 통과)
$phone = '/^01[016789]-?\d{3,4}-?\d{4}$/';

// 3) 날짜 YYYY-MM-DD 형태만 확인 (실제 유효성은 별도 검사)
$date  = '/^\d{4}-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])$/';

// 4) 앞뒤 공백과 중복 공백 정리
$clean = preg_replace('/\s+/u', ' ', trim($raw));

if (preg_match($phone, $input)) {
    echo "형식 통과";
}

이메일 패턴을 일부러 느슨하게 잡은 점을 눈여겨보길 바란다. RFC 규격을 전부 반영한 이메일 정규식은 수백 자에 달하고, 그렇게 만들어도 실제 수신 가능 여부는 확인해 주지 못한다. 형식은 가볍게 거르고 진짜 검증은 인증 메일 발송으로 하는 편이 사고가 적다.

그룹과 치환 — 찾는 것보다 바꾸는 게 더 강력하다

정규식의 진짜 힘은 검색이 아니라 치환에 있다. 괄호로 묶은 부분을 나중에 $1, $2로 꺼내 쓸 수 있기 때문이다.

import re

raw = "주문번호 20260904-1123, 결제 45,000원"

# 날짜와 일련번호를 분리해서 다시 조립
print(re.sub(r"(\d{4})(\d{2})(\d{2})-(\d+)",
             r"\1년 \2월 \3일 접수 (#\4)", raw))
# 결과: 주문번호 2026년 09월 04일 접수 (#1123), 결제 45,000원

# 이름을 붙이면 읽기가 훨씬 편해진다
m = re.match(r"(?P<y>\d{4})-(?P<m>\d{2})", "2026-09")
print(m.group("y"), m.group("m"))

로그 파일에서 특정 값만 뽑아 CSV로 정리하거나, 오래된 게시글의 옛 도메인 주소를 한 번에 바꿔야 할 때 이 방식이 큰 힘을 발휘한다. 손으로 하면 하루 걸릴 일이 몇 줄로 끝난다.

조심해야 할 두 가지 함정

첫째, 탐욕적(greedy) 매칭이다. *+는 기본적으로 가능한 한 길게 먹으려 한다. HTML에서 태그 하나만 뽑고 싶어 <.*>라고 쓰면 첫 <부터 마지막 >까지 통째로 잡힌다. ?를 붙여 <.*?>로 쓰면 최소 범위로 멈춘다.

둘째, 성능이다. 중첩 반복이 들어간 패턴은 입력이 조금만 길어져도 처리 시간이 폭발적으로 늘어난다. 이른바 재앙적 백트래킹이다. 사용자 입력을 그대로 정규식으로 검사하는 자리라면 입력 길이를 먼저 제한하고, 복잡한 패턴 대신 explodesplit 같은 단순한 문자열 함수로 나눌 수 있는지 먼저 고민하는 편이 안전하다.

// 위험: 중첩 반복
// /^(\w+\s?)+$/.test(longInput)

// 안전: 길이 제한 + 단순 패턴
const ok = input.length <= 200 && /^[\w\s]+$/.test(input);

정규식과 친해지는 가장 빠른 방법

정규식은 눈으로 읽어서는 늘지 않는다. 온라인 테스터에 실제 데이터를 붙여 넣고, 패턴을 한 글자씩 바꿔 가며 매칭 범위가 어떻게 달라지는지 눈으로 보는 편이 훨씬 빠르다. 그리고 코드에 넣을 때는 그 패턴이 무엇을 찾는지 한 줄 주석을 반드시 남기자. 석 달 뒤의 나는 오늘의 나를 전혀 기억하지 못한다.

오늘 정리한 내용을 짧게 되짚어 본다. 조건을 먼저 말로 쪼갤 것, 기호는 열두 개만 확실히 익힐 것, 이메일 같은 건 느슨하게 거르고 진짜 검증은 따로 할 것, 탐욕적 매칭과 성능 함정을 기억할 것.

정규식이 어려운 게 아니라 익숙해질 기회가 없었을 뿐이다. 오늘 코드에서 str_replace를 여러 번 이어 쓴 자리가 있다면 딱 한 줄만 정규식으로 바꿔 보시길. 그 한 줄이 쌓이면 어느 날 검색창을 열지 않고도 패턴을 써 내려가는 자신을 발견하게 된다.