Go 언어의 심장을 파헤치다 추상 구문 트리(AST) 완벽 가이드

Go 언어의 심장을 파헤치다 추상 구문 트리(AST) 완벽 가이드

코드를 코드로서 바라보는 새로운 시각

우리가 매일 작성하는 코드는 컴퓨터에게는 실행해야 할 명령어의 집합이지만, 개발 도구에게는 분석하고, 변형하고, 최적화해야 할 하나의 '데이터 구조'입니다.

Go 언어의 강력함과 효율성의 중심에는 바로 이 '코드를 데이터로 바라보는 능력'이 자리 잡고 있으며, 그 핵심에 '추상 구문 트리(AST, Abstract Syntax Tree)'가 있습니다.

아마 많은 개발자분들이 AST라는 용어를 들어보셨을 겁니다.

하지만 그것이 정확히 무엇인지, 그리고 왜 중요한지, 특히 Go 언어 생태계에서 어떤 강력한 역할을 하는지에 대해 깊이 있게 이해할 기회는 많지 않았을 것입니다.

이 글에서는 Go의 AST가 무엇인지부터 시작하여, 어떻게 소스 코드를 AST로 변환하고, 그 구조를 탐색하며, 이를 통해 어떤 강력한 도구들을 만들 수 있는지 실용적인 예제와 함께 단계적으로 파헤쳐 보고자 합니다.

이 글을 끝까지 읽으시면, 여러분은 더 이상 코드를 단순히 실행되는 대상으로만 보지 않고, 그 구조를 이해하고 조작할 수 있는 새로운 차원의 시각을 갖게 될 것입니다.

추상 구문 트리란 무엇인가

추상 구문 트리(AST)는 말 그대로 소스 코드의 '문법적 구조'를 '추상화'하여 '트리' 형태로 표현한 것입니다.

여기서 '추상화'란, 코드의 본질적인 구조와는 관계없는 세부적인 요소들, 예를 들어 괄호 `()`, 세미콜론 `;`, 주석, 공백 등을 제거하고, 오직 코드의 논리적인 구조와 의미만을 남기는 과정을 의미합니다.

예를 들어 `a = b + 2` 라는 간단한 코드가 있다면, 이를 AST로 표현하면 다음과 같은 트리 구조가 됩니다.

    = (할당)
   / \
  a   + (덧셈)
     / \
    b   2




이처럼 AST는 코드의 각 구성 요소(변수, 연산자, 리터럴 등)를 '노드(Node)'로 만들고, 이들 간의 관계를 부모-자식 관계의 '트리'로 표현합니다.

덕분에 우리는 복잡한 텍스트 덩어리였던 소스 코드를, 명확한 위계질서를 가진 구조화된 데이터로 다룰 수 있게 됩니다.

Go 언어에서는 go/ast 패키지를 통해 이 모든 것을 프로그래밍적으로 제어할 수 있는 강력한 기능을 표준 라이브러리로 제공합니다.

Go 생태계의 심장 AST는 왜 중요한가

Go 언어의 개발 도구 생태계는 AST 위에 세워졌다고 해도 과언이 아닙니다.

우리가 매일같이 사용하는 수많은 도구들이 바로 이 AST를 기반으로 동작합니다.

- **코드 포매팅 (`go fmt`)**: `gofmt`는 Go 코드의 스타일을 일관되게 유지해 주는 마법 같은 도구입니다.

이 도구는 소스 코드를 AST로 파싱한 다음, 정해진 규칙에 따라 AST를 다시 소스 코드로 출력하는 방식으로 동작합니다.

이 과정에서 개발자가 어떻게 코드를 작성했든(들여쓰기, 줄 바꿈 등), 항상 표준화된 스타일의 코드가 만들어집니다.

AST를 사용하기 때문에 코드의 '의미'는 전혀 바꾸지 않으면서 '형태'만 바꿀 수 있는 것입니다.

- **정적 분석 (`go vet`, `golangci-lint`)**: `go vet`과 같은 정적 분석 도구들은 잠재적인 버그나 비효율적인 코드 패턴을 찾아냅니다.

이 도구들은 AST를 순회하며 '함수의 인자 개수가 잘못된 호출', '도달할 수 없는 코드' 등 특정 패턴의 노드들을 찾아내어 개발자에게 경고합니다.

- **코드 리팩토링 및 생성**: AST를 활용하면 대규모 코드베이스를 자동화된 방식으로 리팩토링할 수 있습니다.

예를 들어, 오래된 함수 이름을 새로운 이름으로 안전하게 바꾸거나, 특정 구조의 코드를 더 효율적인 형태로 일괄 변경하는 스크립트를 작성할 수 있습니다.

또한, 데이터베이스 스키마나 API 명세로부터 Go 코드를 자동으로 생성하는 도구들도 모두 AST를 동적으로 구축하고 이를 코드로 출력하는 원리를 사용합니다.

이처럼 AST는 Go 언어의 생산성과 안정성을 떠받치는 매우 중요한 기반 기술입니다.

Go 코드를 AST로 파싱하기

그렇다면 이제 직접 Go 코드를 AST로 변환하는 과정을 살펴보겠습니다.

Go는 이를 위해 `go/parser`와 `go/token`이라는 두 가지 핵심 패키지를 제공합니다.

package main

import (
    "fmt"
    "go/parser"
    "go/token"
    "log"
)

func main() {
    // 분석할 Go 소스 코드
    src := `
package main

import "fmt"

func main() {
    message := "Hello, AST!"
    fmt.Println(message)
}
`
    // 파일셋(FileSet) 생성. 토큰의 위치 정보를 관리합니다.
    fset := token.NewFileSet()

    // 소스 코드를 파싱하여 AST를 생성합니다.
    // parser.ParseFile(파일셋, 파일명, 소스, 파싱모드)
    node, err := parser.ParseFile(fset, "example.go", src, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
    }

    // 생성된 AST의 루트 노드는 *ast.File 타입입니다.
    fmt.Printf("Package Name: %s\n", node.Name.Name)
}




위 코드를 실행하면 Package Name: main 이라는 결과가 출력됩니다.

코드를 단계별로 살펴보겠습니다.

  1. token.NewFileSet(): FileSet은 파싱 과정에서 각 토큰(키워드, 식별자, 연산자 등)의 위치 정보(파일, 줄, 열)를 관리하는 중요한 역할을 합니다.

    모든 파싱 작업의 시작점이라고 할 수 있습니다.

  2. parser.ParseFile(): 이 함수가 실질적인 파싱을 수행합니다.

    FileSet, 파일명(없으면 빈 문자열), 파싱할 소스 코드, 그리고 파싱 모드를 인자로 받습니다.

    여기서 parser.ParseComments 모드는 주석도 함께 파싱하도록 지시하는 옵션입니다.

    성공적으로 파싱이 완료되면, 해당 파일의 AST 최상위 노드인 *ast.File 타입의 객체를 반환합니다.

  3. node.Name.Name: 반환된 node는 파일 전체를 나타내는 AST의 루트입니다.

    이 노드는 패키지 이름, 임포트 선언, 함수 선언 등 다양한 자식 노드들을 가지고 있습니다.

    node.Name은 패키지 이름을 나타내는 *ast.Ident(Identifier, 식별자) 노드이며, .Name 속성을 통해 실제 패키지 이름 문자열('main')에 접근할 수 있습니다.

AST 트리 순회하고 분석하기

AST를 생성했다면, 이제 트리를 순회하며 우리가 원하는 정보를 찾아내야 합니다.

마치 DOM 트리를 탐색하는 것처럼, AST 트리도 깊이 우선 탐색(Depth-First Search) 방식으로 순회할 수 있습니다.

Go에서는 `ast.Inspect` 함수를 사용하면 이 과정을 매우 편리하게 수행할 수 있습니다.

package main

import (
    "fmt"
    "go/ast"
    "go/parser"
    "go/token"
    "log"
)

func main() {
    src := `
package main

import "fmt"

// 이 함수는 메시지를 출력합니다.
func main() {
    message := "Hello, AST!"
    fmt.Println(message)
}
`
    fset := token.NewFileSet()
    node, err := parser.ParseFile(fset, "example.go", src, parser.ParseComments)
    if err != nil {
        log.Fatal(err)
    }

    // ast.Inspect를 사용하여 모든 노드를 순회합니다.
    ast.Inspect(node, func(n ast.Node) bool {
        // 노드가 nil이면 순회를 계속하지 않습니다.
        if n == nil {
            return false
        }

        // 타입 스위치를 사용하여 노드의 실제 타입을 확인합니다.
        switch x := n.(type) {
        case *ast.FuncDecl:
            // 함수 선언 노드를 찾았습니다.
            fmt.Printf("Found Function Declaration: %s\n", x.Name.Name)
        case *ast.BasicLit:
            // 기본 리터럴(문자열, 숫자 등) 노드를 찾았습니다.
            fmt.Printf("Found Literal: %s at position %d\n", x.Value, fset.Position(x.Pos()).Line)
        case *ast.Ident:
            // 식별자(변수명, 함수명 등) 노드를 찾았습니다.
            fmt.Printf("Found Identifier: %s\n", x.Name)
        }

        // true를 반환하면 자식 노드로 계속 순회를 진행합니다.
        // false를 반환하면 해당 노드의 자식들은 더 이상 방문하지 않습니다.
        return true
    })
}




ast.Inspect는 루트 노드부터 시작하여 모든 자식 노드를 방문하며 우리가 전달한 함수를 실행합니다.

콜백 함수는 ast.Node 인터페이스 타입의 인자를 받는데, 우리는 '타입 스위치(Type Switch)'를 사용해 이 노드의 구체적인 타입(*ast.FuncDecl, *ast.BasicLit 등)을 알아내고 그에 맞는 처리를 할 수 있습니다.

콜백 함수가 true를 반환하면 계속해서 더 깊은 자식 노드로 탐색을 이어가고, false를 반환하면 현재 노드의 자식들은 건너뛰게 되어 특정 하위 트리의 탐색을 중단할 때 유용하게 사용할 수 있습니다.

코드를 넘어 코드를 만드는 경지로

Go의 AST를 이해하고 다룰 수 있다는 것은, 단순히 코드를 분석하는 것을 넘어 '코드를 생성하고 변형하는' 강력한 능력을 갖게 됨을 의미합니다.

예를 들어, 모든 함수의 시작 부분에 특정 로그를 남기는 코드를 자동으로 삽입하는 도구를 만든다고 상상해 보십시오.

우리는 `ast.Inspect`로 모든 `*ast.FuncDecl` 노드를 찾고, 각 함수의 `Body`(`*ast.BlockStmt`) 노드의 문장 목록 맨 앞에 새로운 로그 출력문(`fmt.Println(...)`에 해당하는 AST 노드)을 추가하면 됩니다.

그 후, 수정된 AST를 `go/printer` 패키지를 사용해 다시 소스 코드로 출력하면 우리의 임무는 완수됩니다.

이것이 바로 '코드 생성(Code Generation)'과 '자동 리팩토링(Automated Refactoring)'의 핵심 원리입니다.

이러한 원리를 응용하면 커스텀 린터(linter) 제작, API 문서 자동 생성, 특정 패턴의 코드를 최적화된 코드로 자동 변환하는 등 무궁무진한 가능성이 열립니다.

Go의 AST는 우리에게 언어의 소비자를 넘어, 언어의 도구를 만드는 생산자가 될 수 있는 길을 열어줍니다.

이제 여러분도 Go 코드의 내부를 들여다보고, 그 구조를 이해하며, 필요에 따라 그것을 재구성하는 강력한 개발자로 거듭날 준비가 되었습니다.