금요일 저녁, 고객사에서 회원 5만 명의 연락처 파일이 도착했습니다. 열어 보니 010-1234-5678, 010 1234 5678, 01012345678, +82-10-1234-5678이 뒤죽박죽 섞여 있습니다. "이걸 언제 다 손으로 고치지..." 이럴 때 30분이면 끝나는 도구가 정규표현식입니다.
정규표현식(Regular Expression, 줄여서 정규식)은 "문자열의 패턴을 글로 적는 방법"입니다. 어렵다는 인상이 강하지만, 실무에서 실제로 쓰이는 문법은 생각보다 좁습니다. 오늘은 외우려 하지 말고 조합하는 법을 익혀 봅시다.
정규식은 '찾기'가 아니라 '설명하기'다
일반 검색은 "010-1234-5678을 찾아라"입니다. 정규식은 "숫자 3개, 하이픈, 숫자 3~4개, 하이픈, 숫자 4개 모양을 가진 것을 모두 찾아라"라고 설명합니다. 이 차이가 전부입니다.
핵심 기호는 열 개 남짓입니다.
| 기호 | 의미 | 예시 |
|---|---|---|
. | 아무 글자 하나 | a.c → abc, a1c |
\d | 숫자 하나 | \d\d → 42 |
\w | 글자·숫자·밑줄 | \w+ → user_01 |
\s | 공백류 | 스페이스, 탭 |
+ | 1번 이상 반복 | \d+ → 12345 |
* | 0번 이상 반복 | a* → 없어도 통과 |
? | 0번 또는 1번 | -? → 하이픈 있어도 없어도 |
{2,3} | 2~3번 반복 | \d{3,4} |
[] | 이 중 하나 | [가-힣] → 한글 한 글자 |
() | 묶어서 기억하기 | 치환에 씀 |
^ $ | 시작 / 끝 | 전체 검사 |
이제 앞의 전화번호를 설명해 봅시다. 01012345678도, 010-1234-5678도 잡으려면 하이픈이나 공백이 "있어도 되고 없어도 되게" 만들면 됩니다.
010[-\s]?\d{3,4}[-\s]?\d{4}[-\s]? 한 조각이 "하이픈이나 공백이 0개 또는 1개"를 뜻합니다. 규칙을 읽는 법만 알면, 정규식은 암호가 아니라 문장이 됩니다.
서버에서 바로 써먹는 grep
로그 파일에서 특정 패턴만 골라내는 일은 리눅스 운영의 절반입니다. 아파치 액세스 로그에서 500번대 에러만 뽑아내려면:
# -E: 확장 정규식, " 500 " 같은 응답코드 위치를 패턴으로 지정
grep -E 'HTTP/1\.[01]" 5[0-9]{2} ' /var/log/apache2/access.log
# 특정 IP대역(192.168.x.x)의 요청만
grep -E '^192\.168\.[0-9]{1,3}\.[0-9]{1,3}' access.log | wc -l
# 에러가 난 URL만 잘라서 상위 10개
grep -E '" 5[0-9]{2} ' access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head여기서 놓치기 쉬운 함정이 하나 있습니다. 점(.)은 정규식에서 "아무 글자"라는 뜻이라, IP를 찾을 때 192.168이라고 쓰면 192X168도 걸립니다. 점을 진짜 점으로 쓰려면 \.처럼 역슬래시로 이스케이프해야 합니다. 실무 정규식 버그의 상당수가 여기서 나옵니다.
정규식이 "안 맞는" 게 아니라, 내가 설명을 부정확하게 적은 것입니다.
PHP: 검증과 치환, 두 가지면 충분하다
PHP에서는 preg_match(검사)와 preg_replace(치환), 그리고 preg_match_all(전부 수집) 세 개가 사실상 전부입니다.
<?php
// 1) 전화번호 정규화 — 어떤 형식으로 들어와도 010-1234-5678로 통일
function normalizePhone(string $raw): ?string {
$digits = preg_replace('/\D/', '', $raw); // 숫자 아닌 것 전부 제거
$digits = preg_replace('/^82/', '0', $digits); // +82 → 0으로
if (!preg_match('/^01[016789]\d{7,8}$/', $digits)) {
return null; // 형식이 아니면 실패
}
return preg_replace('/^(\d{3})(\d{3,4})(\d{4})$/', '$1-$2-$3', $digits);
}
echo normalizePhone('+82-10-1234-5678'); // 010-1234-5678
echo normalizePhone('01098765432'); // 010-9876-5432
var_dump(normalizePhone('02-123-4567')); // NULL (휴대폰 형식 아님)포인트는 \D(숫자가 아닌 모든 것)로 싹 지운 뒤, 깨끗해진 숫자열만 검사한다는 순서입니다. 입력 형식이 열 가지여도 정규식은 하나면 됩니다. ()로 묶은 조각은 $1, $2로 되살아납니다.
로그에서 값을 뽑아내는 것도 같은 원리입니다.
<?php
$log = 'order_id=A2024, amount=15900, user=hong';
preg_match_all('/(\w+)=(\w+)/', $log, $m, PREG_SET_ORDER);
foreach ($m as $set) {
echo "{$set[1]} → {$set[2]}\n"; // order_id → A2024 ...
}이메일 검증에 정규식을 쓰지 마세요
의외로 많이 하는 실수입니다. 인터넷에서 복사해 온 "완벽한 이메일 정규식"은 수백 자에 달하고, 그마저도 RFC 규격을 다 담지 못합니다. PHP에는 이미 검증 함수가 있습니다.
if (filter_var($email, FILTER_VALIDATE_EMAIL)) { /* 통과 */ }정규식은 표준 검증기가 없는 경우에만 꺼내는 도구입니다. 사업자등록번호(\d{3}-\d{2}-\d{5}), 사내 사번 규칙, 로그 포맷처럼 우리가 직접 정한 형식이 정규식의 자리입니다. 반대로 이메일·URL·날짜처럼 표준 파서가 존재하는 것은 라이브러리에 맡기는 편이 안전하고 빠릅니다.
탐욕(greedy)이라는 함정
정규식 초보가 가장 자주 다치는 지점입니다. HTML에서 태그 하나만 지우려 했는데 문서 전체가 사라지는 사고를 봅시다.
$html = '<b>굵게</b> 그리고 <b>또</b>';
echo preg_replace('/<b>.*<\/b>/', '', $html); // 결과: '' (전부 삭제!)
echo preg_replace('/<b>.*?<\/b>/', '', $html); // 결과: ' 그리고 ' (의도대로).*는 가능한 한 길게 먹으려 합니다(탐욕적). 첫 <b>부터 마지막 </b>까지 통째로 삼켜 버리죠. ?를 붙여 .*?로 만들면 가능한 한 짧게 잡는 게으른(lazy) 매칭이 됩니다. "왜 이렇게 많이 지워졌지?" 싶을 때 열에 아홉은 이 문제입니다.
덧붙여, HTML 구조 자체를 파싱해야 한다면 정규식보다 DOMDocument 같은 파서를 쓰는 게 정석입니다. 정규식은 한 줄 단위의 패턴에 강하고, 중첩 구조에는 약합니다.
실전 습관: 작게 만들고 즉시 확인하라
정규식을 한 번에 완성하려 들면 반드시 실패합니다. 저는 이렇게 씁니다.
- 샘플 3~5줄을 먼저 적는다. 통과해야 할 것, 걸리면 안 되는 것을 섞어서.
- 가장 단순한 패턴부터 시작해 조각을 하나씩 붙인다.
- 매 단계마다 결과를 눈으로 확인한다. regex101 같은 온라인 테스터나
php -r,grep으로 즉시. - 완성되면 주석으로 무슨 뜻인지 한글로 적어 둔다. 3개월 뒤의 나는 남이다.
특히 4번이 중요합니다. /^(?:\d{3}-)?\d{3,4}-\d{4}$/ 같은 코드는 작성 당시엔 명확해도 반년 뒤엔 해독 대상이 됩니다. 한 줄 주석이 나중의 30분을 아껴 줍니다.
정리하며
정규식은 새로운 언어가 아니라 문자열을 설명하는 짧은 문법입니다. 기호 열 개, 함수 세 개면 실무의 대부분이 해결됩니다.
- 점(
.)은 이스케이프해서\.로 — 가장 흔한 버그 .*가 너무 많이 먹으면.*?로- 이메일·URL은 표준 검증기에게 맡기고, 정규식은 우리가 정한 형식에만
- 작게 만들고 즉시 확인, 완성되면 주석
오늘 당장 서버에 접속해 grep -E로 어제 로그의 에러 하나만 뽑아 보세요. 손끝으로 한 번 성공해 보면, 다음부터는 5만 줄짜리 파일도 무섭지 않습니다. 처음엔 낯설어도 결국 가장 자주 쓰게 되는, 조용하지만 든든한 도구입니다.
댓글 0