본문 바로가기
개발일지

AST를 활용한 코드내 한글추출

by 새우하이 2024. 12. 11.

코드 내 다국어 처리를 위해 코드 내의 한글을 안정적으로 추출하기 위해 정규 표현식을 통해 한글을 추출하려다 보니 다양한 문제에 직면했습니다.

  1. 주석 제거
  2. JSX 내 태그들 사이의 한글 추출과 여러 메서드들 내에서 사용된 한글 추출
  3. LangPack.getText() 처리 된 데이터 제외

// , <!-- -->, /* */ 등 주석을 제거해야 했고

각종 태그 <></> 들 사이의 정보를 추출하고

“ “ 혹은 ' ' 사이의 한글 텍스트 등을 추출하면 될 줄 알았지만

<button type="button" onclick={alert('클릭')}>클릭</button>

위의 예시와 같이 태그의 프로퍼티 등에 사용된 텍스트라거나

<p>
    사원정보테이블입니다. <br/>
  * 아래는 예제 테이블으로 2건의 더미데이터는 실제사원 정보가 아닙니다.
</p>

개행이 적용되지는 않지만 코드의 가독성을 위한 개행 문자로 구분되어 있거나 명시적으로 닫지 않아도 되는 self-closing tag 등과 같이 고려해야 할 사항이 한 둘이 아니었습니다.

개인마다 다른, 혹은 내가 예측하지 못한 코드 스타일 등을 모두 정의하는 것은 불가능에 가까웠기 때문에 방법을 바꿔야 했습니다.

그렇다면 Prettier 같은 포매터들은 어떤 방식으로 코드를 이해하고 포매팅하는지에 대해 알아보다가

AST(Abstract Syntax Tree)를 이용하기로 했습니다.

AST가 어떻게 만들어지는 지 알기 위해 일반적인 컴파일러의 여러 가지 단계 중 AST를 생성하는 몇 가지 단계만 살펴봅니다.

이 중에서 AST 생성에 주요 역할을 담당하는 어휘 분석, 구문 분석 단계를 살펴보았습니다.

컴파일러에 대해 깊게 들어가자니 너무 어려워서 대략적으로 이해하자면

  1. 어휘 분석 단계에서는 어휘 분석기(스캐너)를 통해 우리가 작성한 소스코드 stream 을 가지고 이를 token(혹은 lexeme)이라고 불리는 작은 논리적 단위를 생성한다.
    1. JS로 보는 간단한 형태의 토크나이저
  2. 구문 분석 단계에서는 구문 분석기(파서)로 어휘분석기를 통해 만들어진 token을 가지고 소스 코드의 문법 구조를 서술하는 syntax tree를 생성한다. 이 과정에서 프로그래밍 언어의 구문 규칙에 따라 어휘 분석기에서 생성한 token 간의 관계를 서술하고 문법 오류를 검출함. (타입, 식별자 오류, 괄호 중괄호 불일치 등등..)
    1. JS로 보는 간단한 형태의 파서

그럼 자바스크립트에서는??

자바스크립트 엔진도 마찬가지로

source code → tokenizing → parsing 단계를 거쳐 AST를 구성하고 인터프리터, 컴파일러를 통해 바이트코드로 변환됩니다.

하 너무 많은 정보를 한 번에 이해하려니 이제 머리가 아픕니다.

직접 해봤습니다.

https://astexplorer.net/

에서 직접 실행시켜 봅니다.

for (let i = 0; i < 10; i++ ){
    console.log(i, "안녕하세요")
}

@bable/parser 를 통해 AST 를 생성했다.

AST 에서 반복문을 나타내는 ForStatement 구문구조를 확인할 수 있고 그 하위속성으로 init, test, update, body 등 이 반복문이 이뤄져있는 코드의 구조를 살펴볼 수 있습니다.

따라서 AST를 활용해서 문법적으로 정리되어 있는 텍스트를 얻으면 깔끔하게 한글을 추출할 수 있었습니다.

import * as Babel from '@babel/standalone';
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;

const transformedCode = Babel.transform(this.state.textInput, {
    presets: ['env', 'react']
}).code;

const ast = parser.parse(transformedCode, {
    sourceType: 'module',
    plugins: ['jsx'], 
});

traverse(ast, {
      StringLiteral(path) {
          if (/[ㄱ-ㅣ가-힣]/g.test(path.node.value)) {
              if (exclusionLangPack) {
                  /**
                   * 해당 노드의 부모노드가
                   * CallExpression 이 아니거나
                   * CallExpression이라면 LangPack.getText() 가 아닐 경우
                   */
                                  if (!(path.parent?.callee?.object?.property?.name === 'LangPack' && path.parent?.callee?.property?.name === 'getText')
                      && !(path.parent?.callee?.object?.name === 'LangPack' && path.parent?.callee?.property?.name === 'getText')) {
                      result.push(path.node.value);
                  }
                      result.push(path.node.value);
                  }
              } else {
                  result.push(path.node.value);
              }
          }
      },
});

https://babeljs.io/docs/babel-standalone

https://babeljs.io/docs/babel-parser

https://babeljs.io/docs/babel-traverse

JSFiddle 등과 같이 브라우저와 같이 Node.js 환경이 아닌 상황에서 입력된 js 코드를 실시간으로 트랜스 파일링 하고 babel/parser를 이용해서 tokenizing, parsing 하여 AST를 구성합니다.

그 다음 AST를 탐색하며 한글을 추출하고, 구조를 보고 LangPack.getText()를 사용한 텍스트를 제외할 수 있었다.

그 다음 조금 무식한 방법이지만..

LangPack의 데이터를 csv → json 으로 변환한뒤 다국어처리가 된 데이터들을 매칭해서

번역된 데이터들은 매칭, 번역이 없는 데이터들은 오른쪽 그리드에 몰아넣기

현재, @babel/standalone 을 통한 트랜스파일 과정을 빼고, MaxLength 및 검출 line 위치 파악 등의 기능이 추가되었습니다.

@babel/parser 는 분명 JSX를 지원한다고 명시되어있습니다.

트랜스파일 과정을 제외하고 JSX 그대로 순회하면 장점이 있습니다.

traverse(ast, {
    enter(path) {
        if (path?.node.type === 'JSXText') {
            if (/[ㄱ-ㅣ가-힣]/g.test(path.node.value)) {
                result.push({
                    stLineNo: path.node.loc.start.line,
                    edLineNo: path.node.loc.end.line,
                    value: path.node.value.trim(),
                    start: path.node.start,
                    end: path.node.end,
                });
            }
        }
        if (path?.node?.type === 'StringLiteral') {
            if (/[ㄱ-ㅣ가-힣]/g.test(path.node.value)) {
                if (exclusionLangPack) {
                    /**
                     * 해당 노드의 부모노드가
                     * CallExpression 이 아니거나
                     * CallExpression이라면 LangPack.getText() 가 아닐 경우
                     */
                    if (!(path.parent?.callee?.object?.property?.name === 'LangPack' && path.parent?.callee?.property?.name === 'getText')
                        && !(path.parent?.callee?.object?.name === 'LangPack' && path.parent?.callee?.property?.name === 'getText')) {
                        result.push({
                            stLineNo: path.node.loc.start.line,
                            edLineNo: path.node.loc.end.line,
                            value: path.node.value,
                            start: path.node.start,
                            end: path.node.end,
                        });
                    }
                } else {
                    result.push({
                        stLineNo: path.node.loc.start.line,
                        edLineNo: path.node.loc.end.line,
                        value: path.node.value,
                        start: path.node.start,
                        end: path.node.end,
                        // start: path.node.loc.start.column,
                        // end: path.node.loc.end.column
                    });
                }
            }
        }
        if (path?.node?.type === 'TemplateLiteral') {
            if (path?.node?.quasis) {
                path.node.quasis.forEach((quasi) => {
                    if ((/[ㄱ-ㅣ가-힣]/g.test(quasi.value.raw))) {
                        result.push({
                            stLineNo: path.node.loc.start.line,
                            edLineNo: path.node.loc.end.line,
                            value: quasi.value.raw,
                            start: path.node.start,
                            end: path.node.end,
                            // start: path.node.loc.start.column,
                            // end: path.node.loc.end.column
                        });
                    }
                })
            }
        }
        if (path?.node?.type === "JSXOpeningElement") {
            if (path.node.attributes && ['OBTTextField', 'OBTNumberField', 'OBTMultiLineTextField'].includes(path.node?.name?.name)) {
                if (!path.node.attributes.some((attr) => attr?.name?.name === 'maxLength')) {
                    maxLengthResult.push({
                        stLineNo: path.node.loc.start.line,
                        edLineNo: path.node.loc.end.line,
                        text: path.node?.name?.name + " maxLength 미지정 의심",
                        start: path.node.start,
                        end: path.node.end,
                    })
                }
            }
        }
        if (path?.type === 'ObjectExpression') {
            let colType = ''
            const typeCheck = path?.node?.properties.some(property => {
                if (property?.key?.name === 'type' && ['text', 'number', 'mask'].includes(property?.value?.value)) {
                    colType = property?.value?.value;
                    return true
                }
                return false;
            })
            const isCheckColumn = typeCheck && !(path?.node?.properties.some(property => property?.key?.name === 'maxLength'))

            if (isCheckColumn) {
                maxLengthResult.push({
                    stLineNo: path.node.loc.start.line,
                    edLineNo: path.node.loc.end.line,
                    text: colType + " type column내 maxLength 미지정 의심",
                    start: path.node.start,
                    end: path.node.end,
                })
            }
        }
    }
});

StringLiteral 로 한글만 추출할 수 있던 기존의 상황에서

JSXText StringLiteral TemplateLiteral JSXOpeningElement ObjectExpression 등 다양한 노트 타입을 지정할 수 있습니다. (물론 개발자는 더 귀찮아 지겠지만..)

다양한 노드타입을 지정하고 path.node.loc.start.line 과 같이 해당 노드의 시작과 끝을 파악할 수 있어 라인 포커스가 가능해집니다.

또한 JSXOpeningElement와 ObjectExpression을 통해 OBTTextField, OBTNumberField, OBTMultiLineTextField 의 maxLength 미지정이나 그리드 내 text, number, mask 타입의 컬럼중 maxLength가 지정되지 않은 부분들을 빠르게 캐치해낼 수 있습니다.

이처럼 AST를 활용한 기능들은 앞으로 더 무궁무진하게 활용될 수 있을 것 같으며, 이를 통해 서비스개선에 큰 역할이 되었으면 좋겠습니다

 

현재는 해당 내용이 vscode extension으로 개발되어 잘 사용하지는 않지만 이 때를 기점으로 AST를 활용한 코드 개선 툴을 만들게 되는 계기가 됐습니다 ^^;;;

댓글