정규식 함부로 쓰지 말자

computerphilosopher·2024년 3월 10일
37
post-thumbnail

너무 느린 Jenkins

사내 배포용 Jenkins 파이프라인에는 사용자가 지정한 파라미터에 따라 배포 대상의 목록을 표시해주는 기능이 있다. 대표적인 사용례로 특정한 애플리케이션이 실행되고 있는 서버나 리전의 목록을 표시하는 것을 들 수 있다.

그런데 이 목록을 불러오는데 시간을 최대 2.5초 가량 소비하고 있었다. 못 쓸 정도로 느리진 않지만, 딱 답답하고 짜증나는 수준이었다. 복잡성이 높지는 않을 거 같아서 최적화 해보기로 마음 먹었다.

groovy 코드를 보니 문자열 비교에 모두 정규식을 사용하고 있었다. groovy의 startWith이나 contains 같은 메소드를 이용해 파싱할 수 있는 케이스에도 모두 정규식을 사용했다. 이 잡을 만들었던 엔지니어는 당시 groovy에 대한 이해가 없는 상태에서 촉박한 기한에 쫓기듯이 만들었다고 한다. 그래서 shell script에서 awk를 쓰는 방식을 그대로 옮겨놓은듯한 코드를 작성한 것이다.

정규식을 모두 직접 파싱으로 대체하고 몇몇 비효율적인 로직을 제거하였더니 응답 시간이 130ms 로 줄어들었다. 성능이 약 20배 가량 개선된 것이다.

정규식과 문자열 연산의 성능 비교

사내 코드를 블로그에 올릴 순 없으니 간단한 케이스로 정규식의 성능을 비교해보자. 카카오 신입 공채 기출문제중에 '신규 아이디 추천' 이라는 문제가 있다. 이 문제는 입력이 크지 않아 정규식을 사용하는 풀이가 인기가 높다. 파이썬 풀이의 최고 인기 답안도 다음과 같이 정규식을 사용하고 있다.

import re

def solution(new_id):
    st = new_id
    st = st.lower()
    st = re.sub('[^a-z0-9\-_.]', '', st)
    st = re.sub('\.+', '.', st)
    st = re.sub('^[.]|[.]$', '', st)
    st = 'a' if len(st) == 0 else st[:15]
    st = re.sub('^[.]|[.]$', '', st)
    st = st if len(st) > 2 else st + "".join([st[-1] for i in range(3-len(st))])
    return st

이 코드를 제출해보면 최소 19ms 가량의 시간을 사용한다. 정규식을 유한 상태 오토마타로 컴파일하는 오버헤드가 실행 시간의 상당 부분을 차지할 것으로 추정된다.

다음은 정규식을 사용하지 않고 한땀한땀 완성한 코드이다.

def is_valid_ch(ch:str):
    if ch.isalpha() or ch.isdecimal():
        return True
    return ch == '-' or ch == '_' or ch == '.'

def solution(new_id):
    answer = []
    before = ''
    for i, ch_raw in enumerate(new_id):
        ch = ch_raw.lower()
        if not is_valid_ch(ch):
            continue
        if ch == '.':
            if before == '.':
                continue
            if len(answer) == 0:
                continue
            if len(answer) == 14:
                break
        
        answer.append(ch)
        if len(answer) >= 15:
            break
        before = ch
    
    if len(answer) == 0:
        return "aaa"
    
    while answer[len(answer)-1] == '.':
        answer.pop()
    while len(answer) < 3:
        answer.append(answer[len(answer)-1])
    
    return ''.join(answer)

코드를 제출해보면 대부분의 테스트 케이스에서 10배 이상 빠르다. 정규식보다 느린 케이스도 소수 있지만 평균적인 성능을 비교하면 압도적이다.

왜 정규식이 더 빠른 경우가 있는지 조사해보고 싶었지만 테스트케이스가 비공개라서 한계가 있었다.

정규식을 버릴 필요는 없다

정규식을 사용하면 작성해야 할 코드 분량이 줄어드는 경향이 있는 것은 사실이다. 생산성도 성능만큼이나 중요한 덕목이므로 정규식을 버리는 선택을 할 필요는 없다.

우선 정규식을 쓰는 방법에 개선의 여지가 있는지 보아야 한다. 정규식 답안에서 시작과 끝의 온점('.')을 제거하는 부분을 보자.

    st = re.sub('^[.]|[.]$', '', st)
    st = 'a' if len(st) == 0 else st[:15]
    st = re.sub('^[.]|[.]$', '', st)

동일한 정규식을 두 번 컴파일 하고 있다. 다음과 같이 한 번 컴파일한 정규식을 계속 재활용하도록 고치면 성능을 개선할 수 있다.

    strip_dots = re.compile('^[.]|[.]$')
    st =  strip_dots.sub('', st)
    st = 'a' if len(st) == 0 else st[:15]
    st =  strip_dots.sub('', st)

(이 문제에서는 불필요한 컴파일이 일어나는 횟수가 많지 않아 성능 개선이 티가 나지 않는다. 그러나 실무에서 매번 같은 정규식을 컴파일하는 코드를 만들면 생각보다 큰 악영향을 줄 수 있다.)

또 다른 개선 방법은 문자열 연산으로 대체하여도 생산성이나 가독성에 지장이 없는 단순한 부분만 대체하는 것이다.

사실 양 끝의 온점을 제거하는 연산은 strip 메소드를 이용하면 훨씬 간단하다. 아마 정규식을 이용해 푼다는 컨셉을 지키기 위한 선택인 것 같은데, 실무에서 이런 코드를 쓸 일은 없다. strip을 이용하면 실행 시간이 개선되는 것을 볼 수 있다.

0개의 댓글