- 강의 다시 보기
- 샘플 코드
- 시작하기
- 오늘 강의에서 다루지 않을 것
- 코드를 줄이는 마술
- 예제 분석해보기
- 정규표현식를 이해하고 다루는 방법
- 식을 분석하는 요령
- 정규표현식의 쓰임새
- 정규표현식을 쉽게 테스트해보는 방법
- 유용한 애플리케이션 코드 만들어보기
- 샘플 1 — 웹 페이지에서 링크와 텍스트 추출하기
- 샘플 2 — 간단한 스크립트 언어 해석기 만들어보기
- 샘플 3 — 리버스 프록시 기능 따라해보기
- 마무리
강의 다시 보기
샘플 코드
강의에 사용된 샘플 코드 파일들을 첨부합니다. LINQPad (https://linqpad.net) 프로그램을 이용하여 실행해볼 수 있습니다.
시작하기
정규표현식을 사용하면 복잡하고 까다로운 문자열 형식 검사, 문자열 치환 작업, 그리고 문자열 내에서 여러 패턴을 그룹으로 묶어서 논리적으로 관리할 수 있는 기능을 쉽게 구현할 수 있습니다. 같은 작업이지만, 정규표현식을 사용하면 수십줄이 넘는 코드를 5줄 이내로 줄이는 것도 가능하죠!
오늘 강의에서 다루지 않을 것
정규표현식의 각 요소가 무엇이 있고 어떻게 쓰이는지는 이번 강의에서 굳이 이야기하지 않겠습니다. 훌륭한 참고 자료가 많은데 굳이 반복할 이유는 없죠!
코드를 줄이는 마술
지역 번호, 국번, 전화 번호로 나뉘어지는 전화 번호를 사용자로부터 입력을 받았는데, 입력이 형식에 맞는지 검사하는 코드가 있다고 가정해보겠습니다.
public bool IsValidPhoneNumber(string phoneNumber)
{
string[] areaCodes = new string[] { "070", "02", "031", "032", "033", "041", "042", "043", "051", "052", "053", "054", "055", "061", "062", "063", "064" };
string[] parts = phoneNumber.Split('-');
if (parts.Length != 3)
{
return false; // 번호는 반드시 3 부분으로 나뉘어야 합니다.
}
if (!areaCodes.Contains(parts[0]))
{
return false; // 첫 번째 부분은 지역 코드 중 하나여야 합니다.
}
if (parts[1].Length < 3 || parts[1].Length > 4 || !IsAllDigits(parts[1]))
{
return false; // 두 번째 부분은 3자리 또는 4자리 숫자여야 합니다.
}
if (parts[2].Length != 4 || !IsAllDigits(parts[2]))
{
return false; // 세 번째 부분은 4자리 숫자여야 합니다.
}
return true;
}
private bool IsAllDigits(string s)
{
foreach (char c in s)
{
if (!char.IsDigit(c))
{
return false;
}
}
return true;
}
어떤가요? 전화 번호를 문자열로 받아서 형식에 맞는지 검사하는 과정이 매우 까다롭다는 것을 알 수 있습니다. 그런데 위의 코드를 단 네 줄의 코드로 줄일 수 있다면 어떨까요?
예제 분석해보기
위의 코드에서 정규표현식이란 바로 아래 문자열을 말합니다.
@"^(070|02|031|032|033|041|042|043|051|052|053|054|055|061|062|063|064)-(\d{3,4})-(\d{4})$"
어지럽게 느껴지시나요? 차라리 긴 코드를 보는게 더 좋겠다고 생각이 들진 않으신가요? 🤣
잠깐 심호흡을 하시고 차분하게 위의 정규표현식을 둘러보시면 조금 생각이 달라질 겁니다.
일단 큰 단위로 봤을 때, 괄호로 둘러싼 부분을 “한 덩어리”라고 보고, 그 이외의 부분들을 먼저 분리해서 봅니다.
^
: 문자열의 시작을 의미합니다.- 뭔가 숫자와 파이프 기호들을 묶어놓은 괄호식이 보이는데, 일단 건너뜁니다. [1]
-
: 지역 코드와 나머지 번호를 구분하는 하이픈('-')입니다.- 또 다시 뭔가 식이 보이는데, 또 건너뜁니다. [2]
-
: 다시 번호 부분을 구분하는 하이픈('-')입니다.- 역시 이번에도 건너뜁니다. [3]
$
: 문자열의 끝을 의미합니다.
즉, 큰 얼개에서 살펴보면 다음과 같은 뜻이 됩니다.
- 이 식의 앞과 뒤에 붙는
^
문자와$
문자는 이 식에서 가리키는 형식 이외에는 다른 문자열은 인정하지 않겠다는 의미입니다. 즉, 건너뛴다는 의미죠. - 구분 기호가 두 번 나오는데, 구분 기호 사이 사이마다 뭔가 규칙을 나타내는 식 같은 것이 포함됩니다.
그러면 아까 건너뛰었던 부분들을 이어서 보겠습니다.
(070|02|031|032|033|041|042|043|051|052|053|054|055|061|062|063|064)
: 괄호 내의 지역 번호 중 하나로 시작해야 함을 의미합니다. 파이프(|
)는 '또는'을 의미합니다. 즉, 지역 코드는 070, 02, 031, 032, 033, 041, 042, 043, 051, 052, 053, 054, 055, 061, 062, 063, 064 중 하나여야 합니다.(\d{3,4})
: 숫자(\d
)가 3개 또는 4개 연속되어야 합니다. 이는 일반적으로 전화번호의 국번이 될 겁니다.(\d{4})
: 숫자(\d
) 4개가 연속되어야 합니다. 이는 일반적으로 전화번호의 마지막 부분을 나타냅니다.
다시보면, 전체적으로 이 정규표현식은 지역 번호, 국번, 전화 번호로 구성된 한국 지역 전화 번호를 검사하는 정규표현식인데, 문자열에 다른 내용이 있으면 안되고 오로지 형식에 맞는 전화 번호만이 들어있어야 함을 알 수 있습니다.
정규표현식를 이해하고 다루는 방법
식을 분석하는 요령
정규표현식은 얼핏 보기에 매우 복잡한 식으로 구성된 것 같이 보이지만, 식을 분석하는 요령만 터득하면 어렵지 않게 식을 분석하고, 만들거나, 재구성할 수 있습니다.
- 정규표현식의 최소 단위는 “문자 패턴”입니다.
- 실은 우리가 검색을 할 때 일상적으로 사용하는 단순한 키워드 문자열도 엄연히 정규표현식이라고 볼 수 있습니다.
- 즉, 검색 키워드의 상위 호환이 정규 표현식이라고 생각하면 쉽습니다.
- 정규표현식은 다음 내용만 숙지해서 분석하면 어렵지 않습니다.
- 문자 패턴을 묶는 기호와 그 기호가 몇 번 등장할 것인지를 나타내는 식
- 패턴이 등장하는 순서의 나열
- 일련의 패턴을 그룹화한 표현.
정규표현식의 쓰임새
정규표현식의 쓰임새는 크게 다음과 같이 나뉩니다.
- 매칭: 가장 기본적이면서도 일상적으로 널리 쓰입니다. 매칭을 하게 되면 결과를 반환할 때, 문자열의 몇 번째 인덱스부터 몇 자의 문자가 조건에 해당되는지를 반환합니다.
- 단수 매칭: 입력으로 주어진 문자열에서 단 한 번만 매칭합니다.
- 복수 매칭: 입력으로 주어진 문자열에서 해당되는 모든 사례를 매칭하여 리스트로 반환합니다.
- 대체/치환: 복수 매칭에서 파생된 형태로, 매칭된 문자열을 치환하기 위해 사용합니다. 치환을 할 때에는 다른 문자열로 치환하거나, 빈 문자열로 치환하도록 하여 해당되는 부분을 “삭제”할 수도 있습니다.
- 나누기: 복수 매칭에서 파생된 발전된 형태로, 매칭된 문자열을 기준으로 매칭되지 않는 문자열들을 나누어 문자열의 배열로 나누는 기능을 합니다. 흔히 “문자열 토큰 분리”라고 하는 작업과 유사하지만, 정규표현식을 이용해서 매칭하므로 복잡한 조건을 사용할 수 있습니다.
이어서 살펴볼 유용한 애플리케이션 코드 만들어보기에서는 주로 매칭과 나누기 기능을 이용해서 실제로 쓸 법한 코드 예제를 주로 만들어보려 합니다.
정규표현식을 쉽게 테스트해보는 방법
- LINQPad 활용하기
- https://www.regexlib.com/
유용한 애플리케이션 코드 만들어보기
샘플 1 — 웹 페이지에서 링크와 텍스트 추출하기
웹 페이지 크롤러를 만들 때 핵심적인 동작을 꼽자면, 이어서 탐색할 링크를 추출하는 것과 텍스트를 추출하는 것이 핵심일 것입니다.
정규표현식 만으로 모든 것을 다 해소할 수는 없지만, 상당수의 복잡한 작업을 간소화할 때는 도움이 됩니다.
var client = new HttpClient();
var content = await client.GetStringAsync("https://www.nasa.gov/rss/dyn/breaking_news.rss");
var matches = Regex.Matches(content, @"<link[^>]*>(?<LinkUrl>[^<]*)", RegexOptions.Compiled);
var links = matches
.Select(x => Uri.TryCreate(x.Groups["LinkUrl"].Value, UriKind.Absolute, out Uri result) ? result : null)
.Where(x => x != null)
.ToList();
links.Dump();
샘플 2 — 간단한 스크립트 언어 해석기 만들어보기
명령 키워드와 두 개의 인자로 구성된 명령어를 처리할 수 있는 간단한 Domain Language를 처리하는 코드를 만들어보겠습니다.
이 예제에서 주목할 부분은, 별도로 Split 메서드를 쓰지 않더라도 간단하게 행 내 토큰 분리, 그리고 행 간 분리를 한 번의 Regex 작업으로 쉽게 구조화해서 분류할 수 있다는 점입니다.
var mySimpleDomLang = @"
ADD 1 4.0
DIV 1 0
SUB 6 1.9
MUL 4.1 8
DIV 2 2.3
MOD 3.5 3
HLO 4 6
Z A A
";
var matches = Regex.Matches(
mySimpleDomLang,
@"(?<Instruction>[^\s]+)\s+(?<Op1>[^\s]+)\s+(?<Op2>[^\s]+)",
RegexOptions.Compiled);
// 행을 나누는 Split 메서드를 쓰지 않아도 자동으로 그룹핑이 이루어집니다.
//matches.Dump();
for (var i = 0; i < matches.Count; i++)
{
var currentLine = matches[i].Groups.AsReadOnly();
// Index 0은 전체 문자열을 반환합니다.
//currentLine.Skip(1).Dump();
var instruction = currentLine.ElementAtOrDefault(1)?.Value;
var firstOp = currentLine.ElementAtOrDefault(2)?.Value;
var secondOp = currentLine.ElementAtOrDefault(3)?.Value;
switch (instruction)
{
case "ADD":
if (decimal.TryParse(firstOp, out decimal addArg1) &&
decimal.TryParse(secondOp, out decimal addArg2))
Console.Out.WriteLine(
$"{firstOp} + {secondOp} = {addArg1 + addArg2:#,##0.00}");
else
Console.Out.WriteLine($"Cannot process ADD instruction. (Line {i + 1})");
break;
case "SUB":
if (decimal.TryParse(firstOp, out decimal subArg1) &&
decimal.TryParse(secondOp, out decimal subArg2))
Console.Out.WriteLine(
$"{firstOp} - {secondOp} = {subArg1 - subArg2:#,##0.00}");
else
Console.Out.WriteLine($"Cannot process SUB instruction. (Line {i + 1})");
break;
case "MUL":
if (decimal.TryParse(firstOp, out decimal mulArg1) &&
decimal.TryParse(secondOp, out decimal mulArg2))
Console.Out.WriteLine(
$"{firstOp} * {secondOp} = {mulArg1 * mulArg2:#,##0.00}");
else
Console.Out.WriteLine($"Cannot process MUL instruction. (Line {i + 1})");
break;
case "DIV":
if (decimal.TryParse(firstOp, out decimal divArg1) &&
decimal.TryParse(secondOp, out decimal divArg2) &&
divArg2 != decimal.Zero)
Console.Out.WriteLine(
$"{firstOp} / {secondOp} = {divArg1 / divArg2:#,##0.00}");
else
Console.Out.WriteLine($"Cannot process DIV instruction. (Line {i + 1})");
break;
case "MOD":
if (decimal.TryParse(firstOp, out decimal modArg1) &&
decimal.TryParse(secondOp, out decimal modArg2))
Console.Out.WriteLine(
$"{firstOp} % {secondOp} = {modArg1 % modArg2:#,##0.00}");
else
Console.Out.WriteLine($"Cannot process MOD instruction. (Line {i + 1})");
break;
default:
Console.Out.WriteLine($"Cannot process '{instruction}' instruction. (Line {i + 1})");
break;
}
}
샘플 3 — 리버스 프록시 기능 따라해보기
http://www.example.com/ 페이지의 내용을 가져와서 일부분만 치환하여 응답을 변조해서 내보내는 리버스 프록시 기능을 만들어 볼 수 있습니다. 전체 HTML, XML 콘텐츠를 파싱하지 않고 정규표현식을 이용해서 원하는 부분만 빠르게 치환해서 내보낼 수 있어 유용한 점이 있습니다.
using var host = new SimpleAsyncHost();
host.ListenAsync().Wait();
// https://gist.github.com/stanroze/11008822
public sealed class SimpleAsyncHost : IDisposable
{
private HttpListener _httpListener;
private Lazy<HttpClient> _httpClientFactory;
private bool _disposed;
public SimpleAsyncHost(string prefix = "http://localhost:10090/")
{
Console.Out.WriteLine($"Prefix: {prefix}");
_httpListener = new HttpListener();
_httpListener.Prefixes.Add(prefix);
_httpClientFactory = new Lazy<HttpClient>();
}
public async Task ListenAsync()
{
_httpListener.Start();
while (!_disposed)
{
HttpListenerContext ctx = null;
try
{
ctx = await _httpListener.GetContextAsync();
}
catch (HttpListenerException ex)
{
if (ex.ErrorCode == 995) return;
}
if (ctx == null) continue;
var client = _httpClientFactory.Value;
var content = await client.GetStringAsync("http://www.example.com/");
content = Regex.Replace(content, @"<a[^>]*>[^<]*</a>", "<p>링크 치환됨</p>", RegexOptions.Compiled);
var response = ctx.Response;
response.Headers.Add(HttpResponseHeader.CacheControl, "private, no-store");
response.ContentType = "text/html";
response.StatusCode = (int)HttpStatusCode.OK;
var messageBytes = Encoding.UTF8.GetBytes(content);
response.ContentLength64 = messageBytes.Length;
await response.OutputStream.WriteAsync(messageBytes, 0, messageBytes.Length);
response.OutputStream.Close();
response.Close();
}
}
public void Dispose()
{
if (_disposed)
return;
if (_httpListener.IsListening)
_httpListener.Stop();
_disposed = true;
}
}
마무리
정규표현식을 이용하면 문자열 관련 처리 작업이나 알고리즘 프로그래밍을 크게 줄여 유지보수 비용을 낮추는데 큰 도움이 됩니다.
반면 정규표현식을 잘 알지 못하는 개발자들에게 큰 허들이 될 수 있는데, 그럼에도 정규표현식의 가파른 러닝커브를 극복할 수 있도록 독려하고 응원하는 것이 개발팀 전체의 생산성을 끌어올리는데 무척 큰 기여를 할 것입니다.
정규표현식은 닷넷은 물론, 자바스크립트, 자바, 심지어는 유닉스 환경에서까지 두루 널리 쓰이는 필수 기능이니 잘 익혀두면 커리어를 신장시키는데 큰 도움이 될 것입니다.
정규표현식을 이용해서 더 많은 것을 할 수도 있는데, Lexer나 Grammar Analyzer 설계에서 필수적으로 쓰이는 Lex/Yacc 파서 개발, 컴파일러나 스크립트 언어 개발을 시도할 수 있는 좋은 토대가 되기도 합니다.