영어 텍스트를 처리할 때 단순히 마침표(.)만을 기준으로 문자열을 나누면 "Dr.", "e.g.", "U.S." 같은 축약 표현 때문에 엉뚱한 위치에서 문장이 잘리는 문제가 발생합니다. 이러한 한계를 극복하려면 여러 개의 정규식을 조합해 축약어, 월 이름, 직함 등 다양한 예외 상황을 걸러내면서 문장 경계를 판별해야 합니다. 아래 예제는 그런 방식으로 구현된 sentence_split() 함수입니다.
예제
정규식 배열을 활용해 문장 경계를 판별하는 함수의 전체 구현은 다음과 같습니다.
function sentence_split($text) {
$before_regexes =
array('/(?:(?:[\'\"„][\.!?…][\'\"”]\s)|(?:[^\.]\s[A-Z]\.\s)|(?:\b(?:St|Gen|Hon|Prof|Dr|Mr|Ms|Mrs|[JS]r|Col|Maj|Brig|Sgt|Capt|Cmnd|Sen|Rev|Rep|Revd)
\.\s)|(?:\b(?:St|Gen|Hon|Prof|Dr|Mr|Ms|Mrs|[JS]r|Col|Maj|Brig|Sgt|Capt|Cmnd|Sen|Rev|Rep|Revd)\.\s[A-Z]\.\s)|(?:\bApr\.\s)|(?:\bAug\.\s)|(?:\bBros\.
\s)|(?:\bCo\.\s)|(?:\bCorp\.\s)|(?:\bDec\.\s)|(?:\bDist\.\s)|(?:\bFeb\.\s)|(?:\bInc\.\s)|(?:\bJan\.\s)|(?:\bJul\.\s)|(?:\bJun\.\s)|(?:\bMar\.\s)|(?
:\bNov\.\s)|(?:\bOct\.\s)|(?:\bPh\.?D\.\s)|(?:\bSept?\.\s)|(?:\b\p{Lu}\.\p{Lu}\.\s)|(?:\b\p{Lu}\.\s\p{Lu}\.\s)|(?:\bcf\.\s)|(?:\be\.g\.\s)|(?:\besp
\.\s)|(?:\bet\b\s\bal\.\s)|(?:\bvs\.\s)|(?:\p{Ps}[!?]+\p{Pe} ))\Z/su',
'/(?:(?:[\.\s]\p{L}{1,2}\.\s))\Z/su',
'/(?:(?:[\[\(]*\.\.\.[\]\)]* ))\Z/su',
'/(?:(?:\b(?:pp|[Vv]iz|i\.?\s*e|[Vvol]|[Rr]col|maj|Lt|[Ff]ig|[Ff]igs|[Vv]iz|[Vv]ols|[Aa]pprox|[Ii]ncl|Pres|[Dd]ept|min|max|[Gg]ovt|lb|ft|c\.?\s
*f|vs)\.\s))\Z/su',
'/(?:(?:\b[Ee]tc\.\s))\Z/su',
'/(?:(?:[\.!?…]+\p{Pe} )|(?:[\[\(]*…[\]\)]* ))\Z/su',
'/(?:(?:\b\p{L}\.))\Z/su',
'/(?:(?:\b\p{L}\.\s))\Z/su',
'/(?:(?:\b[Ff]igs?\.\s)|(?:\b[nN]o\.\s))\Z/su',
'/(?:(?:[\"”\']\s*))\Z/su',
'/(?:(?:[\.!?…]
[\x{00BB}\x{2019}\x{201D}\x{203A}\"\'\p{Pe}\x{0002}]*\s)|(?:\r?\n))\Z/su',
'/(?:(?:[\.!?…]
[\'\"\x{00BB}\x{2019}\x{201D}\x{203A}\p{Pe}\x{0002}]*))\Z/su',
'/(?:(?:\s\p{L}[\.!?…]\s))\Z/su');
$after_regexes = array('/\A(?:)/su',
'/\A(?:[\p{N}\p{Ll}])/su',
'/\A(?:[^\p{Lu}])/su',
'/\A(?:[^\p{Lu}]|I)/su',
'/\A(?:[^p{Lu}])/su',
'/\A(?:\p{Ll})/su',
'/\A(?:\p{L}\.)/su',
'/\A(?:\p{L}\.\s)/su',
'/\A(?:\p{N})/su',
'/\A(?:\s*\p{Ll})/su',
'/\A(?:)/su',
'/\A(?:\p{Lu}[^\p{Lu}])/su',
'/\A(?:\p{Lu}\p{Ll})/su');
$is_sentence_boundary = array(false, false, false, false, false, false, false, false, false, false, true, true, true);
$count = 13;
$sentences = array();
$sentence = '';
$before = '';
$after = substr($text, 0, 10);
$text = substr($text, 10);
while($text != '') {
for($i = 0; $i < $count; $i++) {
if(preg_match($before_regexes[$i], $before) && preg_match($after_regexes[$i], $after)) {
if($is_sentence_boundary[$i]) {
array_push($sentences, $sentence);
$sentence = '';
}
break;
}
}
$first_from_text = $text[0];
$text = substr($text, 1);
$first_from_after = $after[0];
$after = substr($after, 1);
$before .= $first_from_after;
$sentence .= $first_from_after;
$after .= $first_from_text;
}
if($sentence != '' && $after != '') {
array_push($sentences, $sentence.$after);
}
return $sentences;
}
$text = "Hello there, hello from Tokyo, Japan, Universe, Earth.";
print_r(sentence_split($text));
실행 결과
위 코드를 실행하면 다음과 같은 결과가 출력됩니다.
Array ( [0] => Hello there, hello from Tokyo, Japan, Universe, Earth. )
작동 원리
이 함수는 텍스트를 한 글자씩 차례로 순회하며 동작합니다. 임의의 시점에서 현재 텍스트 데이터는 항상 두 부분으로 나뉩니다. 하나는 문장 경계 앞쪽에 해당하는 부분 문자열($before)이고, 다른 하나는 경계 뒤쪽에 해당하는 부분 문자열($after)입니다.
$before_regexes와 $after_regexes에 담긴 정규식 쌍이 이 두 영역에서 각각 매칭을 시도하며 경계 후보 위치를 찾아냅니다. 축약어나 월 이름처럼 문장 경계가 아닌 패턴이 일치하면 새 문장을 저장하지 않은 채 그대로 진행합니다. $is_sentence_boundary 배열은 어떤 패턴이 실제 문장 경계를 의미하는지를 표시합니다.
앞선 패턴들 중 어느 것도 일치하지 않으면 마지막 세 개의 정규식 쌍으로 매칭을 시도하는데, 이때 일치하면 실제 문장 경계로 판단되어 지금까지 누적된 문자열이 하나의 완성된 문장으로 결과 배열에 추가됩니다.
참고로 이 코드는 PCRE의 /u(유니코드) 수정자를 사용하므로 \p{Lu}(대문자), \p{Ll}(소문자), \p{N}(숫자) 같은 유니코드 문자 속성 클래스를 활용할 수 있습니다. 덕분에 단순 ASCII 범위를 넘어 다양한 언어의 텍스트에서도 안정적으로 문장 분할이 가능합니다.