오류에도 멈추지 않는 코드 분석: 재귀 하향 파싱으로 만드는 강건한 파서

오류에도 멈추지 않는 코드 분석: 재귀 하향 파싱으로 만드는 강건한 파서

우리는 매일 코드를 작성하고 실행합니다. 완벽한 코드는 드물고, 때로는 작은 문법 오류 하나가 전체 프로그램을 멈추게 만들기도 하죠. 하지만 이상하지 않나요? 우리가 사용하는 통합 개발 환경(IDE)은 코드에 오류가 있어도 빨간 줄을 표시하면서도, 동시에 다른 코드에 대한 자동 완성 기능을 제공하거나, 심지어 리팩토링까지 제안합니다. 어떻게 이런 일이 가능할까요? 바로 ‘강건한 파서(Resilient Parser)’ 덕분입니다.

일반적인 파서는 코드에서 첫 번째 오류를 발견하면 즉시 작동을 멈추고 에러를 보고합니다. 이는 컴파일러나 인터프리터가 “올바른” 코드만을 처리해야 할 때 효율적일 수 있습니다. 하지만 IDE나 복잡한 코드 분석 도구의 경우, 오류가 있는 부분은 건너뛰더라도 나머지 유효한 코드에 대한 정보를 계속해서 제공해야 할 필요가 있습니다. 이때 필요한 것이 바로 오류를 만나도 포기하지 않고 끝까지 분석을 시도하는 강건한 파서입니다.

이 글에서는 파싱의 기본 개념부터 시작하여, 흔히 “간단하다”고 여겨지지만 사실은 매우 강력한 파싱 기법인 재귀 하향 파싱(Recursive Descent Parsing)을 심도 있게 다루고자 합니다. 특히, 이 재귀 하향 파싱을 어떻게 활용하여 오류에도 굴하지 않고 코드를 분석하는 강건한 시스템을 구축할 수 있는지 그 전략과 구현 방법에 대해 자세히 알아보겠습니다. 기존의 인식을 뛰어넘어, 재귀 하향 파싱이 가진 진정한 잠재력을 함께 탐색해 볼 시간입니다.


1. 파싱이란 무엇이며, 왜 강건함이 필요한가?

코딩의 첫걸음을 뗀 분들이라면 ‘파싱(Parsing)’이라는 단어가 다소 생소할 수 있습니다. 쉽게 말해, 파싱은 우리가 작성한 소스 코드와 같은 문자열 데이터를 컴퓨터가 이해할 수 있는 구조(대개 추상 구문 트리, AST)로 변환하는 과정입니다. 예를 들어, x = 10 + 5;라는 코드를 파싱하면, ‘변수 x에’, ’10과 5를 더한’ 결과를 ‘할당한다’는 의미론적 구조를 갖는 AST가 만들어지는 식이죠. 이 AST는 이후 컴파일러가 기계어로 변환하거나, 인터프리터가 직접 실행하는 데 사용됩니다.

문제는 우리가 작성하는 코드가 항상 문법적으로 완벽할 수 없다는 점입니다. 오타, 괄호 누락, 세미콜론 누락 등 다양한 이유로 문법 오류가 발생할 수 있습니다. 이때 일반적인 파서는 첫 번째 오류 지점에서 “여기서 더 이상 진행할 수 없습니다!”라고 선언하며 멈춰버립니다.

하지만 현대 소프트웨어 개발 환경에서는 이러한 “올바르지 않으면 아무것도 하지 않는” 접근 방식으로는 부족합니다. 다음과 같은 상황들을 생각해봅시다.

  • 통합 개발 환경(IDE): 개발자가 코드를 타이핑하는 즉시 IDE는 문법 오류를 빨간 밑줄로 표시해줍니다. 동시에 변수나 함수의 정의를 따라가거나, 자동 완성을 제안하거나, 심지어 오류가 없는 다른 부분에 대한 리팩토링까지 지원합니다. 만약 IDE의 파서가 첫 오류에서 멈춰버린다면, 개발자는 오류를 해결하기 전까지 어떠한 도움도 받을 수 없을 것입니다.
  • 컴파일러 및 린터: 실제 프로덕션 환경에서 오류가 있는 코드를 컴파일하거나 린트(lint)할 때, 개발자는 단 하나의 오류 메시지보다는 가능한 한 많은 오류 목록을 한 번에 받고 싶어 합니다. 그래야 효율적으로 수정 작업을 진행할 수 있기 때문입니다. 강건한 파서는 첫 오류 이후에도 계속해서 파싱을 시도하여 더 많은 오류를 찾아냅니다.
  • 코드 분석 도구: 코드의 복잡도를 측정하거나, 특정 패턴을 찾거나, 보안 취약점을 분석하는 도구들은 종종 불완전하거나 오류가 있는 코드 스니펫을 처리해야 합니다. 이때도 전체적인 분석을 위해 오류 부분을 ‘적절히’ 건너뛰고 나머지 부분을 이해해야 합니다.

결론적으로, 개발자의 생산성 향상과 다양한 코드 분석 도구의 효율적인 작동을 위해서는 오류에 ‘취약한(brittle)’ 파서가 아닌, 오류를 만나도 흔들리지 않고 강건하게 제 역할을 수행하는 파서가 필수적입니다.


2. 재귀 하향 파싱(Recursive Descent Parsing)의 기본 원리

수많은 파싱 기법 중에서도 재귀 하향 파싱(Recursive Descent Parsing)은 그 이름처럼 ‘재귀’적인 함수 호출을 통해 소스 코드를 ‘하향식’으로 분석해나가는 방식입니다. 이 기법은 문법 규칙을 직접 함수로 구현하기 때문에 이해하기 쉽고 구현이 직관적이라는 큰 장점을 가지고 있습니다.

재귀 하향 파싱, 어떻게 작동할까?

가장 기본적인 아이디어는 다음과 같습니다. 프로그래밍 언어의 문법은 여러 ‘규칙(Rule)’들로 이루어져 있습니다. 예를 들어, “문장은 표현식으로 시작하고 세미콜론으로 끝난다”거나, “표현식은 숫자와 연산자의 조합이다”와 같은 규칙들이죠. 재귀 하향 파서는 이러한 각각의 문법 규칙에 대응하는 함수를 만듭니다.

예를 들어, 간단한 산술 표현식을 파싱한다고 가정해봅시다.

  • parse_expression() 함수는 parse_term()을 호출하고, 그 뒤에 + 또는 - 연산자가 오면 다시 parse_term()을 호출하는 식으로 반복합니다.
  • parse_term() 함수는 parse_factor()를 호출하고, 그 뒤에 * 또는 / 연산자가 오면 다시 parse_factor()를 호출하는 식으로 반복합니다.
  • parse_factor() 함수는 숫자 리터럴(예: 10, 25)을 처리하거나, 괄호 () 안에 있는 parse_expression()을 재귀적으로 호출합니다.

이러한 방식으로, 최상위 문법 규칙(예: parse_program())에서 시작하여 하위 규칙(예: parse_statement(), parse_expression(), parse_term(), parse_factor())으로 점차 내려가면서 코드를 분석하게 됩니다. 각 함수는 현재 입력 스트림의 토큰(token)을 확인하고, 문법 규칙에 맞는 토큰을 ‘소비(consume)’하면서 다음 토큰으로 넘어갑니다.

재귀 하향 파싱의 장점과 한계 (그리고 오해)

장점:

  1. 직관적인 구현: 문법 규칙을 거의 1:1로 함수로 매핑할 수 있어 코드가 깔끔하고 이해하기 쉽습니다.
  2. 쉬운 디버깅: 호출 스택을 통해 파서가 현재 어떤 문법 규칙을 분석하고 있는지 쉽게 추적할 수 있습니다.
  3. 높은 유연성: 파서 생성기를 사용하지 않고 수동으로 구현하기 때문에, 파서 동작을 세밀하게 제어할 수 있습니다. 특히 에러 복구 로직을 추가하기에 유리합니다.

일반적인 한계 및 오해:

  1. 좌측 재귀(Left Recursion) 문제: Expr -> Expr + Term과 같은 직접/간접 좌측 재귀 규칙은 무한 루프를 유발할 수 있습니다. 이는 문법을 수정하여 해결해야 합니다.
  2. LL(1) 제약: 일반적으로 다음 하나의 토큰만 보고 결정을 내릴 수 있는 LL(1) 문법에 적합합니다. 더 복잡한 문법은 미리 토큰을 더 많이 봐야 하거나, 백트래킹(backtracking)이 필요할 수 있습니다.
  3. 복잡한 에러 복구의 어려움 (오해): 종종 재귀 하향 파싱은 간단한 파서에만 적합하며, 복잡한 오류 복구를 구현하기 어렵다는 인식이 있습니다. 하지만 이는 편견일 수 있습니다. 직접 구현하는 방식의 유연성 덕분에, 개발자가 세심하게 설계한다면 오히려 다른 파싱 기법보다 더 정교하고 의미 있는 오류 복구 메커니즘을 구축할 수 있습니다.

우리가 오늘 주목할 부분은 바로 이 마지막 오해를 불식시키는 것입니다. 다음 섹션에서는 재귀 하향 파싱의 유연성을 적극 활용하여 어떻게 강건한 에러 복구 시스템을 구축할 수 있는지 살펴보겠습니다.


3. 강건한 재귀 하향 파서 설계의 핵심 전략: 에러 복구

강건한 파서를 만드는 핵심은 에러 복구(Error Recovery) 전략에 있습니다. 즉, 문법 오류를 만났을 때 단순히 멈추지 않고, 어떻게든 현재 상황을 수습하고 파싱을 이어나가는 방법을 찾는 것입니다. 재귀 하향 파싱은 각 문법 규칙에 해당하는 함수 내에서 이러한 복구 로직을 직접 구현할 수 있다는 점에서 큰 강점을 가집니다.

여기 몇 가지 주요 에러 복구 전략이 있습니다:

3.1. 오류 보고 및 수집 (Error Reporting and Collection)

가장 기본적인 단계는 오류를 만났을 때 파서가 멈추는 대신, 오류 정보를 기록하고 계속 진행하는 것입니다.

  • 오류 리스트 유지: 파서 내부에 `List errors`와 같은 구조를 두고, 문법 오류가 발생할 때마다 해당 오류의 종류, 위치(줄 번호, 컬럼), 예상 토큰 등의 정보를 담아 리스트에 추가합니다.
  • 오류 플래그: 현재 파서가 오류 복구 상태에 있는지 나타내는 플래그를 두어, 중복된 오류 메시지를 피하거나 특정 복구 로직을 활성화할 수 있습니다.

3.2. 패닉 모드 복구 (Panic Mode Recovery)

가장 간단한 에러 복구 전략입니다. 오류를 발견하면, 파서는 현재 오류가 발생한 지점부터 특정 ‘동기화 토큰(Synchronization Token)’을 만날 때까지 입력을 건너뜁니다. 동기화 토큰은 대개 문장의 끝을 나타내는 세미콜론(;), 블록의 시작/끝을 나타내는 괄호({, }), 함수 정의의 시작(func) 등 다음 문법 단위가 시작될 것이라고 예상되는 토큰을 의미합니다.

  • 동작 방식:

    1. 현재 파싱 중인 함수에서 오류 발생.
    2. skip_until_sync_token(expected_tokens)와 같은 헬퍼 함수 호출.
    3. 이 함수는 expected_tokens 중 하나를 만날 때까지 입력 토큰을 계속해서 버립니다.
    4. 동기화 토큰을 찾으면, 파서는 그 토큰부터 다시 정상적인 파싱을 시도합니다.
  • 장점: 구현이 간단합니다.

  • 단점: 오류가 발생한 부분부터 동기화 토큰까지의 유효한 코드를 통째로 건너뛸 수 있어, 중요한 정보가 손실되거나 추가적인 오류가 보고되지 않을 수 있습니다.

3.3. 구문 수준 복구 (Phrase-Level Recovery)

패닉 모드보다 좀 더 정교한 방법으로, 특정 문법 규칙 내에서 예상되는 토큰이 없을 때, 그 토큰을 ‘삽입(insertion)’하거나 잘못된 토큰을 ‘삭제(deletion)’하는 방식으로 복구를 시도합니다.

  • 토큰 삽입: 예를 들어, if (cond) { ... 와 같이 조건문 뒤에 {가 예상되는데 없다면, 파서는 내부적으로 {가 있는 것으로 가정하고 파싱을 진행합니다. 물론, 오류 목록에는 {가 누락되었다고 기록합니다.
  • 토큰 삭제: int ; x = 10;과 같이 문법적으로 잘못된 토큰(여기서는 세미콜론)이 갑자기 나타났을 때, 이를 버리고 다음 예상 토큰으로 넘어갑니다.

재귀 하향 파싱에서는 각 파싱 함수 내에서 if (current_token != expected_token)과 같은 조건문을 통해 이러한 삽입/삭제 로직을 구현할 수 있습니다. 예를 들어, parse_statement() 함수에서 세미콜론이 예상되는데 없다면, “세미콜론 누락” 오류를 기록하고 다음 문장으로 넘어가는 식입니다.

3.4. 오류 노드 삽입 (Error Node Insertion in AST)

앞선 방법들이 주로 토큰 스트림을 다루는 반면, 이 방법은 결과물인 AST 자체에 오류를 표현하는 노드를 삽입합니다.

  • 오류 발생 시: 파싱 함수 내에서 문법 오류가 발생하면, 해당 부분을 나타내는 특별한 ‘오류 노드(Error Node)’를 AST에 추가합니다. 이 오류 노드에는 잘못된 토큰 시퀀스, 예상되었던 토큰, 오류 메시지 등이 포함될 수 있습니다.
  • 장점: AST는 여전히 완전한 구조를 가지게 되므로, IDE는 오류가 있는 부분에 대해서도 기본적인 구조를 파악하고 다른 유효한 노드에 대한 추가적인 분석(예: 심볼 테이블 구축, 타입 검사)을 수행할 수 있습니다. 또한, 사용자에게 오류를 시각적으로 더 명확하게 보여줄 수 있습니다.

재귀 하향 파싱과 에러 복구의 시너지

재귀 하향 파싱의 큰 장점 중 하나는 개발자가 파서의 각 부분을 직접 제어할 수 있다는 것입니다. 이는 위에서 설명한 다양한 에러 복구 전략들을 각 문법 규칙의 특성에 맞게 세밀하게 적용할 수 있음을 의미합니다.

예를 들어, 선언문의 끝에서는 세미콜론을 동기화 토큰으로 사용하고, if 문의 블록 시작에서는 {를 삽입하는 방식으로 복구를 시도하는 등, 문법의 구조에 따라 유연하게 대처할 수 있습니다. 각 파싱 함수는 자신의 컨텍스트 내에서 가장 적절한 복구 방법을 선택하고 실행할 수 있으므로, 최종적으로는 오류가 발생하더라도 가능한 한 많은 정보를 복구하고 의미 있는 AST를 구축할 수 있게 됩니다.


4. 실제 적용 사례와 장점

강건한 파서재귀 하향 파싱 기반의 에러 복구 전략은 단순한 이론을 넘어 실제 소프트웨어 개발 환경에서 광범위하게 사용되며 그 가치를 증명하고 있습니다.

4.1. 통합 개발 환경(IDE)의 핵심

우리가 매일 사용하는 Visual Studio Code, IntelliJ IDEA, Eclipse 등 모든 현대 IDE는 강건한 파서를 핵심 구성 요소로 사용합니다.

  • 실시간 오류 진단: 코드를 입력하는 순간 발생하는 문법 오류를 즉시 감지하고 표시합니다. 이는 파서가 오류를 만나도 멈추지 않고 계속 분석하기 때문에 가능한 일입니다.
  • 스마트 자동 완성 및 코드 어시스트: 개발자가 코드를 미완성 상태로 두거나 오류를 포함한 상태에서도, IDE는 가능한 범위 내에서 변수 이름, 함수 호출, 클래스 멤버 등에 대한 정확한 자동 완성을 제안합니다. 이는 오류 노드를 포함하더라도 AST의 유효한 부분을 바탕으로 문맥을 파악하기 때문입니다.
  • 정확한 구문 강조(Syntax Highlighting): 오류가 있는 줄에서도 유효한 키워드, 문자열, 주석 등을 올바르게 색칠합니다.
  • 리팩토링 및 탐색 기능: 오류가 있는 파일 내에서도 함수 정의로 이동하거나, 변수 사용처를 찾거나, 이름을 변경하는 등의 리팩토링 기능을 제공합니다. 이는 오류 부분이 아닌 다른 유효한 AST 노드를 기반으로 이루어집니다.

4.2. 컴파일러 및 린터의 오류 보고 기능 향상

전통적인 컴파일러는 첫 번째 오류에서 멈추는 경향이 있었지만, 현대 컴파일러는 여러 오류를 동시에 보고하여 개발자가 한 번의 컴파일로 여러 문제를 해결할 수 있도록 돕습니다.

  • 다중 오류 보고: 강건한 파서는 코드 전체를 스캔하며 발견된 모든 문법 오류를 수집하여 보고합니다. 이는 개발자가 오류 수정에 드는 시간을 절약하게 해줍니다.
  • 의미 분석 지속: 파싱 단계에서 오류가 발생하더라도, 파서가 오류 노드를 포함한 AST를 생성하면, 후속 단계인 의미 분석기(Semantic Analyzer)는 AST의 유효한 부분에 대해 타입 검사, 심볼 확인 등을 계속 수행할 수 있습니다.

4.3. 도메인 특화 언어(DSL) 도구 개발

특정 도메인에 특화된 언어(DSL)를 만들 때, 재귀 하향 파싱과 강건한 에러 복구는 매우 유용한 조합입니다.

  • 유연한 문법 정의: 직접 파서를 작성하므로, 표준 언어에는 없는 특이한 문법 규칙이나 구문을 쉽게 정의하고 처리할 수 있습니다.
  • 사용자 친화적인 도구: DSL을 사용하는 사용자가 문법 오류를 저질렀을 때, 일반적인 컴파일러처럼 딱딱한 오류 메시지를 던지는 대신, IDE와 유사한 경험을 제공하는 강력한 도구를 만들 수 있습니다.

강건한 파서의 궁극적인 장점

  • 개발자 경험(DX) 향상: 오류에도 불구하고 지속적인 피드백과 도움을 제공함으로써 개발자가 더욱 빠르고 효율적으로 작업할 수 있게 합니다.
  • 더 나은 진단: 오류의 위치와 종류를 정확하게 파악하고, 심지어 오류가 발생했을 때 파서가 ‘무엇을 기대했는지’까지 알려줌으로써 디버깅 과정을 단축시킵니다.
  • 고급 코드 분석 가능: 불완전하거나 오류가 있는 코드에 대해서도 심층적인 분석을 수행할 수 있는 기반을 마련합니다.

결론: 재귀 하향 파싱의 숨겨진 잠재력을 깨우다

지금까지 파싱의 기본 개념부터 시작하여, 흔히 간과될 수 있는 재귀 하향 파싱의 강력한 잠재력, 특히 강건한 파서를 구축하기 위한 에러 복구 전략에 대해 상세히 알아보았습니다. 재귀 하향 파싱은 그 직관적인 구현 방식과 유연성 덕분에 개발자가 원하는 대로 세밀한 제어가 가능하며, 이를 통해 오류를 만나도 굴하지 않고 끝까지 코드를 분석하는 스마트한 시스템을 만들 수 있음을 확인했습니다.

현대의 소프트웨어 개발 환경은 오류에 강하고 사용자에게 친화적인 도구를 요구합니다. IDE의 실시간 코드 분석부터 컴파일러의 다중 오류 보고, 그리고 DSL을 위한 맞춤형 도구에 이르기까지, 강건한 파서는 우리가 코딩하는 방식의 근간을 이루고 있습니다. 그리고 이러한 강건함을 구현하는 데 있어, 재귀 하향 파싱은 여전히 매력적이고 강력한 선택지가 될 수 있습니다.

만약 여러분이 새로운 프로그래밍 언어를 만들거나, 특정 도메인에 특화된 스크립트 언어의 인터프리터를 개발하거나, 혹은 기존 코드 베이스에 대한 심층적인 분석 도구를 구축할 계획이라면, 재귀 하향 파싱을 다시 한번 진지하게 고려해보세요. 단순함 속에 숨겨진 그 무한한 가능성이 여러분의 프로젝트에 견고함과 유연성을 동시에 선사할 것입니다. 오류에도 멈추지 않는 코드 분석의 세계, 이제 여러분의 손으로 직접 만들어갈 차례입니다.

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다

You can use the Markdown in the comment form.

Translate »