문자열 연산: 패턴 매칭, 성능, 그리고 span 기반 검색

문자열 연산: 패턴 매칭, 성능, 그리고 span 기반 검색

C#에서 문자열을 다루는 일은 단순히 붙이고 자르는 것에서 그치지 않아요. 이 글에서는 세 가지 문자열 연산을 차례로 살펴봅니다. 정규식으로 패턴을 매칭하는 방법, 할당 없이 문자열을 검색하는 ReadOnlySpan<char> 활용법, 그리고 비교를 빠르고 정확하게 만들어 주는 StringComparison 선택 기준이 바로 그것이에요.

출처: Microsoft Learn

본문

정규식으로 특정 텍스트 찾기

고정된 부분 문자열이 아니라 어떤 *모양(pattern)*을 기준으로 문자열을 검색할 때는 xref:System.Text.RegularExpressions.Regex?displayProperty=nameWithType 클래스를 써요. 정적 메서드 xref:System.Text.RegularExpressions.Regex.IsMatch*?displayProperty=nameWithType는 입력 문자열과 패턴, 그리고 선택적으로 xref:System.Text.RegularExpressions.RegexOptions 플래그를 받습니다.

아래 예제는 각 문장에서 단어 the 또는 their 를 대소문자 구분 없이 찾아요. 패턴 the(ir)?\sthe 뒤에 선택적으로 ir 이 오고, 그다음 공백 문자가 오는 경우를 매칭합니다.

패턴 의미
the 문자 그대로의 텍스트 the 를 매칭
(ir)? ir 의 0회 또는 1회 등장을 매칭
\s 공백 문자 하나를 매칭

:::code language="csharp" source="snippets/string-operations/Program.cs" id="RegexPattern":::

문자열을 패턴에 맞는지 검증하기

입력 전체가 특정 모양에 들어맞는지 확인하려면 패턴을 ^$ 로 묶어 주면 돼요. 아래 예제는 각 문자열이 미국식 전화번호 형태인지 검증해요. 숫자 세 자리, 세 자리, 네 자리이며 대시로 구분되는 형식이죠.

패턴 의미
^ 문자열의 시작을 매칭
\d{3} 정확히 세 개의 숫자 문자를 매칭
- 리터럴 - 문자 하나를 매칭
\d{4} 정확히 네 개의 숫자 문자를 매칭
$ 문자열의 끝을 매칭

:::code language="csharp" source="snippets/string-operations/Program.cs" id="RegexValidate":::

전체 패턴 문법은 Regular expression language - quick reference 문서에서 자세히 볼 수 있어요.

string 메서드와 정규식 중 무엇을 쓸까

string 메서드와 Regex 는 겹치는 문제를 해결하는 경우가 많아요. 찾으려는 텍스트가 리터럴 값이거나, 미리 아는 접두사·접미사, 또는 고정된 구분자라면 string 메서드를 쓰는 편이 좋아요. 패턴을 컴파일하고 실행하는 비용을 치르지 않으니 읽기 쉽고 더 빠르죠. 반대로 검색 대상이 모양 — 대체(alternation), 선택적 그룹, 반복 문자 클래스, 앵커로 묶은 검증 같은 것 — 일 때는 Regex 를 꺼내는 게 맞아요. 실용적인 기준은 이렇습니다. 검색을 string.Contains / StartsWith / IndexOf 호출 한두 개로 쓸 수 있다면 그렇게 하세요.

ReadOnlySpan<char> 로 검색하기

큰 입력을 파싱하거나 핫 패스(hot path)에서 검색을 돌리다 보면, 호출 때마다 할당되는 string.Substringstring.Split 의 비용이 지배적이게 돼요. ReadOnlySpan<char> 는 기존 문자열(또는 배열, 스택 버퍼) 위에 를 만들어 주는데 복사가 일어나지 않죠. 그리고 xref:System.MemoryExtensions 가 일반적인 string 메서드의 span 기반 버전을 제공하는데, 그중 하나가 xref:System.MemoryExtensions.IndexOf* 입니다.

:::code language="csharp" source="snippets/string-operations/Program.cs" id="SpanSearch":::

span 기반 검색이 할당을 피하는 이유는, 슬라이스(input[start..], rest[..end])가 단순히 원본 문자의 창(window)이기 때문이에요. 같은 방식은 키-값 목록, 헤더, 그리고 기타 구분자로 나뉜 텍스트를 파싱하는 데까지 자연스럽게 확장되는데, 그 과정에서 Substring 을 한 번도 호출하지 않아도 됩니다.

StringComparison 의 성능 고려 사항

대부분의 string 인스턴스 메서드에는 xref:System.StringComparison 값을 받는 오버로드가 있어요. xref:System.String.Equals(System.String)?displayProperty=nameWithType 같은 메서드는 기본적으로 ordinal 비교를 쓰지만, xref:System.String.Compare(System.String,System.String)?displayProperty=nameWithTypexref:System.String.IndexOf(System.String)?displayProperty=nameWithType 는 기본적으로 현재 문화권(current culture) 을 기준으로 해요. 이 차이는 두 가지 측면에서 중요합니다.

  • 속도. Ordinal 비교는 바이트 대 바이트로 비교하는 테스트라서 벡터화된 타이트한 루프 안에서 실행돼요. 반대로 문화권 인식 비교는 정렬 테이블을 참조하고, 결합 문자를 훑고, 로캘별 규칙을 적용하죠. 같은 입력이라도 한 자릿수만큼 느려질 수 있어요.
  • 정확성. 문화권 인식 비교는 예상하지 못한 문자를 병합할 수 있어요. 터키어의 i/I, 독일어의 ßss 로, 합자(ligature) 같은 것들이죠. 이 동작은 사용자가 보는 이름을 정렬할 때는 올바르지만, 식별자·경로·프로토콜 토큰을 파싱할 때는 잘못된 결과를 낳아요.

파일 이름, URL, HTTP 헤더, 식별자, 구성 키처럼 기계가 정의한 텍스트에는 xref:System.StringComparison.Ordinal?displayProperty=nameWithType 또는 xref:System.StringComparison.OrdinalIgnoreCase?displayProperty=nameWithType 를 명시적으로 전달하세요. 문화권 인식 값은 사용자에게 보여 주는 자연어 텍스트를 위해 아껴 두는 게 좋아요. 체계적인 지침은 Best practices for comparing strings in .NET 문서를 참고합니다.

더 알아보기