요약

글을 읽을 때 띄어쓰기와 문장부호로 단어를 나누듯, 파싱은 긴 문자열을 규칙에 따라 뜻 있는 조각으로 나누는 일이다. 나누는 기호가 정해져 있으면 그 기호로 자르고, 조각의 길이가 들쭉날쭉하면 글자를 하나씩 읽으며 모은다. 모양이 복잡하면 정규 표현식이라는 “모양 설명서”로 한 번에 찾는다. 정규 표현식은 짧게 쓸 수 있지만 읽기 어려워서 작은 기호 하나 때문에 틀리기 쉽다.

예시로 보기

같은 일을 세 방법으로 해 본다. "1S2D*3T"는 “점수, 글자, 기호(있을 수도 없을 수도)”가 세 번 이어진 문자열이다. 이것을 (1, S, 없음) (2, D, *) (3, T, 없음)으로 나눈다.

방법 1. 나누는 기호로 자르기. 이 문자열에는 사이를 가르는 기호가 없어서 split으로는 안 된다. "10:20"처럼 가르는 기호(:)가 있을 때 쓰는 방법이다.

h, m = "10:20".split(":")       # ["10", "20"]을 두 이름에 나눠 담는다
h, m = int(h), int(m)           # 글자를 수로

방법 2. 한 글자씩 읽기. 번호 i를 앞으로 옮기며 지금 글자가 무엇인지 보고 모은다. 숫자는 두 자리(10)일 수 있으니 “숫자가 이어지는 동안” 모은다.

s, i, parts = "1S2D*3T", 0, []
while i < len(s):
    j = i
    while s[j].isdigit():       # 숫자가 이어지는 동안 j를 늘린다
        j += 1
    num, bonus = int(s[i:j]), s[j]
    j += 1
    opt = ""
    if j < len(s) and s[j] in "*#":   # 기호가 있으면 가져간다
        opt = s[j]
        j += 1
    parts.append((num, bonus, opt))
    i = j                       # 다음 조각의 시작
# parts == [(1, 'S', ''), (2, 'D', '*'), (3, 'T', '')]

방법 3. 정규 표현식. 조각의 모양을 설명서로 적고, 그 모양을 모두 찾아 달라고 한다.

import re
re.findall(r"(\d+)([SDT])([*#]?)", "1S2D*3T")
# [('1', 'S', ''), ('2', 'D', '*'), ('3', 'T', '')]

(\d+)([SDT])([*#]?)는 “숫자 하나 이상, 그다음 S·D·T 중 한 글자, 그다음 *나 #이 0개나 1개”라는 뜻이다. 괄호로 묶은 세 부분이 따로 꺼내진다.

stateDiagram-v2
    [*] --> 숫자: 숫자 한 글자
    숫자 --> 숫자: 숫자가 또 오면
    숫자 --> 글자: S, D, T 중 하나
    글자 --> 기호: * 또는 #
    글자 --> [*]: 기호 없음
    기호 --> [*]

한 조각을 읽는 동안 지나는 단계다. 숫자 칸에서 제자리로 도는 화살표가 \d+의 “하나 이상”이고, 글자 칸에서 바로 끝으로 가는 화살표가 [*#]?의 “0개”다. 한 조각이 끝나면 다음 조각은 다시 처음부터 읽는다1.

정규 표현식 기호

기호 뜻 예 → 맞는 것
\d 숫자 한 글자 \d\d → “05”
[abc] 괄호 안 글자 중 하나 [SDT] → “D”
[a-z] a부터 z까지 중 하나 [a-z0-9] → “k”, “7”
[^a-z] 괄호 안이 아닌 글자 하나 [^0-9] → “x”
. 아무 글자 하나 a.c → “abc”, “a-c”
\. 진짜 점 한 개 \d+\.\d+ → “3.14”
+ 앞의 것이 1번 이상 \d+ → “2024”
* 앞의 것이 0번 이상 ab* → “a”, “abbb”
? 앞의 것이 0번이나 1번 [*#]? → “”, “*”
{2,} 앞의 것이 2번 이상 \.{2,} → “..”, “….”
^, $ 문자열의 시작, 끝 ^\. → 맨 앞의 점
( ) 따로 꺼낼 부분 (\d+)-(\d+)

자주 쓰는 함수는 셋이다2.

함수 하는 일
re.findall(모양, s) 모양에 맞는 부분을 모두 찾아 리스트로
re.sub(모양, 바꿀 글자, s) 모양에 맞는 부분을 모두 바꾼 새 문자열
re.fullmatch(모양, s) s 전체가 모양과 맞는지

모양은 r"..."(r을 붙인 문자열)로 쓴다. 그래야 \d의 \가 파이썬 문자열의 특수 기호로 먹히지 않고 그대로 전달된다.

re.sub(r"[^a-z0-9]", "", "Hi! a-1")   # 'ia1'  소문자·숫자가 아닌 글자(H, !, 빈칸, -)를 모두 지운다
re.sub(r"\.{2,}", ".", "a...b..c")    # 'a.b.c'  점이 두 개 이상 이어지면 하나로
검증: 세 방법의 결과가 같다는 것, 표의 기호마다 예가 맞는다는 것, re.sub 예의 결과를 실행해 확인했다. 무작위로 만든 “점수·글자·기호” 문자열 1,000개에서 방법 2와 방법 3의 조각이 같았다 — 06_string-parsing_verify.py

활용

  • 고르는 기준: 가르는 기호가 있으면 split, 조각 길이가 들쭉날쭉하면 한 글자씩 읽기, 같은 모양이 여러 번 나오거나 “이런 글자만 남겨라” 같은 규칙이면 정규 표현식이다.
  • 한 글자씩 읽기는 규칙이 복잡해도 그대로 옮길 수 있어서, 정규 표현식이 헷갈릴 때의 안전한 길이다.
  • 자주 하는 실수:
    • 숫자를 한 글자씩만 읽어 10을 1과 0으로 나눈다.
    • 정규 표현식에서 .을 진짜 점으로 쓴다. 진짜 점은 \.이다.
    • "a b".split(" ")는 ['a', '', 'b']로 빈 조각이 생긴다. 빈칸 여러 개를 하나로 보려면 split()을 쓴다.

연결

과목별 관점

컴파일러 (3-2학기). 컴파일러의 첫 단계인 어휘 분석기가 하는 일이 이 문서의 파싱이다. 프로그램 글자 줄을 뜻 있는 조각으로 끊는다: 토큰과 렉심. 컴파일러 쪽이 더하는 것은 셋이다.

  • 편한 기호는 기본 다섯 식으로 풀린다. 컴파일러에서 쓰는 기본 정규 표현식은 다섯 가지뿐이다. 빈 문자열 \(\epsilon\), 글자 하나, 둘 중 하나 |, 이어 쓰기, 0번 이상 *다. 이 다섯으로 적을 수 있어야 정규 표현식이라 부른다. 위 표의 기호는 모두 이 다섯으로 풀어 쓸 수 있다3.
위 표의 기호 기본 식으로
x+ xx*
x? x\|ε
[abc] a\|b\|c
\d 0\|1\|…\|9
x{2,} xxx*
. 쓸 수 있는 모든 글자를 \|로 이은 것

그래서 정규 표현식을 처리하는 프로그램은 다섯 가지만 다룰 줄 알면 된다: 정규 표현식.

  • 상태 그림이 곧 기계다. 위의 상태 전이도 같은 그림을 유한 오토마타라 부른다. 정규 표현식을 이런 그림으로 바꾸고, 그림을 표로 바꿔 글자 하나에 표 한 칸만 읽게 하는 정해진 방법이 있다: 유한 오토마타, 톰슨 구성법, 부분집합 구성법.
  • 가장 긴 것을 고르는 규칙이 다르다. 어휘 분석기는 입력의 앞부분 가운데 토큰이 되는 가장 긴 것을 자른다. 이 규칙은 최장 일치와 토큰 인식에 있다. 파이썬 re의 |는 그렇지 않다. 왼쪽에 적힌 것부터 맞춰 보고, 맞으면 더 긴 것이 있어도 그것을 고른다. re.findall(r"a|ab", "ab")는 ['ab']가 아니라 ['a']다3. \d+가 숫자 덩어리를 끝까지 가져가는 것은 +가 되는 데까지 되풀이하기 때문이고, |에는 그런 성질이 없다.

확인 문제

C1 re.findall(r"\d+", "a12b3c045")의 결과는?

답: ['12', '3', '045']. \d+는 숫자가 이어진 가장 긴 덩어리를 찾는다. 결과는 글자라서 앞의 0도 남는다.

흔한 오답: ['1', '2', '3', …]처럼 한 글자씩 나누는 것. +가 “이어지는 동안 모두”라는 뜻이다.

C2 한 글자씩 읽으며 점수를 모을 때, “숫자가 이어지는 동안 모은다”로 짜야 하는 까닭을 "10S2D"로 설명하라.

답: 점수 10은 두 글자다. 한 글자씩 수로 바꾸면 1과 0이 따로 점수가 되어 조각이 어긋난다. 숫자 글자가 끝날 때까지 모아 int("10")으로 바꿔야 한 조각이 된다.

C3 다음 셋에 알맞은 방법을 고르라: (가) "2022.05.19"에서 연·월·일 꺼내기, (나) "...!@BaT#"에서 소문자와 숫자만 남기기, (다) "1S2D*3T"를 조각내기.

답: (가) split("."). 가르는 기호가 정해져 있다. (나) re.sub(r"[^a-z0-9]", "", s) 같은 정규 표현식(대문자는 먼저 소문자로 바꾼다). “이런 글자만 남긴다”는 규칙이다. (다) 한 글자씩 읽기나 정규 표현식 (\d+)([SDT])([*#]?). 가르는 기호가 없고 조각 길이가 다르다.

C4 re.findall(r"<|<=", "a<=b")의 결과는? 컴파일러의 어휘 분석기라면 같은 두 모양(<, <=)으로 무엇을 자르는가? 파이썬에서 같은 결과를 얻으려면 모양을 어떻게 고치는가?

답: ['<']. 어휘 분석기는 가장 긴 것을 고르므로 <=를 자른다. 파이썬에서는 긴 것을 왼쪽에 적어 r"<=|<"로 고치면 ['<=']가 나온다.

흔한 오답: ['<=']. re의 |는 왼쪽 것부터 맞춰 보고 <가 맞자마자 고른다. 남은 =는 어느 모양에도 안 맞아 버려진다.

  1. 방법 3의 정규 표현식 (\d+)([SDT])([*#]?)과 그 뜻풀이 문장을 상태 전이도로 옮겼다. 정규 표현식과 이런 상태 전이도(유한 오토마타)가 같은 것을 나타낸다는 것은 표준 결과다(Sipser, Introduction to the Theory of Computation, 1.3). ↩

  2. Python 3 표준 라이브러리 문서, “re — Regular expression operations”(기호와 findall, sub, fullmatch), 그리고 “Regular Expression HOWTO”(r 문자열을 쓰는 까닭, “The Backslash Plague” 절). ↩

  3. 풀어 쓴 식이 같은 문자열을 받는 것은 05_regular-expression_verify.py로, a|ab와 <|<=의 결과는 10_longest-match-tokenizing_impl.py로 확인했다. re의 동작은 Python 3 표준 라이브러리 문서 “re”의 | 설명에 있다. ↩ ↩2