kakasoo

1. regexp-manager 기본 구조 설계 본문

프로그래밍/regexp-manager

1. regexp-manager 기본 구조 설계

카카수(kakasoo) 2023. 1. 16. 23:43
반응형

regexp-manager를 만들면서, 며칠 간 생각한 것을 정리한다. ( 아직 작업 중이다. )

내 감상을 적는, 일종의 일기나 다름없어서 다른 사람들은 읽어도 알아듣기 어려울 거라고 생각한다.

 

 

regexp-manager

regexp builder for node.js developer. Latest version: 0.3.0, last published: 11 hours ago. Start using regexp-manager in your project by running `npm i regexp-manager`. There is 1 other project in the npm registry using regexp-manager.

www.npmjs.com

 

서론

회사에서 정규표현식을 사용해서 각 커머스 사이트의 도메인을 분류하는 작업을 진행했다.

예를 들어 모 커머스가 있다면, 단순히 그 커머스를 식별하는 것으로 끝이 아니라, 커머스의 특정 페이지도 유추한다.

그게 장바구니인지, 결제 페이지인지, 아니면 상품 상세나 검색 등의 페이지인지 세세하게 유추해내며,

정상적인 URL인지 아닌지 검증하고 만약 고칠 수 있다면 정상적인 형태로 Transform하는 것까지 작성했다.

 

그렇게 하다보니 대략 사이트마다 5~6개 정도의 정규표현식을 작성해야 했고, 수십 개의 복잡한 정규식을 짜야 했다.

짜면서 느끼기에, 정규식은 너무 어렵고, 또 배우더라도 금새 잊을 것만 같은 기호 (symbol) 들로 이루어져 있다.

동료 개발자는 이걸 두고,

 

“참 배우기가 애매한 게, 배우는 시간 대비 정규표현식의 가치가 어정쩡한 거 같아요.”라고 말했다.

 

정규식을 알면 다양한 부분에 사용할 수 있긴 하지만 String.prototype의 메서드로도 어느 정도 대응이 가능하다.

그리고 그걸 알기보다 차라리 그러한 다른 메서드로 대응하는 것이 학습 시간 대비 결과가 나을 것이다.

정규표현식은 분명 쓸모가 있고, 그렇기 때문에 언어 스펙으로 자리잡은 것이겠지만, 비즈니스에서는 그 용도가 애매하다.

그래서 차라리 이걸 학습할 시간에, 이걸 대신 작성해줄 수 있는 라이브러리가 있으면 어떨까 싶었다.

기본 구조 & Builder Pattern

export class RegExpBuilder {
    private flag: 'g' | 'i' | 'ig' | 'm';
    private expression: string;
    private minimum?: number;
    private maximum?: number;
    constructor(initialValue: string = '') {
        this.expression = initialValue;
    }
}

처음 구상은 간단하다.

내부에 expression 이라는 프로퍼티로 정규식을 지닌, 빌더 패턴을 작성하는 것이다.

정규식을 작성하기 위한 각종 프로퍼티들을 가지고 있고, 메서드를 호출하면서 정규표현식 이해도 없이 정규식 작성이 가능한 수준을 구축하는 것이다.

사용법은 아래와 같이 가능하다고 생각했다.

 

const includeRegExp = new RegExpBuilder()
        .from('test')
        .include('forehead', { isForehead: true })
        .getOne();

이것의 실행 결과는 /(?<=(forehead))(test)/gi 로, from, include, getOne 등의 메서드로 작성된다.

from은 처음 빌더 패턴 생성 시 파라미터를 생성자에게 주지 않았을 경우, 생성자를 대체하는 역할을 하고,

include는 정규표현식에 가장 많이 쓰이는 패턴을 대체하기 위한, 비캡쳐 그룹에 대한 검증을 담당하고자 했다.

위의 예시로는 forehead라는 텍스트를 포함하는 ( 포함하되, 캡쳐되지 않는 ) test가 들어간 문장을 의미한다.

getOne은 작성이 완료된 시점에서 표현식( = expression ) 을 꺼내는 메서드를 의미한다.

TypeORM을 포함한 각종 빌더 패턴들을 참고하면 이런 식의 사용이 가능하리라 믿었다.

 

export declare class RegExpBuilder {
    private flag;
    private expression;
    private minimum?;
    constructor(initialValue?: string);

    from(qb: (RegExpBuilder: RegExpBuilder) => RegExpBuilder): this;
    from(qb: (RegExpBuilder: RegExpBuilder) => string): this;
    from(initialValue: string): this;

    include(partial: (qb: RegExpBuilder) => RegExpBuilder, options?: {
        isForehead?: boolean;
    }): this;
    include(partial: (qb: RegExpBuilder) => string, options?: {
        isForehead?: boolean;
    }): this;
    include(partial: string, options?: {
        isForehead?: boolean;
    }): this;

    lessThanEqual(maximum: number): this;

    getOne(): RegExp;
    getRawOne(): string;

    private lookaround;
    private lookbehind;
}

기본적인 Builder Class 구조는 위와 같았다.

lessThanEqual은 정규표현식의 {n,m} 구문 중 minimum 값을 의도했다.

추후 moreThanEqual과 같은 메서드도 구현할 예정인데, 당장에 필요없어서 일단 제외했다.

가장 중요한 것은 사용자들이 메서드의 이름과 사용방법을 적당히 유추해가면서도 사용이 가능해야 한다는 거고,

두번째는 당장 메서드의 수가 부족하더라도, 기 구현된 메서드로 나머지를 직접 구현 가능해야 하는 것이었다.

마지막 세번째는 정규표현식에 대한 이해가 없더라도, 메서드들을 이용해 원하는 수준의 코드를 작성할 수 있어야 했다.

 

include method

/**
 * @param first string (to catch)
 * @param second lookaround(?=) string
 * @return `(${first})(${symbol}(${second}))`
 */
private lookaround(first: string, second: string): `(${string})(?=(${string}))` {
    const symbol = '?=';
    return `(${first})(${symbol}(${second}))`;
}

/**
 * @param first lookbehind(?<=) string
 * @param second string (to catch)
 * @returns `(${symbol}(${first}))(${second})`
 */
private lookbehind(first: string, second: string): `(?<=(${string}))(${string})` {
    const symbol = '?<=';
    return `(${symbol}(${first}))(${second})`;
}

가장 중요하다고 할 수 있는 include문은, 정규표현식의 범용적인 사용을 담당해야 했다.

그래서 실제 정규표현식이 작성되는 방식은 위와 같이 private 메서드로 구현해, 클래스 내부에서만 사용 가능하게 제한할 예정이다.

만약, 추후 생각이 바뀌면 ( 아직 초기라 언제든 바뀔 수 있다. ) 외부로도 노출시킬 수도 있지만,

가급적 위 중요 사항의 세번째, 정규표현식에 대한 이해가 부족해도 사용 가능한 것을 목표로 하고자 하기에 제했다.

 

developer -> builder's method -> regexp spec

include는 이런 private 메서드들과 사용자의 생각을 이어주는 interface를 담당하는 메서드가 된다.

다른 메서드들도 구현될 때, 이런 식으로 개발자들이 regexp의 언어적 스펙을 몰라도 되게 만들 생각이다.

 

/**
 *
 * @param partial sub-regular expression builder that returns a string
 * @param options isForehead's default is true. If it's false, first parameter(partial) will set after present expression
 */
include(partial: (qb: RegExpBuilder) => string, options?: { isForehead?: boolean }): this;

/**
 * Specifies the string that must be included before and after the current expression.
 * @param partial string to be included but not captured.
 * @param options isForehead's default is true. If it's false, first parameter(partial) will set after present expression
 * @returns
 */
include(partial: string, options?: { isForehead?: boolean }): this;
include(
    partial: string | ((qb: RegExpBuilder) => string),
    options: { isForehead?: boolean } = { isForehead: true },
) {
    if (typeof partial === 'string') {
        if (options.isForehead) {
            this.expression = this.lookbehind(partial, this.expression);
        } else {
            this.expression = this.lookaround(this.expression, partial);
        }
        return this;
    } else if (typeof partial === 'function') {
        const subRegExp = partial(new RegExpBuilder());

        if (options.isForehead) {
            this.expression = this.lookbehind(subRegExp, this.expression);
        } else {
            this.expression = this.lookaround(this.expression, subRegExp);
        }
        return this;
    }
}

며칠 전에 만들어진 include문은 이런 구조를 가지고 있다.

사용자로부터 부분 문자열에 해당할 partial 또는 그 partial을 표현한 서브 빌더를 받아 내부 표현식을 수정한다.

 

if (options.isForehead) {
    this.expression = this.lookbehind(partial, this.expression);
} else {
    this.expression = this.lookaround(this.expression, partial);
}

constructor나 from에서 받은 초기 문자열에 대해, 앞인지 뒤인지를 의미하는 options 파라미터를 받는다.

이걸 표현하기 위해 lookbehind, lookaround 같은 메서드들이 필요한데, 따라서 include는 인터페이스가 된다.

 

getOne & getRawOne

getOnegetRawOne 은 ORM에서 볼 수 있는 메서드 이름인데, 백엔드 개발자에게 무척 친숙할 거 같았다.

그래서 from이나 다른 메서드들을 포함해, 대부분의 메서드 명을 ORM에서 따서 지을 생각으로 정했다.

getRawOne은 expression을 string type으로 받고자 했고, getOne은 RegExp instance를 받고자 했다.

 

getOne(): RegExp {
    const flag = this.flag ?? 'ig';
    return new RegExp(this.getRawOne(), 'ig');
}

getRawOne(): string {
    let expression = this.expression;
    if (typeof this.minimum === 'number' && typeof this.maximum === 'number') {
        // more than equal minimum, less thean equal maximum
        expression = `${expression}{${this.minimum}, ${this.maximum}}`;
    } else if (typeof this.minimum === 'number') {
        // more than equal minimum
        expression = `${expression}{${this.minimum},}`;
    } else if (typeof this.maximum === 'number') {
        // more than equal 1, less thean equal maximum
        expression = `${expression}{1,${this.maximum}}`;
    }

    return expression;
}

그래서 내부 구현은 이처럼 됐다.

minimum, maximum에 대한 것을, 정규표현식이 생성되는 마지막에 적용하려다 보니 getRawOne이 커졌다.

어쨌거나 getRawOne은 정규표현식의 pattern에 해당하는 string type을 반환하는 메서드가 되었고,

getOne은 고스란히 이 getRawOne을 사용해서 정규표현식을 만들 수 있게 되었다.

flag는 아직은 ig만 지원하되, 점차 늘려갈 계획이다.

 

그 외 메서드와 상수

export const REGULAR_CONSTANT = {
    NUMBERS: '[0-9]+',
    SMALL_LETTERS: '[a-z]+',
    CAPITAL_LETTERS: '[A-Z]+',
} as const;
/**
 * it make dot.
 * it means ".". It means that it doesn't matter what letter it is.
 * @returns `(${string})?`
 */
whatever() {
    this.expression = `(${this.expression}).`;
    return this;
}

/**
 * it make quantifier.
 * it means "?". It means that it doesn't matter if this character is present or not.
 * @returns `(${string})?`
 */
isOptional() {
    this.expression = `(${this.expression})?`;
    return this;
}

부족한 구현이지만, 일단 전체적인 구성과 구조를 갖추기 위해 메서드와 상수 등을 점진적으로 추가할 예정이다.

 

Q. 사람들이 생각한 대로 동작할까?

describe('includeForhead', () => {
        it('include forhead forhead (twice)', () => {
            const includeRegExp = new RegExpBuilder()
                .from('test')
                .include('[0-9]+', { isForehead: true })
                .include('[a-z]+', { isForehead: true })
                .getOne();

            const res = 'cat123test'.match(includeRegExp)?.at(0);
            expect(res).toBe('test');
        });
    });

사람들이 include 문을 2번 사용하고자 할 때, 어떤 결과가 벌어질까?

이게 TypeORM의 where 메서드였다면, 첫번째 where문은 삭제되고, 두번째 where문이 적용된다.

하지만 나는, 개인적으로 이게 잘못된 구현이었다고 생각한다.

 

where문을 2번 작성하여 앞의 것을 무시하고자 하는 사람보다는 where문을 실수로 또 작성한 게 맞을 것이다.

그게 아니라면 where문을 만약 2번 작성했다면, 그게 정상적인 동작이라고 생각했을 수도 있다.

마찬가지로, include문을 2번 작성하는 것은, 어떠한 문자열과도 매치될 수 없었다.

내부 구조를 아는 나로서는, 2번 작성된 include문을 이렇게 해석할 수 있었다.

 

test라는 문자 앞에 무조건 1개 이상의 숫자가 있어야 한다. 단, 그 숫자는 캡쳐 그룹에 포함되지 않는다.

그리고 다시, 캡쳐되지 않되 숫자가 앞에 붙어있는 문자열 test에 대해, a-z 중 아무 문자가 1개 이상 포함된다.

 

상식적으로 말이 안 된다.

이미 문자열에 숫자가 캡쳐 그룹에 포함되지 않는다고 했는데, 뒤에서는 그걸 번복하는 셈이다.

따라서 include문은 사람들로부터 아래와 같이 혼란을 줄 것처럼 보였다.

  1. 사람들은 include문을 2번 작성 가능하다고 생각할 수 있다.
  2. 사람들은 include문의 동작 순서를, include, include를 이어씀으로써 계속 맨앞에 특정 구문이 붙는다 본다.
  3. 내가 구현한 방식의 문법을 전혀 이해하지 못했다.

그래서 새로 생긴 질문은 이렇다.

어떻게 해야, 사용자들의 인식과 같으면서, 실수를 방지하고, 실행 순서를 보장할 수 있을까?

 

Builder 내부에서 Status를 관리하기

type IncludeOptions = { isForehead?: boolean };

type Status<T = keyof typeof RegExpBuilder.prototype> = {
    name: T;
    value: string;
    options: T extends 'include' ? IncludeOptions : null;
    beforeStatus: string;
    order: number;
};

export class RegExpBuilder {
    private flag: 'g' | 'i' | 'ig' | 'm';
    private expression: string;
    private step: Array<Status>;

    constructor(initialValue: string = '') {
        this.step = [];
        if (initialValue) {
            this.from(initialValue);
        }
    }
}

이제 step이라는 프로퍼티가 추가되었고, step은 Status의 배열이다.

Status는 from, include 등 정규표현식을 작성하는 메서드가 호출될 때마다 그 상태를 저장하는 용도다.

기존의 코드에서는 큰 변화를 주었는데, 메서드가 호출될 때마다 조금씩 정규표현식이 작성되는 게 아니고,

어떤 메서드가, 어떤 순서로 호출되었는지 그 순서를 step에 저장해두었다가,

getRawOne, getOne의 메서드가 호출될 때 step에서 꺼내 차례대로 호출하는 방식으로 바뀌었다.

이렇게 하면 일단 각 메서드마다 중요도를 설정하여 실행 순서를 보장할 수 있었다.

 

/**
 * A function that returns a pattern by executing the methods written so far in order.
 * @returns RegExp's first parameter named "pattern"
 */
execute() {
    const sorted = this.step
        .sort((a, b) => a.order - b.order)
        .reduce((acc, { name, value, options, beforeStatus, order }, index, arr) => {
            if (name === 'from') {
                return value;
            } else if (name === 'include') {
                if (options.isForehead) {
                    return this.lookbehind(value, acc);
                } else {
                    return this.lookaround(acc, value);
                }
            } else if (name === 'lessThanEqual') {
                if (typeof this.minimum === 'number' && typeof this.maximum === 'number') {
                    // more than equal minimum, less thean equal maximum
                    return `${acc}{${this.minimum}, ${this.maximum}}`;
                } else if (typeof this.minimum === 'number') {
                    // more than equal minimum
                    return `${acc}{${this.minimum},}`;
                } else if (typeof this.maximum === 'number') {
                    // more than equal 1, less thean equal maximum
                    return `${acc}{1,${this.maximum}}`;
                }
            } else if (name === 'whatever') {
                if (acc === '') {
                    return '.';
                }
                return `(${acc}).`;
            } else if (name === 'isOptional') {
                return `(${acc})?`;
            }
        }, '');

    return sorted;
}

step에 쌓인 method 호출 순서를 해석해줄 메서드로 execute를 구현했다.

대신 기존에 구현한 메서드에서는 그 상태값만 저장하되, 직접 정규식을 작성하던 부분을 모두 제거했다.

그리고 마지막으로 andInclude 메서드를 추가했다.

명시적으로, include 2번은 사용하면 안된다는 의미를 전달한다.

 

감상

ORM들이 어떤 식으로 만들어졌을지, 왜 그런 메서드들이 탄생했는지를 간접적으로 경험하고 있다.

반응형