로그 파일을 열어보면 줄마다 규칙이 보여요. 줄 맨 앞엔 시각, 맨 끝엔 상태 코드, 이런 식이죠. 저는 신입 때 "각 줄 맨 앞 단어만 뽑아줘" 같은 부탁을 받고, 단어는 잘 잡는데 맨 앞이라는 조건을 어떻게 거는지 몰라서 막막했어요. 그냥 단어를 찾으면 줄 중간에 있는 것까지 다 딸려오니까요. 그 "맨 앞", "맨 끝" 조건을 걸어주는 게 오늘의 주인공, 앵커(anchor, 위치를 고정하는 기호)예요. ^는 시작, $는 끝을 가리켜요. 글자를 잡는 게 아니라 "여기가 시작이야, 여기가 끝이야" 하는 위치를 잡는다는 게 핵심이에요.
10.1 앵커는 글자가 아니라 위치예요
이게 제일 헷갈리는 부분이라 먼저 못을 박을게요. ^나 $는 글자를 소비하지 않아요. 무슨 말이냐면, a는 "a라는 글자 한 개"를 실제로 집어가지만, ^는 "지금 위치가 시작 지점이 맞아?"만 확인하고 아무 글자도 집어가지 않아요. 이런 걸 너비가 0인(zero-width) 기호라고 불러요. 자리는 차지하지만 글자를 먹지는 않는다는 뜻이에요. 그래서 앵커는 글자라기보다 조건에 가까워요. 위치 조건이 맞으면 통과, 아니면 탈락. 이 감각을 갖고 예제를 보면 훨씬 쉬워요.
10.2 맨 앞 단어만 잡고 싶어요
hello world에서 맨 앞 단어 hello만 잡아볼게요. \w+는 단어 글자(영문, 숫자, 밑줄) 1개 이상이라는 뜻이고, 앞에 ^를 붙이면 "시작 위치에서 시작하는 단어"가 돼요.
import re
re.findall(r"^\w+", "hello world")
# ['hello']
뒤의 world는 분명 단어 글자로만 되어 있는데도 빠졌어요. 시작 위치가 아니기 때문이에요. 앞에 hello와 공백이 이미 지나갔으니, world가 시작하는 지점은 문자열의 맨 앞이 아니거든요. ^ 하나로 "맨 앞"이라는 조건이 딱 걸린 거예요.
10.3 이번엔 맨 끝 단어만요
반대로 맨 끝 단어를 잡으려면 $를 단어 뒤에 붙여요. \w+$는 "끝 위치에서 끝나는 단어"예요.
re.findall(r"\w+$", "hello world")
# ['world']
이번엔 hello가 빠지고 world만 잡혔죠. hello 바로 뒤엔 공백이 있어서 "끝"이 아니거든요. 로그 줄 맨 끝의 상태 코드나 결과 값을 뽑을 때 $를 자주 써요. 예를 들어 줄 끝에 붙은 응답 코드만 골라내고 싶을 때 이 $가 큰 역할을 해요.
10.4 여러 줄인데 왜 첫 줄만 잡히죠?
여기서 초보가 꼭 한 번 걸려 넘어져요. 12, 34, 56이 세 줄로 있는 글에서 각 줄 첫 숫자를 다 잡으려고 ^\d+를 썼어요. \d+는 숫자 1개 이상이죠. 참고로 \n은 줄바꿈을 뜻해요.
re.findall(r"^\d+", "12\n34\n56")
# ['12']
기대와 달리 첫 줄 12만 잡혔어요. 왜냐하면 기본 상태에서 ^는 문자열 전체의 맨 앞 딱 한 곳만 가리키거든요. 줄이 여러 개여도 앵커에겐 시작점이 하나뿐인 거예요. 우리 눈엔 세 줄이지만, 정규식에겐 그냥 중간에 줄바꿈 글자가 낀 기다란 한 덩어리일 뿐이에요. 그래서 두 번째, 세 번째 줄의 34, 56은 시작 위치가 아니라 잡히지 않았어요.
10.5 각 줄마다 걸리게 하려면요?
줄마다 ^가 걸리게 하려면 멀티라인(multiline, 여러 줄 모드) 옵션을 켜요. 파이썬에선 re.M을 넘겨요. 이걸 켜면 ^와 $가 "각 줄의 시작과 끝"으로 바뀌어요.
re.findall(r"^\d+", "12\n34\n56", re.M)
# ['12', '34', '56']
이제 세 줄의 첫 숫자가 전부 잡혔어요. 옵션 하나 켰을 뿐인데 ^의 의미가 통째로 바뀐 거죠. 줄바꿈 바로 뒤도 "줄의 시작"으로 쳐주니까요. 글자로 된 예도 똑같아요.
re.findall(r"^\w+", "abc\ndef", re.M)
# ['abc', 'def']
각 줄 맨 앞 단어 abc, def가 모두 잡혔어요. 로그처럼 줄마다 의미가 있는 텍스트를 다룰 때 re.M은 거의 필수예요. "각 줄에서 뭔가를 뽑아야 하는데 첫 줄만 나온다" 싶으면 십중팔구 이 옵션을 안 켠 거예요.
10.6 같은 ^인데 뜻이 두 개라고요?
여기서 초보를 정말 자주 헷갈리게 하는 게 있어요. ^ 기호가 있는 자리에 따라 뜻이 완전히 달라져요. 대괄호 바깥의 ^는 지금 배운 "시작" 앵커예요. 그런데 대괄호 안 맨 앞의 ^는 "이것들을 뺀 나머지"라는 부정이에요. 예를 들어 [^0-9]는 "숫자가 아닌 한 글자"예요.
re.findall(r"[^0-9]+", "a1b2c3")
# ['a', 'b', 'c']
숫자 1, 2, 3은 빠지고 숫자가 아닌 글자만 잡혔죠. 같은 ^인데 여기선 "시작"이 아니라 "제외"로 쓰인 거예요. 정리하면, 대괄호 밖에 있으면 시작 위치, 대괄호 안 맨 앞에 있으면 부정이에요. 이 둘을 헷갈리면 패턴을 완전히 반대로 읽게 되니, ^를 볼 때마다 "이게 대괄호 안이야 밖이야?"를 먼저 확인하는 습관을 들이세요.
10.7 "들어있나"와 "전체가 형식에 맞나"는 달라요
앵커의 진짜 쓸모는 형식 검사에서 드러나요. 123-4567xx가 전화번호 형식인지 검사한다고 해볼게요. 패턴은 \d{3}-\d{4}, 숫자 3개에 하이픈, 숫자 4개예요. re.search는 "어딘가 들어있나"를 보고, re.fullmatch는 "전체가 딱 맞나"를 봐요.
re.search(r"\d{3}-\d{4}", "123-4567xx")
# 매치됨: '123-4567'
re.fullmatch(r"\d{3}-\d{4}", "123-4567xx")
# None
re.search는 뒤에 xx가 붙어 있어도 앞부분만 보고 "있다"고 해요. 그래서 형식 검사에 search를 쓰면 123-4567xx도 통과시키는 실수를 하게 돼요. 사용자 입력을 검증할 땐 이게 치명적이에요. 반면 re.fullmatch는 xx 때문에 전체가 안 맞으니 None, 즉 "형식 아님"으로 정확히 걸러내요. 같은 효과를 앵커로도 낼 수 있어요. 패턴을 ^\d{3}-\d{4}$처럼 양 끝을 앵커로 감싸면 "처음부터 끝까지 이 형식뿐"이라는 뜻이 돼서, 뒤에 군더더기가 붙은 입력을 막아줘요. 입력값을 검사할 땐 이렇게 앵커나 fullmatch를 꼭 챙기세요.
10.8 오늘 내용을 정리하면요
![[정규식 10] 줄의 시작과 끝, ^ 와 $](https://img.thenullpage.com/posts/6594/6594_1_caa930.webp)
^는 시작, $는 끝을 가리키는 앵커예요. 글자를 집어가지 않고 위치만 확인하는 너비 0짜리 기호라는 게 가장 중요한 포인트예요. 기본 상태에서 ^와 $는 문자열 전체의 앞과 뒤 한 곳씩만 봐요. 여러 줄에서 줄마다 걸리게 하려면 멀티라인 옵션 re.M을 켜요. 그리고 대괄호 안 맨 앞의 ^는 시작이 아니라 부정이라는 것, 형식을 검사할 땐 ^...$로 양 끝을 감싸거나 re.fullmatch를 써서 앞부분만 맞아도 통과하는 실수를 막는다는 것까지 챙기면 돼요. 위치를 다루는 이 감각은 데이터를 검증할 때 두고두고 쓰이니 예제를 직접 켜고 꺼가며 익혀보세요.