데이터 엔지니어링(Deep Dive)

정규표현식 활용 Apply Regular Expression #JAVA

직장인B 2022. 9. 2. 23:43

 본 글에서는 JAVA에서 정규 표현식을 어떻게 활용할 수 있는지를 알아본다. 즉, 정규표현식 관련 작업을 지원하는 라이브러리를 소개하고 그것의 활용법을 알아본다.

 두 개의 클래스만 기억하면 된다. 

 

 PatternMatcher.

 

 1 . 정규표현식 라이브러리 사용 절차

  정규표현식의 활용 영역은 무궁 무진하다. 하지만 프로그래밍적인 관점에서 바라본 정규표현식의 사용 절차는 아주 단순하다. 절차는 두 가지로 나뉜다. 

 

 - 정규표현식 패턴의 생성

 - 문자열과 패턴의 일치 작업

 

  첫 번째 작업을 해주는 클래스가 Patttern 클래스이고, 두 번째 작업을 해주는 클래스가 Matcher다. 첫 번째 작업은 그야말로 패턴을 만들어주는 작업이다. 패턴의 생성엔 독특하게도 compile 이라는 명칭이 사용된다. 심오한 컴퓨터 공학적 의미가 있을텐데 거기까지 알아볼 여력은 없다. 두 번째 작업은 말하자면 만들어 놓은 패턴을 유용하게 잘 써먹는 일이다. 여기서의 써먹다를 다양하게 해석해낼 수 있겠지만, 결국 그 모든건 실체적인 문자 raw text와 패턴을 일치시켜보는 것에서 출발한다. 일치하는가? 아닌가? 

 더 긴 설명 대신 직접 코드를 구성해보기로 하자. 

 

 

2 . Pattern 과 Matcher 클래스 

 

 무식하게 소스만 작성할 뻔했다. 소스 작성 이전에 두 클래스의 구조를 살펴보는 일이 필요하겠다. 두 클래스는 모두 java.util.regex  패키지 내부의 클래스다. java.util.regex 안에는 딱 저 두 개의 클래스만 존재한다. 

 패키지 안에는 클래스와는 별도로 PatternSyntaxException 이라는 예외가 존재한다. 이름만 봐도 느낌을 알 수 있듯이 이 예외는 적합하지 않은 정규표현식 패턴을 생성하려고 할 때 생겨나는 에러다. 당연히 Pattern 클래스를 이용한 패턴 생성 과정에서 발생한다. 

 더 무엇을 설명해야할 지 모르겠다. 정말로 코드를 작성해보기로 한다. 

 

 

3 . 활용 코드

 

 앞서 말했듯 정규표현식의 활용은 두 절차를 따른다. 아래는 각각의 절차에 대한 코드 구성법이다. 

 

 3-1) 정규표현식 패턴의 생성

 

 정규표현식 패턴은 문자열로 입력된다. 문자열 패턴을 클래스 내부 로직에 따라 실제 처리에 사용되는 패턴으로 바꾸는 작업을 하는 것이 Pattern 클래스의 compile 함수다. 

 처음 나를 헷갈리게 만든 건, 생성자를 사용하지 않는 방식이었다. 간단한 추론으로 new Pattern( regx ) 와 같은 방식으로 pattern 인스턴스를 생성하지 않을까 했던 것. 실제 소스 안에서는 모든 생성자가 private 처리되어있다. 결국 정규표현식의 문자식은 그 자체로 컴퓨터에게 읽힐 수 없고, 컴퓨터 언어로 변화하는 compile 작업이 더 필요하다는 것일테다. 이 compile 작업이란 것이 존재하고, 그것이 어떤 의미인지를 까먹지만 않는다면 Pattern을 만드는 작업은 실로 단순하다. 

String regx = "[A-Z]";

Pattern pattern = Pattern.compile(reg);

 적용된 정규표현식 패턴을 확인하려면 pattern 메소드를 활용하면 된다. 

String regx = "[A-Z]";

Pattern pattern = Pattern.compile(reg);

String get_regx = pattern.patter();

// get_regx : [A-Z]

 

 살짝 차원을 높이자면, compile 작업에서 참조될 수 있는 부연 설명을 또 다른 인자로 넣어줄 수 있다. 이 부연 설명은 flag라고 명명되며, 붙여질 수 있는 옵션들은 Pattern 클래스 내에 상수로 선언되어 있다. 가령 이런 식이다. 

String regx = "[A-Z]";
int flag = Pattern.CASE_INSENSITIVE;

Pattern pattern = Pattern.compile(reg, flag);

 적용된 플래그는 대문자와 소문자를 구별하지말라는 부연 설명이다. 다양한 플래그들이 있는데 무엇이 실무적으로 중요한지 판단할 수 없어서 전부 가져와 보았다. 

 

Flag 설명
CANON_EQ Enables canonical equivalence.
CASE_INSENSITIVE Enables case-insensitive matching.
COMMENTS Permits whitespace and comments in pattern.
DOTALL Enables dotall mode.
LITERAL Enables literal parsing of the pattern.
MULTILINE Enables multiline mode.
UNICODE_CASE Enables Unicode-aware case folding.
UNICODE_CHARACTER_CLASS Enables the Unicode version of Predefined character classes and POSIX character classes.
UNIX_LINES Enables Unix lines mode.

이 영어들은 언젠가 번역을 하도록 하겠다...

 

 

3-2) 문자열과 패턴의 일치 작업 

 

 기본적으로 일치 작업은 Matcher 인스턴스를 생성하여 진행된다. 하지만 굳이 Matcher 인스턴스를 생성하지 않고, Pattern의 인스턴스만으로 수행할 수 있는 작업들이 있다. 이것부터 먼저 소개하겠다. 

 

 3-2-1) Pattern 인스턴스 사용

 

  * matches : 주어진 패턴과 문자열이 일치하는지를 확인해준다. 이미 패턴이 컴파일된 pattern 인스턴스를 이용하면서 새롭게 패턴을 넣어주어야 한다는 점에서 좋은 활용 방안은 아니라고 생각된다. 하지만 다수의 문자와 다수의 패턴을 교차적으로 쭉 반복 매칭해보아야할 경우엔 퍽 유용하다. 

/* Pattern 인스턴스 pattern 생성 */

Boolean result = pattern.matches("[a-z]{5}", "abcde");

// result : true

* split : 컴파일된 패턴을 기준으로 입력된 문자열을 쪼갠다. 주어진 패턴과 일치되는 부분은 사라진다는 점을 주의할 필요가 있겠다. 패턴과 일치되는 모든 부분이 사라진다. 일치하는 부분이 없는 경우 문자열 전체를 그대로 반환한다. 

/* Pattern 인스턴스 pattern 생성 */
/* regx = "(b){1}" */

String[] result1 = pattern.split("abc");

// result1 : ["a", "c"]

String[] result2 = pattern.split("bbbcccbcb");

// result2 : ["ccc", "c"]

 

3-2-2) Matcher 인스턴스 사용

 

 Matcher 인스턴스를 생성하기 위해선 두 가지가 필요하다. 컴파일된 패턴과 비교 대상이 되는 문자열이 있어야 한다. 컴파일된 패턴은 Pattern 인스턴스 그 자체이므로 여기에 비교 문자열만 넣어주면 된다. 이 작업을 해주는 Pattern 클래스의 메소드가 있다. 

/* Pattern 인스턴스 pattern 생성 */
/* regx = "[a-z]{5}" */

String rawText = "abcde";

Matcher matcher = pattern.matcher(rawText);

 Matcher 인스턴스를 이용해선 조금 더 섬세한 일치 작업들을 해낼 수 있다. 

*matches : 패턴와 문자열의 일치여부를 확인해준다. 

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "[a-z]{5}" */
/* rawText = "abcde" */

Boolean result = matcher.matches();

// result : true

*lookingAt : 문자열의 시작부분이 패턴과 일치하는지를 확인해준다. startWith과 비슷한 기능이라고 생각하면 되겠다. 

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "[a-z]{5}" */
/* rawText = "abcdeabcde" */

Boolean result = matcher.lookingAt();

// result : true

*find: 문자열 안에 패턴과 패턴과 일치하는 부분이 있는 지를 확인해준다. 이 표현이 맞는지 모르겠지만 기본적으로 iterator의 기능을 탑재하여, 함수를 여러번 사용할 시 이전에 previous 찾았던 부분을 제외한 나머지 부분을 기준으로 패턴 일치 여부를 확인한다. 말이 어렵고 코드로 보면 쉽게 이해된다. 

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "ab" */
/* rawText = "abcabcabc" */

Boolean result1 = matcher.find(); <- ab vs abcabcabc
Boolean result2 = matcher.find(); <- ab vs cabcabc
Boolean result3 = matcher.find(); <- ab vs cabc
Boolean result4 = matcher.find(); <- ab vs c

// result1, result2, result3 : true
// result4 : false

 Matcher의 소스 안에는 ImmutableMatchResult 라는 내부 클래스가 있다. 여기엔 이런 식의 작동을 가능하게 하는 필드들이 선언되어 있다. text 필드는 Matcher 인스턴스 생성 시 입력되었던 문자열을 저장한다. 

private static class ImmutableMatchResult implements MatchResult {
        private final int first;
        private final int last;
        private final int[] groups;
        private final int groupCount;
        private final String text;
        
        ...

 first, last는 문자열의 index를 저장하는 필드다. 이를 이용해 기존에 찾은 find 패턴을 제외한 문자열을 기준으로 재탐색을 진행한다. 작동 원리까지는 도저히 유심히 보지 못하겠다. 간단하게만 보자면, find로 패턴과 일치하는 특정 부분을 찾은 경우 first에는 문자열 안에서의 해당 부분의 시작 위치가 담기고, last에는 문자열 안에서의 해당 부분의 끝 위치가 담긴다. 이를 이용하여 이미 찾은 부분을 무시하고 그 뒤에 부분부터 탐색을 시작할 수 있는 것이다. 

 이 first와 last 인덱스를 확인시켜주는 메소드가 있다. 

*start: first를 반환한다. 즉, 직전 previous에 탐색된 문자열 부분의 시작 index를 반환한다.

*end: last를 반환한다. 즉, 직전 previous에 탐색된 문자열 부분의 끝 index를 반환한다. 

문자열 일치 작업이 선행되지 않은 경우나, 일치하는 부분이 없는 경우 예외를 뱉으니 이를 주의하자. 

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "[a-z][0-9]" */
/* rawText = "a1b2c3" */

matcher.find(); // -> a1b2c3
int start1 = matcher.start();
int end1 = matcher.end();

matcher.find(); // a1 -> b2c3
int start2 = matcher.start();
int end2 = matcher.end();

matcher.find(); // a1b2 -> c3
int start3 = matcher.start();
int end3 = matcher.end();

matcher.find(); // a1b2c3 ->
int start4 = matcher.start();
int end4 = matcher.end();


// start1 : 0, end1 : 1
// start2 : 2, end2 : 3
// start3 : 4, end3 : 5
// start4, end4 - IllegalStateException

 한편 필드 중 group이라는 이름이 눈에 띈다. 여기서의 group은 패턴과 일치하는 문자열의 부분 조합을 의미한다. 솔직히 별로 직관적인 이름은 아니라고 생각이 든다. 무튼 이 group은 굉장히 주요하게 사용되고 이를 지원하는 메소드가 있다. 이름도 친절하게 group이다.

 *group : 패턴과 일치하는 문자열의 부분 조합을 반환한다. 앞서와 같이 문자열 일치 작업이 선행되지 않은 경우나, 일치하는 부분이 없는 경우 예외를 뱉으니 이를 주의하자. 

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "[a-z][0-9]" */
/* rawText = "a1b2c3" */

matcher.find(); [a-z][0-9] vs a1b2c3
String result1 = matcher.group();
matcher.find(); [a-z][0-9] vs b2c3
String result2 = matcher.group();
matcher.find(); [a-z][0-9] vs c3
String result3 = matcher.group();
matcher.find(); [a-z][0-9] vs 
String result4 = matcher.group();

// result1 : a1
// result2 : b2
// result3 : c3
// result4 - IllegalStateException

  group에는 꾀 특별한 세부 기능이 있다. 잘 쓰면 정말 획기적으로 쓰일 법한 기능이다. group으로 반한된 문자열의 부분 조합은 또 다시 패턴을 기준으로 여러 부분으로 나뉠 수 있다. 이를 위해선 우선적으로 정규표현식 패턴 자체에 부분 그룹화를 적용해야한다. 이때 사용되는 것이 소괄호다. 표현식에서 소괄호로 묶어진 부분들을 '부분 집합'으로 고려할 수 있는 것. 그리고 group 메소드에 인자를 주어 문자열 부분 조합에서 세부적인 부분을 다시금 추출해낼 수 있다. 개념적으로 상당히 난해한데 불행이도 코드를 보아도 꾀 난해하다.

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* groupedRegx = "([a-z])([0-9])" */
/*    => 두 개의 소괄호 묶음이 있다는 건, 패턴이 두 개의 부분 집합으로 그룹화되었다는 것 */
/* rawText = "a1b2c3" */

matcher.find(); // -> a1b2c3
String result1 = matcher.group();
String result1FirstGroup = matcher.group(1);
String result1SecondGroup = mathcer.group(2);

matcher.find(); // {(a)(1)} -> b2c3
String result2 = matcher.group();
String result2FirstGroup = matcher.group(1);
String result2SecondGroup = mathcer.group(2);

matcher.find(); // {(a)(1)}{(b)(2)} -> c3
String result3 = matcher.group();
String result3FirstGroup = matcher.group(1);
String result3SecondGroup = mathcer.group(2);

// result1 : a1, result1FirstGroup : a, result1SecondGroup : 1
// result2 : b2, result2FirstGroup : b, result2SecondGroup : 2
// result3 : c3, result3FirstGroup : c, result3SecondGroup : 3

 무튼 이렇게 사용한다. groupCount 라는 메소드로 몇 개의 부분 집합을 지니는 지 파악할 수 있다. 

 이와 같은 작업은 Matcher 인스턴스의 내부값들을 휘저어 놓는다. 친절하게도 Matcher의 상태를 초기화시켜주는 reset이라는 메소드가 있다. 

 한 가지 더 알아둘 것이 있다. 바로 패턴에 일치되는 문자열 부분을 다른 문자열로 교체 replace 해주는 기능이다. 언뜻 들어도 상당히 유용하게 쓰일 법하다. 이 기능을 지원하는 세 가지 메소드가 있다. 

*replaceAll: 패턴과 일치하는 모든 부분을 입력된 문자열로 교체한다.

*replaceFirst: 패턴과 일치하는 부분들 중 첫 번째 것을 입력된 문자열로 교체한다.

/* Pattern 인스턴스 pattern 생성 */
/* Matcher 인스턴스 mathcer 생성 */
/* regx = "[a-z][0-9]" */
/* rawText = "a1b2c3" */

String result1 = matcher.replaceAll("TT"); 

String result2 = matcher.replaceFirst("TT");

// result1 : TTTTTT
// result2 : TTb2c3

 몇 가지 기능들이 더 있지만, 이 정도의 기능만으로도 정규표현식을 충분히 유용할 수 있으리라 생각된다. 

 끝내기 전에 간단한 기능 두 가지를 더 소개해야겠다. Mathcer 인스턴스를 재구성해주는 메소드들이다.

 *Matcher usePattern(String newRegx) : 새로운 패턴을 Matcher 인스턴스에 입력한다. 

 *Mathcer reset(CharSquence newRawTest) : 새로운 비교 문자열을 Matcher 인스턴스에 입력한다.